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 shown as a coloured bar with a label and example duration. 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)

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

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+

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

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.

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.

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;

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.

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.