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.

How one request moves through the renderer scheduler and the socket pool, and which DevTools Timing row each hold produces Four browser scheduler states are stacked in a column on the left: request created in the renderer, held by the resource scheduler, pending in the socket pool, and bound to a socket. Each state points right to the Chrome DevTools Timing row it produces: no bar yet, Queueing at 3 milliseconds, Stalled at 600 milliseconds, and Waiting or TTFB at 120 milliseconds. One request, two queues: where the grey bar actually comes from Chrome 141, image discovered by the preload scanner, origin already at six open sockets Browser scheduler state DevTools Timing row Request created in the renderer ResourceFetcher assigns a load priority Held by the resource scheduler delayable, or a cache slot is being cut Pending in the socket pool no socket bound, no stream credit Bound to a socket, bytes written the origin is now doing the work handed to the network service socket freed, late binding fires request headers written No waterfall bar yet the request has not left the renderer Queueing — 3 ms priority sort plus disk-cache slot Stalled — 600 ms six of six sockets already in use Waiting (TTFB) — 120 ms first byte from the origin

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.

Waterfall of the reproduction page showing five images transferring immediately and three stalling for 600 milliseconds behind a long-poll request A nine-row waterfall on a zero to fourteen hundred millisecond axis. The top row is a long-poll XHR that holds one socket for the entire load. The next five image rows begin transferring at twenty milliseconds. The last three image rows show a six hundred millisecond stalled segment before their transfer starts, because they are waiting for one of the five remaining sockets to free. One held socket costs the last three images 600 ms of Stalled time HTTP/1.1 origin, Fast 4G throttle, eight images at 140 KB each on assets.example.com 0 200 400 600 800 1000 1200 1400 ms /events g/01.avif g/02.avif g/03.avif g/04.avif g/05.avif g/06.avif g/07.avif g/08.avif long-poll XHR — holds socket 1 for the whole page load TTFB + download 580 ms Stalled 600 ms TTFB + download 580 ms Stalled 600 ms Stalled 600 ms Stalled TTFB + download long-poll XHR first socket released at 600 ms — wave two starts here

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.

Decision tree mapping the Protocol and Priority columns to the underlying cause of a stalled request and its fix Starting from a request with more than fifty milliseconds of combined Queueing and Stalled time, three branches split on the DevTools Protocol and Priority columns. HTTP/1.1 with cycling connection IDs indicates socket-pool exhaustion, fixed by coalescing onto one HTTP/2 origin. HTTP/2 or HTTP/3 with a Low priority indicates scheduler deferral, fixed with a priority hint. HTTP/2 or HTTP/3 with a High priority indicates stream-slot exhaustion, fixed by raising the server's concurrent stream cap. Read three columns first: the fix depends on which ceiling you hit Queueing + Stalled > 50 ms on a render-critical request Protocol: http/1.1 Connection ID keeps cycling Protocol: h2 or h3 Priority: Low or Lowest Protocol: h2 or h3 Priority: High, still waiting Socket-pool exhaustion six sockets per origin Scheduler deferral request marked delayable Stream-slot exhaustion server concurrency budget Coalesce onto one h2 origin and move the long poll away Set fetchpriority=high or preload it from the head Raise the stream cap 128 minimum, 256 preferred

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 to Fast 4G, hard-reload with Ctrl+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, Priority and Connection 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. Record Queueing and Stalled separately. 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/, click Start Logging to Disk, reload the page, stop, then search the JSON for SOCKET_POOL_STALLED_MAX_SOCKETS_PER_GROUP. Each occurrence is one request that hit the six-per-origin ceiling; SOCKET_POOL_STALLED_MAX_SOCKETS means 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 both loading="lazy" and fetchpriority="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.

Before and after timelines for the hero image, showing the 600 millisecond stalled segment collapsing to 20 milliseconds Two stacked timelines for the same hero image on a zero to twelve hundred millisecond axis. The before row spends six hundred milliseconds stalled, then one hundred and twenty milliseconds waiting for the first byte, then four hundred and sixty milliseconds downloading, finishing at eleven hundred and eighty milliseconds. The after row stalls for only twenty milliseconds and finishes at six hundred milliseconds. Three summary tiles below give the stalled time, the LCP and the count of requests queued longer than fifty milliseconds. Hero image g/06.avif: the wait disappears, the transfer does not change Same page and same throttle; only the long-poll XHR moved to its own origin and h2 was enabled Before Stalled 600 ms TTFB Download 460 ms LCP 1180 ms After TTFB Download 460 ms LCP 600 ms — Stalled 20 ms 0 200 400 600 800 1000 1200 ms Stalled, hero image 600 ms to 20 ms Largest Contentful Paint 1180 ms to 600 ms Requests held over 50 ms 3 to 0
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.