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 {
3636const FETCH_DELAY_MIN = 1000 // Minimum artificial delay
3737const FETCH_DELAY_MAX = 3000 // Maximum artificial delay
3838const FETCH_DELAY_STEP = 500 // Delay increments (1000, 1500, 2000, 2500, 3000)
39- const PROXIMITY_THRESHOLD = 100 // ms to consider events causally related
4039const IDLE_TIMEOUT = 4000 // Consider page idle after 4s of no fetch activity (> max delay)
4140const 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 */
195188function 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 */
380261function 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
459320let 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