一、现象与背景:后端说很快,DevTools 说很快,用户说很卡
先摆数字。我们一个客服工作台(React 18.2.39 + Vite 5.4 + TypeScript 5.5,约 38 个业务页面,DAU 约 1.9 万,单会话并发接口 6~10 个):
- 后端网关日志:核心接口
/api/session/messages等 5 个接口的 P99 = 180ms,P50 = 45ms,错误率 0.02%,一切「健康」; - 用户侧投诉:「点『发送』到消息上屏,经常要等 1 秒多,偶发 2 秒」,一周内 17 条工单;
- 开发自查:DevTools → Network 面板,每条请求的 Time 列 120~200ms,Waterfall 干净;Performance 面板看不出和「点发送」动作的对应关系;
- 后端同学抓链路:网关 → 服务 → DB 全链路 trace 合计 90ms。
结论错位:用户感知的 1.5s 里,有约 1.2s 发生在「响应在网络层已经到达」到「业务代码真正读到这个响应」之间。 这段延迟不在后端 trace 里,不在 DevTools Network 面板里,也不在任何一条现成的 Web Vitals 指标里。
规模感:按会话内平均 8 个请求、单会话 2 小时估算,全量用户每天产生约 2.4 万次这类「网络很快但反馈很慢」的事件,其中 P95 超过 800ms 的约占 31%。
二、根因分析:三个「终点」错位,盲区是结构性的
把一次点击后的完整链路拆成四段,问题出在第 3 段没有任何人给它定义指标:
T0 业务发起 fetch
T1 网络层完成响应传输(Resource Timing 的 endTime 在这里)
T2 主线程从任务队列中取出该响应的 continuation(微任务 dispatch)
T3 业务代码处理完(setState / 渲染 / 上屏)
- Resource Timing 的终点是 T1,不是 T2。
PerformanceResourceTiming规范里responseEnd定义的是网络层完成响应传输的时刻,之后 JS 拿到 Promise 的时机由主线程调度决定。主线程被一个 800ms 的长任务占着(比如某 SDK 在DOMContentLoaded前同步跑序列化、或一个 30 万字节的JSON.parse,这两类我都见过线上案例),响应就在队列里干等 800ms——而 Resource Timing 记录的分秒不差仍是 180ms。 - DevTools 的 Network 面板只画到 T1。 Time 列 =
responseEnd - startTime,T1→T2 这段对它是不可见的。所以「Network 全绿」和「用户觉得慢」可以同时为真,二者不矛盾。 - 后端指标的定义域根本不含浏览器。 网关 P99 度量的是服务端从收到请求到写完响应,T1 之后 1 毫秒都不归它管。
所以这不是「哪里配错了」,而是观测体系的三个组件(后端 trace / Resource Timing / 用户感知)在 T1→T2 这一段集体失明。反直觉点在于:越是「并发请求多 + 主线程重」的页面,盲区越深,而这类页面的用户恰恰最敏感。
三、具体做法:3 层打点,把 T1→T2 变成可上报的指标
第 1 层:fetch 包装层,直接量化 queueDelay
核心指标只有一个:queueDelay = 业务读到响应的时刻 − 网络层完成时刻。
// fetch-trace.ts:全局包装一次
const nativeFetch = window.fetch;
type TraceEntry = {
url: string;
t0: number; // 发起时刻
t2: number | null; // 业务读到时刻
};
const traces = new Map<string, TraceEntry>();
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const t0 = performance.now();
const resp = await nativeFetch.call(this, input, init);
// 关键:克隆一份,业务侧读 clone 时才打点 t2
const clone = resp.clone();
const key = `${url}@${t0}`;
traces.set(key, { url, t0, t2: null });
const body = await clone.text(); // 在包装层先消费一次,拿到「可读」时刻
const t2 = performance.now();
const entry = traces.get(key);
if (entry) entry.t2 = t2;
reportQueueDelay(url, t0, t2); // 见下方上报
return new Response(body, { status: resp.status, statusText: resp.statusText, headers: resp.headers });
};
function reportQueueDelay(url: string, t0: number, t2: number) {
// 用 Resource Timing 拿 T1(网络完成时刻)
const entry = performance.getEntriesByType('resource')
.filter(e => e.name.includes(url))
.sort((a, b) => b.startTime - a.startTime)[0];
if (!entry) return;
const queueDelay = Math.max(0, t2 - entry.responseEnd);
// 上报(走 sendBeacon 批量,字段:url, queueDelay, t2-t0, responseEnd)
queue.push({ url, queueDelay: Math.round(queueDelay), total: Math.round(t2 - t0) });
}
注意一个细节:performance.getEntriesByType('resource') 在弱网/慢响应下条目可能还没进缓冲区,上面按 startTime 倒排取最近一条是经验做法;更稳的是监听 PerformanceObserver('resource', { buffered: true }) 后按 URL + 发起时刻窗口匹配。
第 2 层:把 queueDelay 和 Long Task 做归因关联
const longTasks: Array<{ start: number; duration: number }> = [];
new PerformanceObserver(list => {
for (const e of list.getEntries()) {
longTasks.push({ start: e.startTime, duration: e.duration });
}
}).observe({ type: 'long-task', buffered: true });
// 对每个 queueDelay 样本,计算它落在哪些长任务区间内
function attributedMs(url: string, t1: number, t2: number): number {
return longTasks.reduce((acc, lt) => {
const overlap = Math.max(0, Math.min(t2, lt.start + lt.duration) - Math.max(t1, lt.start));
return acc + overlap;
}, 0);
}
上报 queueDelay 的同时带上 attributedMs,服务端聚合后可以直接回答「这 800ms 里有 760ms 是被长任务占掉的,还是真在调度排队」。
第 3 层:rAF 探针,分离「调度排队」与「同步处理」
T1→T2 内部可以再拆。在包装层读到响应后、业务处理前,插一帧:
const t2a = performance.now(); // 微任务已 dispatch
await new Promise(r => requestAnimationFrame(r)); // 等一次主线程空闲
const t2b = performance.now(); // 主线程让出过一次
// 调度排队 ≈ t2a - T1;后续同步处理 ≈ t2b - t2a(含业务侧 JSON.parse 等)
三层合起来,一个样本最终长这样(真实聚合数据节选):
{
"url": "/api/session/messages",
"queueDelay": 912,
"attributedMs": 861,
"rafGap": 43,
"responseEnd": 176
}
四、踩过的坑与反直觉之处
resp.clone()的消费时机决定打点准不准。 最初把t2打在包装层await resp.text()之前(即nativeFetchresolve 的时刻),测出来 queueDelay 全部 <5ms——因为fetch的 Promise resolve 本身就在网络完成时触发,真正被主线程卡住的是「你的业务代码执行到那行」,而不是 fetch 内部。打点位置差一行,结论差 900ms。responseEnd不是你想的「拿到数据」。 拿 30 万字节响应时,responseEnd之后 body 的读取/解析仍要花时间,但这段被算进「业务处理」而非「网络」。把 body 解析慢当成「接口慢」去催后端,是纯浪费——解析在主线程,属于前端自己的账(长任务归因那层就是为此设的)。- 长任务占比高 ≠ 都是「坏」长任务。 我们最后定位到的元凶里,一个 720ms 的长任务来自一个第三方客服 SDK 在
DOMContentLoaded前同步初始化(和我们之前某次 SDK 初始化 8ms 涨到 220ms 拖 LCP 是同一类问题)。治理方式不是删代码,而是给 SDK 初始化加requestIdleCallback兜底 + 发版门禁:初始化同步耗时 >50ms 直接 CI 卡住。 - 弱网会污染归因。 弱网下 T1 本身很晚,
queueDelay反而小(主线程没活干,响应一到就 dispatch)。所以看板必须双列展示:responseEnd和queueDelay分开看,只报t2 - t0总时长会再次把两种病因混在一起。 - HTTP/2 多路复用救不了这段。 有人建议「把接口拆小、并发拉高」,实测无效——瓶颈在单线程调度,不在并发度。
五、结论与可复用清单
一句话结论:T1→T2(网络完成 → 主线程读到)是 Web 可观测性的结构性盲区,后端 P99、Resource Timing、Web Vitals 三者都不覆盖它;给它建一个独立指标(queueDelay)并做长任务归因,是「后端说快、用户说慢」类投诉的最快定位路径。
可复用清单:
- 打点:fetch 包装层记
t0 / t2,用 Resource Timing 的responseEnd做 T1,上报queueDelay = t2 - responseEnd;打点位置必须在「业务真正消费 body」之后,不能在 fetch resolve 处。 - 归因:
long-taskPerformanceObserver 收集长任务区间,对每个样本算 overlap,上报attributedMs;attributedMs / queueDelay > 60%的样本直接指向主线程占用(序列化、SDK 同步初始化、大列表 diff 等)。 - 拆分:rAF 探针分离调度排队与同步处理,避免把「解析慢」误报成「排队慢」。
- 门禁:SDK/三方库初始化同步耗时进 CI 阈值(我们取 50ms);单次
JSON.parse输入体积 >100KB 的接口,强制走 Worker 或流式解析。 - 看板:
responseEnd与queueDelay双列展示、分位数 P50/P95/P99 各自独立告警,不要只报总时长。 - 排查顺序:用户反馈「接口慢」时,先看后端 P99,再看
responseEnd,最后看queueDelay——三段里恰好只有一段慢,指哪儿修哪儿,不再三边互猜。