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.
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 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>
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.
-
[ ] 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)) ); });
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.
-
[ ] 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. If instead you see severalHTTP2_SESSIONobjects 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.
schedulerMsshould drop below50msfor 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.
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.
Related
- Network Waterfall Anatomy & Timing Metrics — parent: waterfall phase decomposition and timing attribution
- Diagnosing Request Queueing and Stalled Time — sibling: the socket pool and scheduler mechanics behind the two grey rows
- 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