A dev-only module that subscribes to QueryCache events and groups fetches into waterfall phases. Each phase is a cluster of queries that started within 50ms of each other. Multiple phases mean sequential Suspense waterfalls — the profiler tells you exactly where beforeLoad prefetches are missing.
No instrumentation in components. No wrapper hooks. Zero production overhead.
Core
type TimingEntry = {
queryKey: readonly unknown[];
startedAt: number;
settledAt?: number;
status: "pending" | "success" | "error";
};
type WaterfallGroup = {
startedAt: number;
entries: TimingEntry[];
};
const GROUP_GAP_MS = 50;
function buildWaterfallGroups(entries: TimingEntry[]): WaterfallGroup[] {
const sorted = [...entries].sort((a, b) => a.startedAt - b.startedAt);
const groups: WaterfallGroup[] = [];
for (const entry of sorted) {
const last = groups[groups.length - 1];
const lastStart = last?.entries.at(-1)?.startedAt ?? -Infinity;
if (!last || entry.startedAt - lastStart > GROUP_GAP_MS) {
groups.push({ startedAt: entry.startedAt, entries: [entry] });
} else {
last.entries.push(entry);
}
}
return groups;
}
Initialization
Subscribe to the QueryCache observer once, before the first route loads:
function initQueryTiming(client: QueryClient) {
const entries = new Map<string, TimingEntry>();
client.getQueryCache().subscribe((event) => {
const key = JSON.stringify(event.query.queryKey);
if (event.type === "observerResultsUpdated") {
if (event.query.state.fetchStatus === "fetching") {
entries.set(key, { queryKey: event.query.queryKey, startedAt: Date.now(), status: "pending" });
} else {
const entry = entries.get(key);
if (entry?.status === "pending") {
entry.settledAt = Date.now();
entry.status = event.query.state.status === "error" ? "error" : "success";
}
}
}
});
window.__QUERY_TIMING = {
snapshot: () => buildWaterfallGroups([...entries.values()]),
clear: () => entries.clear(),
};
}
Triggering from a script
The profiler activates via a localStorage flag — set it before navigating so recording begins from the first cache event:
// pnpm measure script (Node, runs in a Playwright context)
await page.evaluate(() => localStorage.setItem("__QUERY_TIMING_AUTO_RECORD", "true"));
await page.goto(route);
await page.waitForLoadState("networkidle");
const groups = await page.evaluate(() => window.__QUERY_TIMING.snapshot());
console.log(`${groups.length} waterfall phase(s)`);
for (const [i, g] of groups.entries()) {
const dur = Math.max(...g.entries.map(e => (e.settledAt ?? Date.now()) - e.startedAt));
console.log(` Phase ${i + 1}: ${g.entries.length} queries, ${dur}ms`);
for (const e of g.entries) console.log(` ${JSON.stringify(e.queryKey)}`);
}
In queryClient.ts, check the flag synchronously:
if (import.meta.env.DEV && localStorage.getItem("__QUERY_TIMING_AUTO_RECORD")) {
initQueryTiming(queryClient);
}
Reading the output
3 waterfall phase(s)
Phase 1: 2 queries, 120ms
["session"]
["user","me"]
Phase 2: 1 queries, 340ms
["dashboard","overview"]
Phase 3: 3 queries, 80ms
["widgets",{"dashboardId":"d1"}]
["widgets",{"dashboardId":"d1"},"counts"]
["user-preferences"]
3 phases = 2 avoidable Suspense cascades. Phase 2 fired after phase 1 settled because its component was inside a Suspense boundary and the query wasn’t prefetched in beforeLoad. Move it to beforeLoad → phases collapse to 1.
Rules
- 50ms gap constant is the only magic number. Tune to your network latency.
- Never run in production — guard with
import.meta.env.DEV. - Only
snapshot()andclear()need to be onwindow— don’t expose the raw entries map. - One phase is ideal. Two is acceptable. Three or more is always worth fixing.