Decoding Chrome DevTools Network Waterfall: Debugging Scheduler Stalls
Symptom: A resource appears to stall in the waterfall for hundreds of milliseconds despite a fast connection, with Initial Connection and SSL both near zero.
Root Cause: Browser Scheduler States Hidden Inside the Waterfall
The Chrome DevTools Network waterfall compresses several distinct internal states into a single timeline bar. What looks like network latency is frequently scheduler-induced wait time that never touches the wire. Understanding this distinction is the prerequisite for any meaningful network waterfall anatomy diagnosis.
Chrome’s network stack separates requests into two phases before a byte is transmitted: Queueing (the resource is known but not yet dispatched to the network process) and Stalled (the request was dispatched but is waiting for a connection slot or a proxy resolver). Both show as grey bars in the waterfall, making them visually indistinguishable from each other and from genuine network delay unless you open the per-request Timing breakdown.
Three scheduler mechanisms produce phantom stalls in practice:
Connection pool exhaustion. Under HTTP/1.1, Chrome limits each origin to six concurrent connections. A seventh request queues until a slot frees. HTTP/2 multiplexing removes this limit, but only when the server negotiates h2 in the TLS ALPN handshake — a misconfigured origin may silently fall back to HTTP/1.1 and reintroduce the six-connection ceiling.
Priority inversion. Chrome’s internal resource scheduler enforces five priority tiers (VeryHigh, High, Medium, Low, Lowest). When multiple resources compete for a stream window on an HTTP/2 connection, lower-priority streams can starve a critical resource if fetchpriority attributes are missing or incorrect. An LCP image without an explicit fetchpriority="high" hint defaults to Low and can sit behind dozens of lower-value assets.
Service Worker fetch handler latency. If a Service Worker’s fetch event handler performs asynchronous work before calling event.respondWith(), Chrome records that gap as Queueing in the Network panel. The waterfall makes it look like network delay; the actual cost is main-thread JavaScript execution inside the worker.
Minimal Reproduction
The following snippet surfaces scheduler-stalled requests using the PerformanceResourceTiming API. Run it in the DevTools console after a hard reload:
// Filter resources where the browser held the request internally but
// no network connection work happened — a pure scheduler stall signal.
performance.getEntriesByType('resource')
.filter(r => {
const connectionTime = r.connectEnd - r.connectStart; // 0 when connection was reused
const internalHold = r.requestStart - r.fetchStart; // Queueing + Stalled combined
return connectionTime < 5 && internalHold > 150; // Threshold: 150ms scheduler hold
})
.map(r => ({
name: r.name.split('/').pop(),
schedulerMs: Math.round(r.requestStart - r.fetchStart),
totalMs: Math.round(r.duration),
initiator: r.initiatorType
}))
.forEach(r => console.warn('Scheduler stall:', r));
A non-empty result set with connectionTime ≈ 0 confirms the delay is scheduler-induced and the fix lies in fetchpriority attributes or Service Worker handler timing — not network topology.
Deterministic Fix Protocol
Work through these steps in order; each addresses a distinct stall mechanism and can be verified before moving to the next.
-
[ ] Step 1 — Normalise the measurement environment. Open DevTools → Network → tick
Disable cache→ set throttle toFast 4G. Hard-reload withCtrl+Shift+R. This removes cached connections that would mask stalls. -
[ ] Step 2 — Add the Priority column. Right-click the Network panel header → check
Priority. Sort byPriorityascending. Any LCP image, render-blocking CSS, or above-the-fold font listed asLoworMediumis misclassified by the scheduler and must be fixed in Step 3. -
[ ] Step 3 — Apply explicit priority hints. For each misclassified critical asset, add
fetchpriority="high"(images/scripts) or verify the<link rel="preload">carries the correctasattribute (stylesheet, font, image). The browser cannot promote priority without an explicit signal.<!-- LCP hero image: scheduler must see fetchpriority before it queues the request --> <img src="/hero.webp" fetchpriority="high" decoding="async" alt="Hero"> <!-- Preloaded font: missing crossorigin causes a duplicate fetch and drops priority --> <link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin> <!-- Non-critical third-party script: async keeps it out of the critical path --> <script src="/analytics.js" async></script> -
[ ] Step 4 — Audit Service Worker fetch handlers. Open DevTools → Application → Service Workers. If a Service Worker is registered, inspect its
fetchhandler.event.respondWith()must be called synchronously in the handler body; any async work before that call becomes scheduler-visible stall time.self.addEventListener('fetch', event => { // Call respondWith synchronously — move all async work inside the Promise. // Chrome records time-to-respondWith as Queueing in the Network panel. event.respondWith( caches.match(event.request).then(cached => cached ?? fetch(event.request)) ); }); -
[ ] Step 5 — Verify HTTP/2 connection reuse. Navigate to
chrome://net-export/, clickStart Logging, reload the target page, clickStop Logging, and open the resulting JSON in the NetLog Viewer. Search forHTTP2_SESSIONevents on the origin. If you seeHTTP_STREAM_REQUEST_STARTEDwithweight: 16for LCP resources alongsideweight: 256for lower-value assets, HTTP/2 stream priority inversion is active and requires server-sidePRIORITYframe configuration. -
[ ] Step 6 — Re-measure and validate. Clear cache, reload, re-run the console snippet from the Minimal Reproduction section.
schedulerMsshould drop below50msfor previously stalled critical assets.
Before/After Metrics
These are representative DevTools Timing values from a production page where an LCP image lacked fetchpriority and a Service Worker’s handler introduced a 180ms async gap:
| Resource | Metric | Before fix | After fix | How to verify |
|---|---|---|---|---|
| LCP hero image | Queueing duration |
420 ms | 12 ms | DevTools Timing tab |
| Render-blocking CSS | Stalled duration |
280 ms | 35 ms | WebPageTest waterfall |
| HTTP/2 streams/origin | Concurrent active streams | 1 (serialised) | 6 (multiplexed) | chrome://net-export |
| Service Worker fetch gap | Time-to-respondWith |
180 ms | 4 ms | DevTools Timing → Service Worker row |
| TTFB (critical path) | Navigation API | 1 240 ms | 670 ms | performance.getEntriesByType('navigation')[0].responseStart |
FAQ
Why is a request showing Stalled with zero Initial Connection time?
Zero Initial Connection time alongside a long Stalled duration means the browser scheduler held the request internally — no network delay occurred. The three most common causes are a saturated per-origin connection pool under HTTP/1.1 (six-connection ceiling), a low fetchpriority on a critical resource, or a Service Worker fetch handler that delays calling event.respondWith().
Does HTTP/2 multiplexing eliminate the Queueing phase entirely?
HTTP/2 removes the per-origin connection limit that triggers Queueing under HTTP/1.1, but scheduling delays can still appear when the browser’s internal scheduler assigns a low priority to a resource or when head-of-line blocking stalls a stream at the application layer. Correct fetchpriority attributes are necessary even over HTTP/2.
Can a Service Worker inflate Queueing time without touching the network?
Yes. Chrome records the gap between a fetch event being dispatched to a Service Worker and event.respondWith() being called as Queueing in the Network panel. It appears identical to network scheduler delay in the waterfall colour band. Calling event.respondWith() synchronously and moving all async cache logic inside the returned Promise eliminates this gap.
Related
- Network Waterfall Anatomy & Timing Metrics — parent: waterfall phase decomposition and timing attribution
- How Browser Fetch Priority Affects LCP — sibling: priority tier mechanics and LCP impact
- Fixing HTTP/2 Priority Inversion Issues — related: resolving stream weight conflicts at the server level