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.

Chrome Network Waterfall: Scheduler State Mapping A horizontal timeline showing how Queueing, Stalled, DNS, Initial Connection, SSL, TTFB, and Content Download map to colour bands in the Chrome DevTools Network waterfall for a single request. Request A Request B (HTTP/2) 0ms 200ms 400ms 600ms Queueing Stalled DNS Connect SSL TTFB (waiting) Download TTFB Download ← Scheduler / browser-internal → ← Network I/O → Queueing/Stalled Connection setup TTFB Content Download

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 to Fast 4G. Hard-reload with Ctrl+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 by Priority ascending. Any LCP image, render-blocking CSS, or above-the-fold font listed as Low or Medium is 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 correct as attribute (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 fetch handler. 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/, click Start Logging, reload the target page, click Stop Logging, and open the resulting JSON in the NetLog Viewer. Search for HTTP2_SESSION events on the origin. If you see HTTP_STREAM_REQUEST_STARTED with weight: 16 for LCP resources alongside weight: 256 for lower-value assets, HTTP/2 stream priority inversion is active and requires server-side PRIORITY frame configuration.

  • [ ] Step 6 — Re-measure and validate. Clear cache, reload, re-run the console snippet from the Minimal Reproduction section. schedulerMs should drop below 50ms for 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.