Network Waterfall Anatomy & Timing Metrics

Misreading the network waterfall is one of the most common sources of wasted optimization effort. The colored bars in Chrome DevTools do not simply represent “slowness” — each segment maps to a distinct browser scheduler state, protocol negotiation phase, or server-side event. This page gives you a protocol-level model of every waterfall phase, a browser-engine comparison for the states that differ across Chromium, WebKit, and Gecko, and a structured debugging workflow for diagnosing queueing stalls, TTFB inflation, and render-blocking cascades.


Network waterfall phases for a single resource request A horizontal timeline showing the sequential phases a browser request passes through: Queueing, DNS Lookup, Initial Connection, SSL Negotiation, Waiting (TTFB), and Content Download. Each phase is a coloured bar with a label and an annotation naming what inflates it. Below, a second bar shows the same request on a reused connection, where the DNS, connection and SSL phases collapse to zero. One request, cold connection: every phase the browser bills separately Queueing DNS Lookup Initial Connection SSL / TLS Waiting (TTFB) Content Download 0 ms → time Connection cap or priority deferral Warm cache: ~0 ms TLS 1.3 resumption reduces to ~5 ms Server processing + network RTT HTTP/2 connection reuse: DNS + Initial Connection + SSL phases collapse to zero for all requests after the first on the same origin. Q Waiting (TTFB) Content Download ← subsequent request (reused connection) Cold: 40 + 25 + 95 + 60 + 210 + 240 ms = 670 ms · Warm: 4 + 210 + 240 ms = 454 ms

Concept Definition: What Each Waterfall Phase Actually Measures

The PerformanceResourceTiming API (exposed via performance.getEntriesByType('resource')) exposes start/end timestamps for each phase. Chrome DevTools renders these as coloured bars. Understanding the precise spec definition of each interval prevents misattributing server latency to network latency and vice versa.

Phase (DevTools label) PerformanceResourceTiming property What it includes
Queueing fetchStart − requestStart (approximation) Connection pool exhaustion, low-priority scheduler deferral, disk-cache lookup
Stalled Contains Queueing + proxy negotiation Everything in Queueing plus SOCKS proxy setup when applicable
DNS Lookup domainLookupEnd − domainLookupStart Recursive DNS resolution; 0 ms when the OS or browser DNS cache hits
Initial Connection connectEnd − connectStart TCP three-way handshake; 0 ms on reused connections
SSL connectEnd − secureConnectionStart TLS negotiation; 0 ms on reused or pre-connected origins
Waiting (TTFB) responseStart − requestStart Network RTT to server + server processing + queuing in the server’s send buffer
Content Download responseEnd − responseStart Byte transfer time; dominated by payload size and bandwidth

Why Queueing Is an Approximation, Not a Measurement

Notice that the Queueing row above says “approximation”. That is not hedging: there is no queueingStart property in the Resource Timing spec. DevTools draws the Queueing bar from Chromium’s internal network log, which records the moment URLRequest::Start() was called and the moment the socket pool actually handed the request a stream. PerformanceResourceTiming never sees those two events. What it gives you is fetchStart — the instant the fetch was created, after service worker and cache decisions — and requestStart, the instant the request headers were written to the socket. The gap between them absorbs four different waits that DevTools shows separately:

  • Socket pool wait. The request is admitted but no connection is free under the per-key limit.
  • Scheduler deferral. Chromium’s ResourceScheduler deliberately holds Low and Lowest priority requests while any render-blocking request is still in flight on the same client.
  • Cache probe. Disk cache lookups are asynchronous; a slow disk under memory pressure can add tens of milliseconds before the network is touched at all.
  • Renderer dispatch. The fetch is created on the renderer’s main thread. If that thread is executing a long task, the request is not even handed to the network service until the task yields.

The per-key limit matters more than the familiar “six per host” phrasing suggests. Chromium keys its socket pools on (scheme, host, port, network anonymization key, proxy chain, privacy mode), not on origin alone. Two requests to the same host can therefore land in different pools — a credentialed <img> and a crossorigin="anonymous" font, for example — and a third-party iframe on the same host gets its own pool entirely because its network anonymization key differs. That is why an HTTP/1.1 page sometimes shows nine or ten concurrent requests to one host and why raising a CDN’s connection count does not always drain the queue. Separating the two bars empirically, request by request, is the subject of the dedicated guide on diagnosing request queueing and stalled time.

The practical consequence: treat Queueing as a state the request occupies rather than a number to optimise directly. The diagram below shows the states and what causes each transition.

Scheduler state machine behind each network waterfall bar A state machine for one resource request. It moves from Discovered to Queued, then Connecting, then Request sent, then Receiving bytes, then Complete. A side state, Deferred, holds low and lowest priority requests and returns them to Queued when a socket frees or the main thread idles. Each state is annotated with the DevTools waterfall bar it produces. Scheduler states, and the waterfall bar each one paints Discovered parser or scanner Queued bar: Queueing Connecting bar: DNS + TCP + SSL Request sent bar: Waiting (TTFB) Deferred Low / Lowest priority Complete responseEnd fired Receiving bytes bar: Content Download priority deferral socket frees or main thread idles first byte arrives Reused connection: the Connecting state is skipped entirely — Queued hands straight to Request sent.

Browser Engine Differences

The spec defines the timing properties uniformly, but engines differ in how they populate them for cross-origin or opaque responses, and in their priority arbitration during Queueing:

Behaviour Chromium WebKit (Safari) Gecko (Firefox)
Cross-origin PerformanceResourceTiming resolution Full timing with Timing-Allow-Origin header; otherwise all durations zero Full timing with Timing-Allow-Origin; otherwise zeroed Full timing with Timing-Allow-Origin; otherwise zeroed
HTTP/1.1 per-host connection limit 6 6 6
fetchpriority attribute support Chrome 101+ Safari 17.2+ Firefox 132+
Priority lanes for render-blocking vs. non-blocking Yes — 5 tiers via ResourceLoadPriority Yes — 4 tiers Yes — 4 tiers, slightly different weighting
103 Early Hints support Chrome 103+ Safari 17+ Firefox 120+

Three differences bite hardest when you compare traces across engines. First, timestamp resolution. Chromium reports resource timing at 5 µs granularity; Firefox clamps to 1 ms by default and to 100 ms when privacy.resistFingerprinting is enabled, which turns a genuine 0.4 ms Queueing value into a reported 0. A Firefox trace that shows no queueing anywhere is usually a clamping artefact, not a clean load. Second, which fields exist at all. renderBlockingStatus, deliveryType and responseStatus are Chromium-first additions; guard for undefined before asserting on them or your cross-browser synthetic checks will pass vacuously in Safari. Third, the panel vocabulary differs. Safari’s Web Inspector has no “Queueing” bar — the equivalent wait is folded into a segment it labels “Stalled”, and its “Waiting” excludes some of what Chrome bills to TTFB, so a like-for-like TTFB comparison between the two panels is invalid without normalising through PerformanceResourceTiming.

The arbitration order also diverges: Chromium runs five ResourceLoadPriority tiers with an explicit deferral rule for non-render-blocking requests, while Gecko and WebKit use four tiers and release deferred requests earlier. The same page can therefore show a 120 ms Queueing bar in Chrome and none in Firefox with identical network conditions. The per-engine tier tables and the practical consequences are set out in Chrome vs Safari vs Firefox priority differences.


Spec & API Reference

Key PerformanceResourceTiming Properties

// Instrument all resource timing entries for above-the-fold analysis.
// This surfaces the raw intervals that DevTools renders as waterfall bars.
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.initiatorType === 'link' || entry.initiatorType === 'script') {
      console.table({
        name: entry.name.split('/').pop(),
        // Queueing approximation: gap between fetchStart and connection start
        queueingMs: (entry.connectStart - entry.fetchStart).toFixed(1),
        dnsMs: (entry.domainLookupEnd - entry.domainLookupStart).toFixed(1),
        tcpMs: (entry.connectEnd - entry.connectStart).toFixed(1),
        tlsMs: entry.secureConnectionStart
          ? (entry.connectEnd - entry.secureConnectionStart).toFixed(1)
          : 0,
        // TTFB: waiting phase — includes server processing + round-trip latency
        ttfbMs: (entry.responseStart - entry.requestStart).toFixed(1),
        downloadMs: (entry.responseEnd - entry.responseStart).toFixed(1),
        totalMs: entry.duration.toFixed(1),
      });
    }
  }
});
observer.observe({ type: 'resource', buffered: true });

Diagnostic Thresholds

Phase Healthy Investigate Critical
Queueing / Stalled < 10 ms 10–50 ms > 50 ms — likely connection pool exhaustion or priority inversion
DNS Lookup 0 ms (cache) – 20 ms 20–60 ms > 60 ms — missing dns-prefetch or cold resolver chain
Initial Connection < 80 ms 80–150 ms > 150 ms — TCP slow-start, geo-distance to server, or saturated uplink
SSL / TLS < 30 ms 30–80 ms > 80 ms — session not resumed; add ssl_session_cache and TLS 1.3
Waiting (TTFB) < 200 ms 200–600 ms > 600 ms — server-side bottleneck or uncached edge request
Content Download Proportional to size > 2 s for < 100 KB signals bandwidth constraint or missing compression

Reading the Transfer-Size Triple

Three size properties travel with every entry, and read together they explain waterfall bars that the timing numbers alone make look impossible — a 400 KB script with a 3 ms Content Download bar, for instance.

Reading Meaning
transferSize === 0 and decodedBodySize > 0 Served from the memory or disk cache — no bytes crossed the network
transferSize > 0 and encodedBodySize === 0 A 304 Not Modified revalidation: headers only, roughly 300 bytes of overhead
transferSize ≈ encodedBodySize + 300 A normal network fetch; the ~300 bytes are response headers
encodedBodySize === decodedBodySize The response was not compressed — a missing Content-Encoding on a text asset
All three zero on a cross-origin asset Timing-Allow-Origin is absent, so the values are censored, not genuinely zero
// Classify each entry by how it was delivered, then flag uncompressed text.
for (const e of performance.getEntriesByType('resource')) {
  const cached = e.transferSize === 0 && e.decodedBodySize > 0;
  const revalidated = e.transferSize > 0 && e.encodedBodySize === 0;
  const uncompressed = e.encodedBodySize > 0
    && e.encodedBodySize === e.decodedBodySize
    && /\.(js|css|svg|json)(\?|$)/.test(e.name);
  // deliveryType is Chromium-only: 'cache', 'navigational-prefetch', or ''
  const via = e.deliveryType ?? (cached ? 'cache' : 'network');
  if (uncompressed) console.warn('uncompressed text asset', e.name, e.decodedBodySize);
  if (revalidated) console.info('304 revalidation', e.name, e.duration.toFixed(1) + ' ms');
  console.debug(e.name, via, e.nextHopProtocol);
}

nextHopProtocol is the field that ends most “are we actually on HTTP/2?” arguments — it reports the ALPN token (h2, h3, http/1.1) negotiated for that specific request, which can differ per origin on the same page. Collecting it from real users rather than from your own laptop is the only reliable way to know which protocol your traffic is really using; the field-measurement side of that is covered in measuring protocol performance in the field.


Step-by-Step Implementation

Step 1 — Eliminate Queueing with Protocol Upgrades

Long Queueing segments for assets on the same origin almost always signal HTTP/1.1 connection cap exhaustion (six simultaneous connections per host). Upgrading to HTTP/2 eliminates this limit by multiplexing all streams over a single connection. The browser’s fetch priority system then handles inter-stream ordering without queue starvation.

# nginx: Enable HTTP/2 + HTTP/3 (QUIC) on the same port.
# QUIC removes TCP head-of-line blocking between streams — see
# /http2-http3-multiplexing-connection-optimization/ for stream-weight tuning.
server {
  listen 443 ssl;
  listen 443 quic reuseport;           # HTTP/3 via QUIC
  http2 on;                            # HTTP/2 (nginx ≥ 1.25.1 syntax)
  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_session_cache shared:SSL:10m;    # TLS session resumption — eliminates SSL bar on repeat visits
  ssl_session_timeout 1d;
  add_header Alt-Svc 'h3=":443"; ma=86400' always;  # advertise HTTP/3 to clients
}

Step 2 — Collapse DNS + Connection + SSL Phases with Early Hints and Preconnect

For third-party origins (fonts, analytics, CDN), the DNS + TCP + TLS triple consumes 200–400 ms on a cold connection. Declaring preconnect in the HTML <head> parallelises this setup against HTML parsing. For the primary origin, 103 Early Hints starts the connection before the server even begins generating the response body.

<!-- Preconnect to critical third-party origins.
     crossorigin is required for CORS fetches (fonts, API calls) so the
     browser opens a CORS-capable connection rather than a no-CORS one. -->
<link rel="preconnect" href="https://fonts.example.com" crossorigin>
<link rel="dns-prefetch" href="https://analytics.example.com">
HTTP/1.1 103 Early Hints
Link: </assets/critical.css>; rel=preload; as=style
Link: </assets/hero.webp>; rel=preload; as=image; fetchpriority=high

The 103 response is sent before the 200, so the browser can begin fetching critical.css while the server is still assembling the HTML — collapsing the entire DNS/TCP/TLS sequence into the server’s own processing time for the primary origin.

Sequence of exchanges when a server sends 103 Early Hints before the HTML response A sequence diagram with two participants, Browser and Origin server. The browser requests the HTML at 0 ms. At 48 ms the server returns a 103 Early Hints response carrying a preload link for critical.css, so the browser requests the stylesheet at 50 ms. The HTML first byte only arrives at 210 ms, so the stylesheet fetch started 160 ms earlier than it otherwise could. 103 Early Hints on the primary origin: what happens on the wire Browser Origin server 0 ms GET / — HTML document 48 ms 103 Early Hints — Link: </critical.css>; rel=preload 50 ms GET /critical.css — dispatched on the open connection 210 ms 200 OK — first byte of HTML (responseStart) 236 ms 200 OK — critical.css, already in flight since 50 ms Without the 103, /critical.css cannot be requested until the HTML first byte at 210 ms. With it the fetch starts at 50 ms — 160 ms cut from the render-blocking path, on one connection.

Two details decide whether that 160 ms is real. The hint must be emitted by the layer that knows the request early — usually the CDN edge or a reverse proxy, because an application framework that emits 103 only after its own routing and template resolution has already spent the time the hint was meant to save. And the hinted URL must be byte-identical to the one the HTML will reference, including query string and crossorigin mode; a mismatch produces two separate fetches and a “preloaded but not used” console warning instead of a saving. Intermediaries that do not understand informational responses can also buffer the 103 until the 200 is ready, which silently converts the optimisation into a no-op — verify on the wire with curl -v --http2 and look for the interim status line, not just for a faster page.

Step 3 — Attack the TTFB Phase

TTFB is the waterfall phase most engineers underestimate because it conflates two independent problems: network RTT and server processing time. Use curl to isolate them before reaching for DevTools:

# Isolate TTFB components on the command line.
# time_starttransfer = TTFB (DNS + connect + TLS + server processing + first byte RTT)
# time_connect alone = TCP handshake
# time_appconnect = TLS complete
curl -o /dev/null -s -w \
  "DNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
  https://example.com/

If time_starttransfer − time_appconnect (pure server processing time) exceeds 200 ms, the problem is server-side — caching, database queries, or edge routing — not the network stack.

curl only tells you that the server was slow. To learn where, emit a Server-Timing header and let the browser attribute the TTFB bar for you — the values are exposed on the same PerformanceResourceTiming entry, so a real-user beacon can carry them without a separate tracing system:

Server-Timing: edge;dur=4;desc="CDN hit check", origin;dur=182, db;dur=141;desc="product query", cache;desc="MISS"
// Attribute a slow TTFB bar to the server phase that caused it.
const nav = performance.getEntriesByType('navigation')[0];
const ttfb = nav.responseStart - nav.requestStart;
const server = Object.fromEntries(nav.serverTiming.map(m => [m.name, m.duration]));
// e.g. ttfb 214 ms, of which db 141 ms — the network contributed ~28 ms
console.log({ ttfb, ...server, networkMs: ttfb - (server.origin ?? 0) });

A db;dur=141 inside a 214 ms TTFB says the round trip is healthy and the query is not; the same 214 ms with origin;dur=12 says the opposite and points at edge placement or a cold connection instead. Because the header travels with the response, it survives into field data where curl cannot reach — the pattern, including the protocol-stall signals worth adding to it, is expanded in reading Server-Timing headers for protocol stalls. Note that Server-Timing is subject to the same cross-origin rule as the timing properties: without Timing-Allow-Origin, entry.serverTiming is an empty array for third-party responses.

One TTFB trap deserves a name of its own. A streamed HTML response makes responseStart fire on the first flushed chunk, which can be a 200-byte <head> sent before any data fetching has happened. TTFB looks excellent while the user still waits seconds for content. Cross-check with responseEnd − responseStart on the navigation entry: if the document’s own Content Download bar is measured in seconds, the server is streaming its slowness rather than fixing it.

Step 4 — Compress and Chunk to Shrink the Content Download Bar

Content Download time is responseEnd − responseStart. For text assets the dominant lever is compression; for images it is format and dimension. For JavaScript, the additional lever is render-blocking resource identification — a large app.js that blocks parsing inflates effective download time by delaying every subsequent resource.

# Brotli compression for text assets.
# brotli_comp_level 6 balances CPU cost vs compression ratio for dynamic content.
brotli on;
brotli_comp_level 6;
brotli_types text/html text/css application/javascript application/json image/svg+xml;

Compression only moves the Content Download bar for text. For the LCP image the equivalent lever is bytes-on-the-wire per rendered pixel: a hero served at 2400 px wide into a 1200 px slot pays double the download bar for no visible gain, and an AVIF or WebP re-encode typically removes 40–60% of a JPEG’s bytes at the same perceptual quality. Set sizes accurately and let the browser pick from srcset; a wrong sizes value is the most common cause of a download bar that refuses to shrink after a format change.


Worked Example: A 1.94 s Hero Image

The numbers below come from a product page whose LCP element was a hero photograph injected by the framework’s image component after hydration. The waterfall showed a 640 ms Queueing bar on a 96 KB image — a bar that no amount of image optimisation would have touched, because the image was not waiting on bytes, it was waiting on a socket.

Three things were true at once. The site was still on HTTP/1.1, so six sockets per key was the ceiling. Four of those sockets were held by third-party tags that the browser had classified Low — they were requested early, so they were admitted to the pool first. And the hero URL was constructed in JavaScript, so the preload scanner never saw it; the request was created only after hydration, at which point every socket was busy. The fix was three changes with no new infrastructure: enable HTTP/2 on the origin, emit the hero as a static <img fetchpriority="high"> in the server-rendered HTML, and move the tag scripts behind defer.

Before and after waterfalls for a hero image, showing Queueing falling from 640 ms to 8 ms Two stacked request timelines for the same 96 KB hero image. Before: 640 ms Queueing, 180 ms Waiting, 420 ms Content Download, total 1240 ms and LCP at 1.94 seconds. After: 8 ms Queueing, 150 ms Waiting, 380 ms Content Download, total 538 ms and LCP at 1.02 seconds. Same 96 KB hero image, before and after — 1 ms = 0.5 px Queueing Waiting (TTFB) Content Download Before HTTP/1.1, image injected after hydration 640 ms 180 ms 420 ms Request total 1240 ms · LCP 1.94 s · four sockets held by Low-priority tag scripts After HTTP/2, static img with fetchpriority=high 150 ms 380 ms Queueing 8 ms — the gold sliver on the left Request total 538 ms · LCP 1.02 s · one multiplexed connection, no socket contention 702 ms of the 920 ms saving came from the Queueing bar, which no image optimisation can reach. Re-encoding the same image to AVIF would have moved only the 420 ms download bar.

The lesson generalises past this one page: read the bar that dominates before choosing a technique. A 640 ms Queueing bar is a scheduling problem and answers to protocol and discovery changes. A 640 ms Waiting bar is a server or routing problem and answers to caching. A 640 ms Content Download bar is a bytes problem and answers to compression and format. Applying the third fix to the first symptom is how teams end up shipping an AVIF pipeline for a 30 ms improvement.


Verification Workflow

After applying fixes, confirm improvements with two complementary methods:

DevTools Network panel — column-level verification:

  1. Open DevTools → Network panel.
  2. Right-click any column header → enable Priority, Connection ID, and Protocol.
  3. Reload with cache disabled (Ctrl+Shift+R).
  4. Sort by Waterfall to find the longest bars; the Queueing segment (light grey) should be < 10 ms for above-the-fold resources.
  5. Check Connection ID — all same-origin requests should share one ID under HTTP/2, confirming multiplexing is active.

PerformanceObserver — automated CI assertion:

// Assert that no render-critical resource exceeds 50 ms of Queueing.
// Run this in a Puppeteer / Playwright test after navigation completes.
const entries = JSON.parse(
  await page.evaluate(() =>
    JSON.stringify(
      performance
        .getEntriesByType('resource')
        .filter(e => e.initiatorType === 'link' || e.initiatorType === 'script')
        .map(e => ({
          name: e.name,
          // connectStart of 0 means the connection was reused — queueing only
          queueingMs: e.connectStart > 0
            ? e.connectStart - e.fetchStart
            : e.responseStart - e.fetchStart,
        }))
    )
  )
);
const violations = entries.filter(e => e.queueingMs > 50);
if (violations.length) throw new Error(`Queueing violation: ${JSON.stringify(violations)}`);

For decoding Chrome DevTools network waterfall segments in detail — including the Priority Inversion indicator and the hidden Disk Cache state — see the dedicated deep-dive.


Edge Cases & Gotchas

CORS and the Timing-Allow-Origin header Cross-origin resources without Timing-Allow-Origin: * return zeroed timing properties in PerformanceResourceTiming. DevTools still shows the full waterfall visually (because it reads from the internal network log, not the JS-exposed API), but your PerformanceObserver assertions will silently under-count queueing time for third-party assets. Add Timing-Allow-Origin: * to CDN responses you control; for third-party origins, rely on DevTools or WebPageTest HAR analysis instead.

HTTP/2 priority inversion Upgrading to HTTP/2 removes connection-count Queueing but introduces a different failure mode: a large low-priority response (a deferred analytics script, for example) can monopolise the TCP send window and starve higher-priority streams. The symptom is long Waiting (TTFB) or Content Download bars on high-priority resources, not Queueing. The fix is fetchpriority="high" on LCP images and explicit fetch priority hints — not connection tuning.

TLS 1.3 0-RTT replay risk ssl_early_data on in nginx reduces the SSL bar to near-zero for returning visitors, but early data (0-RTT) is replayable. Restrict it to safe idempotent requests (GET, HEAD) and reject it for state-mutating endpoints via the $ssl_early_data variable.

Preload scan and dynamic insertion The preload scanner reads raw HTML tokens before the parser executes scripts. Any resource URL that is constructed at runtime (via document.createElement or framework render cycles) is invisible to it — those resources acquire a Queueing delay equal to the script’s own execution time. This is why critical images should be declarative <img> tags or <link rel="preload"> in static HTML, not injected by JavaScript.

Service worker interception shifts the meaning of every bar On a controlled page the entry gains a non-zero workerStart, and the interval fetchStart − workerStart is service worker startup — up to 100 ms on a cold worker, none of which is network time. DevTools paints it as part of the pre-request region, so a page that “regressed” after shipping a service worker often has identical network timings and a new startup cost in front of them. Worse, if the worker calls fetch() itself, the timing entry describes the worker’s request, and any respondWith(new Response(...)) synthesised from caches.match reports transferSize: 0 with a duration equal to the worker’s own logic. Measure workerStart explicitly before blaming the network:

const e = performance.getEntriesByType('resource').find(r => r.workerStart > 0);
if (e) console.log('SW startup', (e.fetchStart - e.workerStart).toFixed(1), 'ms');

Redirect chains hide inside a single waterfall row redirectEnd − redirectStart covers every hop of a redirect chain, and DevTools collapses the chain into one row unless you expand it. A http://https://https://www. chain costs two extra full connection setups on a cold cache — commonly 300–500 ms on mobile — and it is invisible in the resource entry unless every hop sends Timing-Allow-Origin; without it, redirectStart and redirectEnd are both 0 and the time simply vanishes from your instrumentation while still being spent. Canonicalise at the DNS or edge layer so the browser makes one request, and never redirect a preloaded or hinted URL: the hint fetches the pre-redirect URL, the HTML references the post-redirect one, and the browser downloads the resource twice.

Throttling changes which bar is guilty DevTools’ “Fast 3G” preset applies a bandwidth cap and an added latency, but it does not simulate the CPU contention that produces renderer-side queueing, and its per-request latency injection lands in the connection phases rather than in Queueing. A trace captured under throttling therefore overstates Content Download and understates Queueing relative to a real mid-range device. Pair network throttling with a 4× CPU slowdown when the bar you are investigating is Queueing, and confirm on real hardware before concluding.

stale-while-revalidate and the phantom waterfall entry When a response is served from a stale-while-revalidate cache, the browser returns the cached bytes instantly (near-zero Content Download) but issues a background revalidation fetch. DevTools shows this as a second waterfall row with a disk cache tag. The background fetch counts against connection concurrency on HTTP/1.1 — under high load it can contribute to Queueing on the visible critical path. Monitor for this with cache-control headers audits.


FAQ

Why does the waterfall show a Queueing bar even on HTTP/2? HTTP/2 eliminates the per-host connection cap, so Queueing from connection exhaustion disappears. But the browser still queues low-priority requests when the main thread is busy executing JavaScript, and it throttles background fetches on low-power devices. Check the Priority column: if a Queueing resource has priority Low or Lowest, the browser is intentionally deferring it — that is correct behaviour, not a bug.

How do I tell whether TTFB is a server problem or a network problem? Use the curl -w timing breakdown above to separate time_connect (pure network RTT) from time_starttransfer − time_appconnect (server processing). If server processing exceeds 100 ms, the problem is server-side. If the round-trip dominates, the problem is CDN edge placement or missing connection reuse.

Does switching to HTTP/3 remove the SSL bar in the waterfall? On the first visit to a new origin, HTTP/3 (QUIC) still performs a TLS 1.3 handshake, so the SSL bar is present. QUIC combines the transport and TLS handshakes into a single round trip (1-RTT) compared to TCP + TLS (2-RTT), so the combined Initial Connection + SSL bar should roughly halve. On repeat visits with 0-RTT session resumption, the SSL bar drops to near-zero. To understand the head-of-line blocking differences that make HTTP/3 valuable beyond just the handshake, see head-of-line blocking mitigation.

What causes a “Stalled” bar longer than the Queueing bar? Chrome reports Stalled as a superset of Queueing — it includes proxy negotiation time. If you route traffic through an HTTP/SOCKS proxy (common in corporate networks or some dev setups), Stalled will be longer than Queueing by the proxy handshake duration. In direct-connection environments they are equal.

Why does preload not always eliminate the DNS and connection phases? <link rel="preload"> tells the browser to fetch the resource at high priority as soon as the HTML parser encounters the tag. If the preload target is on a different origin and no preconnect hint preceded it, the browser must still perform DNS + TCP + TLS for that origin — it just starts those phases earlier. Pair preconnect (for the connection setup) with preload (for the fetch) to collapse both phases simultaneously.

Why is transferSize zero for a resource I can see downloading? Two very different causes share one symptom. Either the response came from the memory or disk cache, in which case decodedBodySize is still positive and deliveryType reports cache; or the resource is cross-origin without Timing-Allow-Origin, in which case every size and every intermediate timestamp is censored to zero while startTime, responseEnd and duration remain real. Check decodedBodySize: positive means cache, zero on a resource you know has bytes means the timing data is censored.

Should I care about Queueing on prefetch and low-priority requests? No — a long Queueing bar on a Low or Lowest priority request is the scheduler doing its job. Speculative prefetches, below-the-fold images and deferred analytics are supposed to wait behind render-critical work. The failure to hunt for is the inverse: a High priority request queued behind lower-priority ones, which is priority inversion. Sort by the Priority column, then look only at the Queueing values on High and Highest rows.

Why does the navigation request show almost no Queueing? The document request is created by the browser process rather than by a renderer, so it never waits on a renderer main thread, and it is first into an empty socket pool. Its equivalent of queueing shows up elsewhere: in redirectEnd − redirectStart for redirect chains, and in workerStart when a service worker controls the page. If the navigation entry looks instant but the page feels slow, the delay is in those two fields or in the document’s own TTFB.

A 12 KB JSON response has a 900 ms Content Download bar. How? Content Download measures responseEnd − responseStart, so it includes any time the server spends holding the connection open after the first byte. A streamed or chunked endpoint that flushes headers immediately and computes the body afterwards reports a tiny TTFB and a huge download bar. TCP slow-start can also matter on a cold connection: the first congestion window carries roughly 14 KB, so a payload just over that threshold pays an extra round trip. Confirm with curl -w '%{time_starttransfer} %{time_total}' — if the gap between the two is large for a small body, the server is streaming, not the network.

Do the waterfall phases mean anything under HTTP/3? The phases still exist but their boundaries move. QUIC folds the transport and TLS handshakes together, so Chromium reports the combined setup under Initial Connection with a short or zero SSL segment rather than two separate bars. Connection migration adds a case with no HTTP/2 equivalent: a network change mid-load keeps the connection alive, so you see no new handshake where a TCP stack would have shown a full reconnect. Verify the protocol per request with nextHopProtocol before comparing a QUIC trace against an HTTP/2 baseline.