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.

The split follows a process boundary. Queueing is renderer-side: Blink’s ResourceFetcher has created the request, assigned it a load priority, and handed it to the resource scheduler, which may hold it because the resource is classified delayable or because the disk cache has not yet granted a slot. Stalled is network-service-side: the request has crossed into the network process, asked HttpStreamFactory for a stream, and been parked in the socket pool’s pending list because the group is full. Chrome uses late binding — a request is paired with a socket only when one becomes usable — so a stalled request has no socket at all, which is precisely why domainLookupStart, connectStart and secureConnectionStart all read zero while the bar grows. The mechanics of that split are covered in depth in diagnosing request queueing and stalled time; this guide is about reading it out of the panel.

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.

Where the grey bar sits: the phases of two requests on one DevTools waterfall time axis Two request bars are drawn on a shared 0 to 600 millisecond axis. Request A spends 80 milliseconds Queueing and 50 milliseconds Stalled before DNS, connection and TLS begin at 130 milliseconds, then 140 milliseconds of TTFB and 100 milliseconds of download. Request B reuses the same connection and skips DNS, connect and TLS entirely. A dashed marker at 130 milliseconds separates the browser-internal hold from real network time. Where the grey bar sits: two requests on one waterfall axis Request A opens a new connection; Request B reuses the same HTTP/2 session 0 ms 150 ms 300 ms 450 ms 600 ms Request A new connection Queueing Stalled DNS Connect SSL TTFB waiting Download Request B connection reused TTFB Download Scheduler hold Network I/O and origin time Request A spends its first 130 ms — Queueing plus Stalled — before one packet leaves the machine. Queueing Stalled DNS / Connect / SSL TTFB (waiting) Content Download

What the Timing tab is actually measuring

Every row in the Timing popover is a subtraction between two PerformanceResourceTiming marks, which is why the console can reproduce most of the panel. fetchStart is stamped when the fetch begins, domainLookupStart and domainLookupEnd bracket DNS, connectStart through connectEnd bracket the TCP handshake with secureConnectionStart marking the start of TLS inside it, requestStart is stamped as the request headers go out, and responseStart and responseEnd bracket the body. Waiting (TTFB) is responseStart − requestStart; Content Download is responseEnd − responseStart.

The gap the API cannot resolve is the interesting one. There is no timing mark between Queueing and Stalled, so requestStart − fetchStart returns their sum and nothing more. That single number is still the most valuable signal available from JavaScript, because when the connection marks are all equal — connectEnd − connectStart of zero — every millisecond in that gap is browser-internal hold. To split the sum into its two halves you must leave the API behind and capture a chrome://net-export log, where HTTP_STREAM_JOB_CONTROLLER and SOCKET_POOL_STALLED_MAX_SOCKETS_PER_GROUP events timestamp the socket-pool wait separately from the renderer-side hold.


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.

Two caveats keep this snippet honest. Cross-origin resources return zeroes for every mark except startTime, fetchStart, responseEnd and duration unless the origin sends Timing-Allow-Origin, so a third-party asset will silently report schedulerMs: 0 no matter how long it actually sat in the queue. And a resource served from the memory cache never reaches the scheduler at all, so it is correctly absent from the results rather than mistakenly clean.


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>

Reading the sorted column is where most audits go wrong, because a correct Priority value does not mean a clean bar. The panel below is the same five-asset page used throughout this guide, sorted ascending: only the first row is a priority defect, and the second row proves the point — critical.css is already at Highest and still carries a 280 ms grey bar, which can only be socket-pool wait.

Stylised Chrome Network panel sorted by Priority, with callouts separating a priority defect from a connection-pool defect A drawn Network panel lists four requests with their type, priority pill and grey-bar duration: hero.webp at Low with 420 milliseconds, critical.css at Highest with 280 milliseconds, inter-var.woff2 at High with 95 milliseconds, and analytics.js at Low with 40 milliseconds. Three callouts on the right explain that only the first row is a priority defect, the second is a socket-pool defect despite a correct priority, and the fourth is correctly left alone. Step 2: the Priority column, sorted ascending Chrome 141, cache disabled, Fast 4G — grey bar = Queueing plus Stalled Name Type Priority Grey bar hero.webp img Low 420 ms critical.css css Highest 280 ms inter-var.woff2 font High 95 ms analytics.js script Low 40 ms LCP image sorted Low The grey bar is Queueing Fix in Step 3 Priority already Highest so the 280 ms is Stalled Fix in Step 5 Low is correct here async third-party script Leave it alone Pill colour is the verdict, not the tier: red is a defect, green is correct, slate is deliberate.
  • [ ] 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))
      );
    });

The reason this pattern matters is that the Service Worker sits between the renderer and the network, and the clock starts the moment the fetch event is dispatched. Awaiting an IndexedDB read, a config lookup, or a dynamic import() before respondWith() runs adds the full duration of that await to the request’s lifetime with no network activity to show for it — 180 ms in the production case measured below.

Sequence diagram of a Service Worker fetch handler that awaits IndexedDB for 180 milliseconds before calling respondWith Three lifelines run down the diagram: the renderer, the Service Worker, and the cache or network. The renderer dispatches a fetch event at zero milliseconds. The Service Worker then spends 180 milliseconds awaiting an IndexedDB config read before calling respondWith, which reaches the cache at 180 milliseconds and returns a cached response 4 milliseconds later. The response reaches the renderer at 184 milliseconds, and the whole 180 millisecond gap is billed to Queueing. A Service Worker handler that awaits before it responds Times are DevTools Timing values for one cache-hit navigation sub-resource Renderer Service Worker Cache / network 0 ms 0 – 180 ms 180 ms 184 ms 184 ms fetch event dispatched await idbGetConfig() 180 ms of async work before respondWith() respondWith → caches.match() cached response — 4 ms response handed to the renderer DevTools bills the whole 0 – 180 ms gap to Queueing: no socket, no DNS, no bytes on the wire.
  • [ ] 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. If instead you see several HTTP2_SESSION objects for hostnames you expected to share one, the origins are not coalescing — verifying connection coalescing with DevTools covers the certificate and DNS conditions that have to hold.

  • [ ] 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.


Edge Cases That Mimic a Scheduler Stall

Four situations produce a long grey bar that none of the six steps above will move, and each is worth ruling out before you start rewriting markup.

A CORS preflight hidden from the panel. Chrome issues an OPTIONS request before any non-simple cross-origin fetch, and by default does not list it. The preflight’s entire round trip is absorbed into the real request’s Stalled segment, so a cross-origin JSON call can show 200 ms of Stalled with no local cause. Enable Show CORS preflight requests in the Network panel settings and the missing row appears; the fix is a longer Access-Control-Max-Age so the preflight result is cached.

Cache-entry write lock contention. Chrome’s HTTP cache takes an exclusive write lock per entry. When two requests for the same URL are in flight and the first is still streaming its response into the cache, the second one waits for the lock and Chrome bills the wait to Queueing. This is the usual explanation for two rows with identical URLs and wildly different grey bars.

Proxy resolution. If a PAC script is configured, the browser must evaluate FindProxyForURL() before it can pick a socket, and slow or network-fetched PAC scripts add tens to hundreds of milliseconds. Chrome bills this to Stalled — check chrome://net-internals/#proxy before blaming the origin.

A busy main thread. Long tasks delay request creation, not request dispatch. The resource never reaches the scheduler, so fetchStart itself is late and the grey bar looks short while the row starts hundreds of milliseconds into the waterfall. Cross-check the Performance panel: if a long task overlaps the row’s start offset, the network is not the problem.


Reading the Same Stall in Other Engines

The two-row split is a Chrome implementation detail, not a web standard, so a cross-browser bug report needs translating. Firefox’s Network Monitor collapses both holds into a single Blocked segment covering scheduler hold, socket-pool wait and proxy resolution together; its per-origin ceiling is controlled by network.http.max-persistent-connections-per-server, also six by default. Safari’s Web Inspector labels the same span Stalled and offers no separate queueing row at all, and WebKit does not expose a Priority column, which makes a Chrome-side priority audit the only practical way to reason about ordering before re-testing in Safari.

The consequence for measurement is that a like-for-like comparison must add Chrome’s Queueing and Stalled together before comparing it to the other engine’s single segment. The consequence for fixes is subtler: the three engines compute the initial priority differently for the same markup — most visibly for images below the fold and for late-discovered stylesheets — so a fetchpriority change that clears a Chrome stall can be a no-op elsewhere. The tier-by-tier differences are set out in Chrome vs Safari vs Firefox priority differences.


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

Plotted on a common axis, the shape of the win is clearer than the table suggests: the three scheduler holds collapse to near zero, while TTFB — which is real origin work — only halves. Scheduler time is the cheap half of the budget to reclaim.

Grouped before and after bars for four measured durations on a shared millisecond axis Four pairs of horizontal bars share a zero to 1400 millisecond axis. LCP hero Queueing falls from 420 to 12 milliseconds, critical CSS Stalled from 280 to 35, Service Worker time-to-respondWith from 180 to 4, and navigation TTFB from 1240 to 670 milliseconds. The three scheduler holds almost disappear while TTFB only halves. What the fix protocol actually recovered Same page, same throttle profile, measured before and after Steps 3 to 5 0 350 700 1050 1400 ms LCP hero — Queueing 420 ms 12 ms critical.css — Stalled 280 ms 35 ms SW time-to-respondWith 180 ms 4 ms Navigation TTFB 1 240 ms 670 ms Before fix After fix throttled Fast 4G, cache disabled, five runs, median

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.

Does the Timing tab include the CORS preflight OPTIONS request?

No — the preflight is a separate row that Chrome hides unless Show CORS preflight requests is ticked in the Network panel settings. Its round trip is folded into the real request’s Stalled segment, so a cross-origin call can report 200 ms of Stalled with nothing wrong on the client. Raising Access-Control-Max-Age on the responding origin caches the preflight decision and removes the segment on subsequent requests.

Why do two requests for the same URL show completely different Queueing times?

Because Chrome’s HTTP cache takes an exclusive write lock on each entry. If the first request is still streaming its response into the cache, the second one blocks on the lock, and that wait is billed to Queueing. Deduplicate the fetch at the application layer, or mark the second request cache: 'no-store' so it bypasses the entry entirely.

Firefox shows Blocked where Chrome shows Queueing — are they the same thing?

Not exactly. Firefox’s Network Monitor emits one Blocked segment that covers everything Chrome splits across Queueing and Stalled, plus proxy resolution. Safari’s Web Inspector calls the equivalent span Stalled. When comparing engines, add Chrome’s two grey rows together before matching them against the other browser’s single number, or the Chrome figure will always look smaller than it is.

Should I trust the numbers with DevTools open?

Mostly, with one caveat: recording a large session adds measurable overhead to the renderer, which inflates the main-thread-bound portion of Queueing rather than the socket-pool portion. If a Queueing value looks implausible, confirm it with the console snippet above on a page loaded with DevTools closed and the panel re-opened afterwards, or with a chrome://net-export capture, which records from the network service and is unaffected.