Skip to content

Commit d8f4db7

Browse files
committed
add parallel blocking fetches detection
1 parent ad4f5ce commit d8f4db7

File tree

2 files changed

+300
-255
lines changed

2 files changed

+300
-255
lines changed

packages/next/src/client/waterfall-detector.ts

Lines changed: 53 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
/**
2-
* Waterfall Detection for Next.js Insights Builds
2+
* Waterfall Detection Client for Next.js Insights Builds
33
*
4-
* This module detects fetch waterfalls during initial page load by:
5-
* 1. Patching globalThis.fetch with artificial delays to amplify timing relationships
6-
* 2. Hooking into React DevTools to track commits
7-
* 3. Analyzing the timeline to find: fetch.end → commit → fetch.start chains
4+
* This module collects raw timing data during initial page load:
5+
* 1. Patches globalThis.fetch to add artificial delays and capture timing
6+
* 2. Hooks into React DevTools to track commits
7+
* 3. Sends raw data to the server for analysis
88
*
9+
* All analysis logic is on the server side.
910
* Only active when process.env.__NEXT_INSIGHTS_BUILD is true
1011
*/
1112

@@ -24,7 +25,6 @@ interface FetchEvent {
2425
endTime: number
2526
artificialDelay: number
2627
stackTrace: string
27-
error: Error // Store the actual Error object for browser DevTools
2828
parsedFrames: StackFrame[]
2929
}
3030

@@ -36,7 +36,6 @@ interface CommitEvent {
3636
const FETCH_DELAY_MIN = 1000 // Minimum artificial delay
3737
const FETCH_DELAY_MAX = 3000 // Maximum artificial delay
3838
const FETCH_DELAY_STEP = 500 // Delay increments (1000, 1500, 2000, 2500, 3000)
39-
const PROXIMITY_THRESHOLD = 100 // ms to consider events causally related
4039
const IDLE_TIMEOUT = 4000 // Consider page idle after 4s of no fetch activity (> max delay)
4140
const MAX_ANALYSIS_WINDOW = 60000 // Stop analyzing after 60s regardless
4241

@@ -91,11 +90,7 @@ function patchFetch() {
9190
}
9291

9392
// Capture stack trace synchronously BEFORE any await
94-
const {
95-
error,
96-
stack: stackTrace,
97-
frames: parsedFrames,
98-
} = captureStackTrace()
93+
const { stack: stackTrace, frames: parsedFrames } = captureStackTrace()
9994
const startTime = performance.now()
10095
const delay = getRandomDelay()
10196

@@ -119,7 +114,6 @@ function patchFetch() {
119114
endTime,
120115
artificialDelay: delay,
121116
stackTrace,
122-
error,
123117
parsedFrames,
124118
})
125119

@@ -138,7 +132,6 @@ function patchFetch() {
138132
endTime,
139133
artificialDelay: delay,
140134
stackTrace,
141-
error,
142135
parsedFrames,
143136
})
144137
pendingFetches--
@@ -189,8 +182,8 @@ function setupReactHook() {
189182
}
190183

191184
/**
192-
* Reset the idle timer - triggers analysis when page becomes idle
193-
* Waits for IDLE_TIMEOUT of no fetch activity before analyzing
185+
* Reset the idle timer - triggers sending data when page becomes idle
186+
* Waits for IDLE_TIMEOUT of no fetch activity before sending
194187
*/
195188
function resetIdleTimer() {
196189
if (hasAnalyzed) return
@@ -209,15 +202,15 @@ function resetIdleTimer() {
209202
// Wait for idle period with no new fetches
210203
idleTimer = setTimeout(() => {
211204
if (pendingFetches === 0 && !hasAnalyzed) {
212-
analyzeWaterfalls()
205+
sendRawData()
213206
}
214207
}, IDLE_TIMEOUT)
215208
}
216209

217210
/**
218-
* Analyze the collected fetch and commit events to detect waterfall patterns
211+
* Send raw timing data to the server for analysis
219212
*/
220-
async function analyzeWaterfalls() {
213+
async function sendRawData() {
221214
if (hasAnalyzed) return
222215
hasAnalyzed = true
223216
isInitialLoad = false
@@ -227,158 +220,45 @@ async function analyzeWaterfalls() {
227220
if (maxWindowTimer) clearTimeout(maxWindowTimer)
228221
hideWarningBanner()
229222

230-
if (fetchEvents.length === 0) {
231-
// Send report to server
232-
sendReportToServer({
233-
type: 'no-fetches',
234-
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
235-
timestamp: Date.now(),
236-
totalFetches: 0,
237-
totalCommits: commitEvents.length,
238-
renderTriggeringFetches: [],
239-
waterfallChains: [],
240-
})
241-
return
242-
}
243-
244-
// Sort events by time
223+
// Sort events by time before sending
245224
fetchEvents.sort((a, b) => a.startTime - b.startTime)
246225
commitEvents.sort((a, b) => a.time - b.time)
247226

248-
// First, identify fetches that have an associated React commit
249-
// (fetch.end → commit within threshold means this fetch triggered a re-render)
250-
const fetchesWithCommits = new Set<number>()
251-
for (const fetchEvent of fetchEvents) {
252-
const hasRelatedCommit = commitEvents.some(
253-
(commit) =>
254-
commit.time >= fetchEvent.endTime &&
255-
commit.time - fetchEvent.endTime <= PROXIMITY_THRESHOLD
256-
)
257-
if (hasRelatedCommit) {
258-
fetchesWithCommits.add(fetchEvent.id)
259-
}
260-
}
261-
262-
// Build waterfall chains using the causal relationship:
263-
// fetch.end → commit within threshold → fetch.start within threshold
264-
const chains: FetchEvent[][] = []
265-
const processedFetchIds = new Set<number>()
266-
267-
for (const fetch of fetchEvents) {
268-
if (processedFetchIds.has(fetch.id)) continue
269-
// Only start chains from fetches that trigger commits
270-
if (!fetchesWithCommits.has(fetch.id)) continue
271-
272-
// Start a new chain with this fetch
273-
const chain: FetchEvent[] = [fetch]
274-
processedFetchIds.add(fetch.id)
275-
276-
let currentFetchEndTime = fetch.endTime
277-
278-
// Try to extend the chain
279-
while (true) {
280-
// Find a commit that happened shortly after this fetch ended
281-
let relatedCommit: CommitEvent | undefined
282-
for (let i = 0; i < commitEvents.length; i++) {
283-
const commit = commitEvents[i]
284-
if (
285-
commit.time >= currentFetchEndTime &&
286-
commit.time - currentFetchEndTime <= PROXIMITY_THRESHOLD
287-
) {
288-
relatedCommit = commit
289-
break
290-
}
291-
}
292-
293-
if (!relatedCommit) break
294-
295-
// Find a fetch that started shortly after that commit
296-
let nextFetch: FetchEvent | undefined
297-
for (let i = 0; i < fetchEvents.length; i++) {
298-
const f = fetchEvents[i]
299-
if (
300-
!processedFetchIds.has(f.id) &&
301-
f.startTime >= relatedCommit.time &&
302-
f.startTime - relatedCommit.time <= PROXIMITY_THRESHOLD
303-
) {
304-
nextFetch = f
305-
break
306-
}
307-
}
308-
309-
if (!nextFetch) break
310-
311-
// Found a causal chain!
312-
chain.push(nextFetch)
313-
processedFetchIds.add(nextFetch.id)
314-
currentFetchEndTime = nextFetch.endTime
315-
}
316-
317-
// Only report chains with 2+ fetches (actual waterfalls)
318-
if (chain.length > 1) {
319-
chains.push(chain)
320-
}
227+
// Send raw data to server
228+
const report: RawWaterfallReport = {
229+
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
230+
timestamp: Date.now(),
231+
fetchEvents: fetchEvents.map((f) => ({
232+
id: f.id,
233+
url: f.url,
234+
startTime: f.startTime,
235+
endTime: f.endTime,
236+
artificialDelay: f.artificialDelay,
237+
stackTrace: f.stackTrace,
238+
parsedFrames: f.parsedFrames,
239+
})),
240+
commitEvents: commitEvents.map((c) => ({
241+
time: c.time,
242+
})),
321243
}
322244

323-
// Count fetches that triggered renders (these are the ones we care about)
324-
const renderTriggeringFetches = fetchEvents.filter((f) =>
325-
fetchesWithCommits.has(f.id)
326-
)
327-
328-
// Determine report type and send to server
329-
if (chains.length === 0 && renderTriggeringFetches.length === 0) {
330-
sendReportToServer({
331-
type: 'no-waterfall',
332-
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
333-
timestamp: Date.now(),
334-
totalFetches: fetchEvents.length,
335-
totalCommits: commitEvents.length,
336-
renderTriggeringFetches: [],
337-
waterfallChains: [],
338-
})
339-
} else if (chains.length === 0 && renderTriggeringFetches.length > 0) {
340-
sendReportToServer({
341-
type: 'render-blocking',
342-
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
343-
timestamp: Date.now(),
344-
totalFetches: fetchEvents.length,
345-
totalCommits: commitEvents.length,
346-
renderTriggeringFetches: renderTriggeringFetches.map((f) => ({
347-
url: f.url,
348-
stackTrace: f.stackTrace,
349-
parsedFrames: f.parsedFrames,
350-
})),
351-
waterfallChains: [],
352-
})
353-
} else {
354-
sendReportToServer({
355-
type: 'waterfall',
356-
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
357-
timestamp: Date.now(),
358-
totalFetches: fetchEvents.length,
359-
totalCommits: commitEvents.length,
360-
renderTriggeringFetches: renderTriggeringFetches.map((f) => ({
361-
url: f.url,
362-
stackTrace: f.stackTrace,
363-
parsedFrames: f.parsedFrames,
364-
})),
365-
waterfallChains: chains.map((chain) =>
366-
chain.map((f) => ({
367-
url: f.url,
368-
stackTrace: f.stackTrace,
369-
parsedFrames: f.parsedFrames,
370-
}))
371-
),
245+
try {
246+
await originalFetch('/__nextjs_insights_ingest', {
247+
method: 'POST',
248+
headers: {
249+
'Content-Type': 'application/json',
250+
},
251+
body: JSON.stringify(report),
372252
})
253+
} catch {
254+
// Silently fail - don't disrupt the user experience
373255
}
374256
}
375257

376258
/**
377259
* Capture a clean stack trace, excluding our patched fetch
378-
* Returns both the Error object and parsed frames
379260
*/
380261
function captureStackTrace(): {
381-
error: Error
382262
stack: string
383263
frames: StackFrame[]
384264
} {
@@ -389,7 +269,7 @@ function captureStackTrace(): {
389269
}
390270
const stack = error.stack || ''
391271
const frames = parseStackFrames(stack)
392-
return { error, stack, frames }
272+
return { stack, frames }
393273
}
394274

395275
/**
@@ -417,43 +297,24 @@ function parseStackFrames(stack: string): StackFrame[] {
417297
}
418298

419299
/**
420-
* Report structure sent to the server for processing
300+
* Raw report structure sent to the server
301+
* Server handles all analysis logic
421302
*/
422-
interface WaterfallReport {
423-
type: 'waterfall' | 'render-blocking' | 'no-waterfall' | 'no-fetches'
303+
interface RawWaterfallReport {
424304
pageUrl: string
425305
timestamp: number
426-
totalFetches: number
427-
totalCommits: number
428-
renderTriggeringFetches: Array<{
306+
fetchEvents: Array<{
307+
id: number
429308
url: string
309+
startTime: number
310+
endTime: number
311+
artificialDelay: number
430312
stackTrace: string
431313
parsedFrames: StackFrame[]
432314
}>
433-
waterfallChains: Array<
434-
Array<{
435-
url: string
436-
stackTrace: string
437-
parsedFrames: StackFrame[]
438-
}>
439-
>
440-
}
441-
442-
/**
443-
* Send the waterfall report to the server for source map resolution and logging
444-
*/
445-
async function sendReportToServer(report: WaterfallReport): Promise<void> {
446-
try {
447-
await originalFetch('/__nextjs_insights_ingest', {
448-
method: 'POST',
449-
headers: {
450-
'Content-Type': 'application/json',
451-
},
452-
body: JSON.stringify(report),
453-
})
454-
} catch {
455-
// Silently fail - don't disrupt the user experience
456-
}
315+
commitEvents: Array<{
316+
time: number
317+
}>
457318
}
458319

459320
let warningBanner: HTMLDivElement | null = null
@@ -538,15 +399,15 @@ export function initWaterfallDetector() {
538399
// Set maximum analysis window
539400
maxWindowTimer = setTimeout(() => {
540401
if (!hasAnalyzed) {
541-
analyzeWaterfalls()
402+
sendRawData()
542403
}
543404
}, MAX_ANALYSIS_WINDOW)
544405

545-
// Also analyze on page visibility change (user switches tabs)
406+
// Also send data on page visibility change (user switches tabs)
546407
if (typeof document !== 'undefined') {
547408
document.addEventListener('visibilitychange', () => {
548409
if (document.visibilityState === 'hidden' && !hasAnalyzed) {
549-
analyzeWaterfalls()
410+
sendRawData()
550411
}
551412
})
552413
}

0 commit comments

Comments
 (0)