Diagnosing Request Queueing and Stalled Time
Symptom: a same-origin request shows a grey bar of 300 ms or more in the Chrome DevTools waterfall while DNS Lookup, Initial connection and SSL all read 0.00 ms, and the origin’s access log timestamps the request only after the delay — so nothing that happened on the wire accounts for the wait.
Root Cause: Two Queues Painted as One Grey Bar
The grey band before a request’s coloured phases is not one state. Chrome splits the pre-dispatch wait across two processes, and the Timing tab reports them as two separate rows that most engineers read as a single blur.
The renderer side comes first. When the HTML parser, the preload scanner or a script creates a fetch, Blink’s ResourceFetcher builds the request, assigns it a load priority, and hands it to the resource scheduler. The scheduler is allowed to hold it: requests at Low and Lowest are classified delayable, and while the document is still blocked on render-critical resources the scheduler will keep delayable requests parked rather than let them compete for bandwidth with the stylesheet that is holding up first paint. The time a request spends parked here — plus the time Chrome spends reserving a disk-cache slot for the eventual response — is what the Timing tab calls Queueing. It is a policy decision, not a shortage.
The network-service side comes second, and is where nearly all long grey bars actually live. Once the request crosses into the network service, HttpStreamFactory asks the socket pool for a stream. Chrome’s socket pool applies two hard ceilings: six sockets per group — a group is roughly origin plus network-isolation key — and 256 sockets in total across the process. Chrome also uses late binding: a request is not paired with a socket when it is created, but at the moment a socket becomes usable, so a request that arrives when the group is full simply joins the pool’s pending list with no socket attached at all. That wait is Stalled. Because no socket exists yet, domainLookupStart, connectStart and secureConnectionStart have not been reached, which is exactly why the DNS, connection and SSL rows read zero while the request loses half a second.
Upgrading the protocol changes the ceiling rather than removing it. Over HTTP/2 and HTTP/3 the six-socket group limit no longer applies, but the peer advertises a concurrency budget that the browser must respect: SETTINGS_MAX_CONCURRENT_STREAMS in HTTP/2, and the initial_max_streams_bidi transport parameter in QUIC. nginx defaults both http2_max_concurrent_streams and http3_max_concurrent_streams to 128; Apache’s H2MaxSessionStreams defaults to 100. A page that fires 140 requests at one origin in a single burst will therefore still see the tail of that burst sit in Stalled, waiting for the server to return stream credit. Two further causes belong to neither queue and are worth ruling out early: proxy auto-config script evaluation, which Chrome bills to Stalled, and a busy main thread, which delays request creation so the resource never even reaches the scheduler.
The practical consequence is that Queueing and Stalled demand opposite fixes. Queueing is cleared by changing what the browser thinks the resource is worth — an explicit fetch priority signal, or moving the resource into the initial HTML so the preload scanner sees it. Stalled is cleared by changing how many requests can be in flight at once: fewer sockets held open, one coalesced origin instead of four, or a larger stream budget on the server.
Minimal Reproduction
The smallest page that reproduces a multi-hundred-millisecond Stalled bar needs one long-lived request and enough parallel fetches to exhaust the remaining sockets. Serve the following from any origin negotiating HTTP/1.1 — no HTTP/2, no service worker — and throttle to Fast 4G.
<!doctype html>
<meta charset="utf-8">
<title>Socket pool exhaustion</title>
<!-- One long-poll connection. It never completes, so it permanently occupies
one of the six sockets Chrome allows per origin: every other request on
assets.example.com now competes for five slots, not six. -->
<script>fetch('https://assets.example.com/events?wait=60');</script>
<!-- Eight gallery images, all discovered by the preload scanner in the same
tokenisation pass, so all eight enter the socket pool within ~2 ms of
each other. Five bind to a socket; the last three wait for one to free. -->
<img src="https://assets.example.com/g/01.avif" width="480" height="320" alt="">
<img src="https://assets.example.com/g/02.avif" width="480" height="320" alt="">
<img src="https://assets.example.com/g/03.avif" width="480" height="320" alt="">
<img src="https://assets.example.com/g/04.avif" width="480" height="320" alt="">
<img src="https://assets.example.com/g/05.avif" width="480" height="320" alt="">
<!-- The LCP element. Sixth in document order, therefore sixth into the pool,
therefore last to get a socket — the priority hint below cannot help,
because the ceiling being hit is a socket count, not a priority tier. -->
<img src="https://assets.example.com/g/06.avif" width="960" height="540"
fetchpriority="high" alt="Hero">
<img src="https://assets.example.com/g/07.avif" width="480" height="320" alt="">
<img src="https://assets.example.com/g/08.avif" width="480" height="320" alt="">
Reload with the cache disabled and the waterfall separates into two clean waves. The first five images start immediately; the last three — including the hero — show a 600 ms Stalled bar and only begin transferring when a first-wave socket is released.
The fetchpriority="high" on the hero is not a mistake in the reproduction; it is the point. Priority orders requests within the pending list, so the hero is the first of the three to get the released socket — but a priority hint cannot manufacture a seventh socket, so it still waits 600 ms. That distinction is the single most useful thing the Timing tab tells you, and it is why reading network waterfall anatomy at phase level beats reading total durations.
To measure the hold from script rather than by eye, isolate the interval that ends when socket work begins:
// The default resource buffer is 250 entries; a gallery page overflows it and
// silently drops the very requests that stalled. Raise it before anything loads.
performance.setResourceTimingBufferSize(600);
const holdOf = (e) => {
// Spec behaviour: on a REUSED connection the user agent collapses
// domainLookupStart / connectStart / connectEnd onto fetchStart, so the only
// field that moves is requestStart. On a NEW connection the pool wait ends the
// instant DNS begins, so domainLookupStart is the exact end of Queueing+Stalled.
const reused = e.domainLookupStart === e.fetchStart && e.connectEnd === e.fetchStart;
return reused ? e.requestStart - e.fetchStart
: e.domainLookupStart - e.fetchStart;
};
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
// requestStart is zero on a cross-origin entry without Timing-Allow-Origin;
// treating that as a 0 ms hold would hide real stalls, so skip it explicitly.
if (!e.requestStart) continue;
const hold = holdOf(e);
if (hold > 50) {
console.warn('%s held %d ms before socket work, protocol=%s',
new URL(e.name).pathname, Math.round(hold), e.nextHopProtocol || 'unknown');
}
}
}).observe({ type: 'resource', buffered: true });
nextHopProtocol in that output is the discriminator: a hold over http/1.1 points at the socket ceiling, a hold over h2 or h3 points at scheduler deferral or the server’s stream budget.
Deterministic Fix Protocol
Read the three columns before touching any code — Protocol, Priority and Connection ID identify which of the three holds you are looking at, and each one has a different fix.
Work the steps in order. Each one is verifiable on its own, so you never stack two changes and lose the attribution.
-
[ ] Step 1 — Normalise the capture. DevTools → Network → tick
Disable cache, set throttling toFast 4G, hard-reload withCtrl+Shift+R. A warm socket from a previous load will hide the pool wait entirely, so an un-throttled repeat visit is not a valid trace. -
[ ] Step 2 — Add the three diagnostic columns. Right-click the request-table header and enable
Protocol,PriorityandConnection ID. Under HTTP/2 or HTTP/3 every same-origin row should share one Connection ID; a column of distinct, recycling IDs is the signature of HTTP/1.1 socket churn. -
[ ] Step 3 — Split the grey bar. Click the slow request →
Timing. RecordQueueingandStalledseparately. Queueing above ~20 ms means the renderer deprioritised the request; Stalled above ~50 ms means it was waiting for capacity. Only one of those two numbers is usually large. -
[ ] Step 4 — Prove it in NetLog. Load
chrome://net-export/, clickStart Logging to Disk, reload the page, stop, then search the JSON forSOCKET_POOL_STALLED_MAX_SOCKETS_PER_GROUP. Each occurrence is one request that hit the six-per-origin ceiling;SOCKET_POOL_STALLED_MAX_SOCKETSmeans the 256-socket process limit was hit instead, which points at too many distinct origins rather than too many requests. -
[ ] Step 5 — Evict the socket hogs. Long-poll endpoints, server-sent event streams and WebSocket fallbacks each hold a socket for their whole lifetime. Move them to a dedicated hostname so they consume a different socket group, and the asset origin gets all six back.
# Long-lived streams get their own origin so they cannot starve the asset pool. # Under HTTP/1.1 a socket group is keyed by scheme+host+port: a distinct hostname # is therefore a distinct pool, and events.example.com holding a socket for # 60 s no longer removes capacity from assets.example.com. server { listen 443 ssl; server_name events.example.com; location /events { proxy_pass http://upstream_events; proxy_http_version 1.1; proxy_read_timeout 75s; # longer than the client's 60 s poll window proxy_buffering off; # stream events out instead of accumulating them } } -
[ ] Step 6 — Remove the ceiling instead of working around it. Enable HTTP/2 or HTTP/3 on the asset origin and let all assets share one connection. Because Chrome coalesces origins that resolve to the same IP and are covered by the same certificate, a wildcard certificate over one address collapses several hostnames into a single connection — see connection coalescing for the exact matching rules.
# Removing the six-socket ceiling only helps if the stream budget is generous. # 128 concurrent streams comfortably covers a burst of images discovered in one # tokenisation pass; below ~100 the tail of the burst simply stalls again. http2 on; http2_max_concurrent_streams 128; http3_max_concurrent_streams 128; add_header Alt-Svc 'h3=":443"; ma=86400' always; -
[ ] Step 7 — Fix genuine Queueing with a priority signal. If the large number was Queueing rather than Stalled, the scheduler classified the request as delayable. Mark the LCP element
fetchpriority="high", or declare it with<link rel="preload" as="image" fetchpriority="high">in the head so it is never delayable in the first place. Beware the inverse: an image that is bothloading="lazy"andfetchpriority="high"is still withheld until it approaches the viewport. -
[ ] Step 8 — Re-measure and lock it in. Re-run the observer snippet and assert the result in CI. Fail the build when any render-critical entry reports more than 50 ms of hold, so the next long-poll endpoint someone adds is caught before it ships.
// Playwright/Puppeteer assertion. 50 ms is the threshold at which a hold starts // to move LCP on a Fast 4G profile; anything below it is scheduler noise. const stalls = await page.evaluate(() => performance.getEntriesByType('resource') .filter(e => e.requestStart && (e.domainLookupStart || e.requestStart) - e.fetchStart > 50) .map(e => ({ url: e.name, protocol: e.nextHopProtocol }))); if (stalls.length) throw new Error(`Stalled requests: ${JSON.stringify(stalls)}`);
Before / After Metrics
Moving the long-poll endpoint to events.example.com and turning on HTTP/2 for the asset origin changes the hero image’s profile as shown below. The transfer time is unchanged — only the wait disappears.
| Measurement | Where to read it | Before | After | Delta |
|---|---|---|---|---|
Stalled on g/06.avif |
DevTools → Timing tab | 600 ms | 20 ms | −580 ms |
Queueing on g/06.avif |
DevTools → Timing tab | 3 ms | 2 ms | −1 ms |
domainLookupStart − fetchStart |
Resource Timing probe | 603 ms | 22 ms | −581 ms |
| Largest Contentful Paint | PerformanceObserver, largest-contentful-paint |
1 180 ms | 600 ms | −580 ms |
Sockets open to assets.example.com |
chrome://net-export |
6 (1 pinned) | 1 (h2) | −5 |
SOCKET_POOL_STALLED_MAX_SOCKETS_PER_GROUP events |
chrome://net-export |
3 | 0 | −3 |
| Requests with hold > 50 ms | CI assertion in Step 8 | 3 | 0 | −3 |
| Total page load | DevTools summary bar | 1 340 ms | 760 ms | −580 ms |
The Queueing row barely moves, and that is the expected result: this page never had a scheduling problem. Chasing it with priority hints alone would have reordered the three stalled images without making any of them faster.
FAQ
Why do Queueing and Stalled sometimes show the same duration?
Chrome’s Timing tab renders Stalled as the span from the moment the request left the renderer to the moment socket work began, and Queueing as the renderer-side portion of the same wait. When the renderer hands the request off immediately — the usual case for anything the preload scanner discovers — Queueing rounds to under a millisecond and the entire grey band is attributed to Stalled. Two visually equal bars therefore do not mean the request waited twice; it waited once, in the socket pool, and the panel is drawing overlapping spans of one wait.
Can a request stall on HTTP/3, where there is no connection limit?
Yes, and it is easy to misdiagnose. QUIC replaces the six-socket ceiling with a stream credit: the server advertises initial_max_streams_bidi in its transport parameters and the client may not exceed it, so once the credit is spent the next request waits in the browser for a MAX_STREAMS frame. DevTools records that wait as Stalled with Protocol showing h3, which looks identical to a socket-pool wait. The Connection ID column is the tell — it stays constant, because there is only one connection. Raise the server’s concurrent-stream limit; a burst-heavy page wants 128 or more. The same reasoning applies to HTTP/2 stream prioritization, where a low-weight stream can be starved of window rather than of slots.
My script reports zero hold, but the waterfall clearly shows a stall. Why?
Almost always a missing Timing-Allow-Origin header. Without it, a cross-origin PerformanceResourceTiming entry reports domainLookupStart, connectStart, connectEnd and requestStart as 0, so any interval computed from those fields collapses to zero or goes negative. DevTools still draws the real bars because it reads Chrome’s internal network log rather than the JavaScript-exposed API. Add Timing-Allow-Origin: https://your-site.example to responses from origins you control, and guard the arithmetic with the if (!e.requestStart) continue; check shown above so a zeroed entry is skipped rather than counted as healthy.
Related
- Network Waterfall Anatomy & Timing Metrics — parent topic: every waterfall phase and the Resource Timing field behind it
- Decoding Chrome DevTools Network Waterfall — sibling guide: reading the panel itself, including priority inversion and service worker holds