HTTP/2: The Modern Web Protocol
Server Push: Proactive Resource Delivery
Read aloud is unavailable in this browser.
Anticipating Client Needs
Traditional HTTP follows a strict request-response model: clients ask, servers answer. This seemingly obvious pattern hides a fundamental inefficiency—servers know what clients will need next, but must wait for explicit requests.
When you request index.html, the server knows you'll immediately need style.css and app.js. Yet in HTTP/1.1, the server sends HTML and waits. The browser parses HTML, discovers resources, and makes separate requests. Each round trip adds latency.
HTTP/2 Server Push breaks this pattern entirely. Servers can proactively send resources based on their knowledge of application structure, eliminating round-trip delays for critical assets. When implemented well, server push can shave hundreds of milliseconds from page loads.
By the end of this page, you will understand: (1) The push promise mechanism and PUSH_PROMISE frames, (2) How pushed resources are associated with requests, (3) Cache interaction and validation challenges, (4) Browser handling of pushed resources, (5) When server push helps and when it hurts performance, (6) Why server push has seen limited adoption and what alternatives exist.
The Round-Trip Problem
To appreciate server push, we must understand the latency cost of sequential resource discovery.
Traditional Resource Loading:
Time 0ms: Browser → Server: GET /index.html
Time 50ms: Server → Browser: index.html (contains <link href='style.css'>)
Time 50ms: Browser parses HTML, discovers style.css
Time 50ms: Browser → Server: GET /style.css
Time 100ms: Server → Browser: style.css (contains url('font.woff'))
Time 100ms: Browser parses CSS, discovers font.woff
Time 100ms: Browser → Server: GET /font.woff
Time 150ms: Server → Browser: font.woff
Time 150ms: First meaningful render possible
Three sequential round trips! On a 100ms RTT connection, that's 300ms of pure waiting—not data transfer, just latency.
| Chain | Resources | RTT Cost | Impact |
|---|---|---|---|
| HTML → CSS | HTML references stylesheets | 1 RTT | Blocks rendering |
| CSS → Fonts | CSS @font-face references fonts | 1 RTT | Blocks text rendering (FOIT) |
| CSS → Images | CSS background-image references | 1 RTT | Delays visual completion |
| HTML → JS → API | App.js initializes and calls API | 2 RTT | Delays interactivity |
| JS → Lazy modules | Dynamic imports load code-split chunks | 1+ RTT | Delays feature availability |
The Server's Knowledge Advantage:
The server generating index.html knows what resources it references—the template includes specific CSS and JS files. Yet it sends only what was requested, waiting for the browser to parse and request dependencies.
Traditional workarounds include:
- Resource inlining: Embed CSS/JS in HTML. Breaks caching, bloats HTML.
- Preload hints:
<link rel='preload'>. Requires browser to parse HTML first. - Early hints (103): HTTP 103 status with preload headers. Limited support.
Server push offers a protocol-level solution: start sending resources before the client knows it needs them.
On fast networks, data transfer is nearly instantaneous—latency is the bottleneck. Downloading a 10KB CSS file takes milliseconds, but discovering-then-requesting it takes a full RTT (50-150ms typically). Eliminating that discovery step is where server push provides value.
PUSH_PROMISE Mechanics
Server push uses the PUSH_PROMISE frame to initiate proactive resource delivery. This frame announces what the server intends to push and reserves a stream for the pushed response.
PUSH_PROMISE Frame Structure:
+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|R| Promised Stream ID (31) |
+-+-----------------------------+-------------------------------+
| Header Block Fragment (*) |
+---------------------------------------------------------------+
| Padding (*) |
+---------------------------------------------------------------+
Key fields:
- Promised Stream ID: The even-numbered stream ID for the pushed response
- Header Block Fragment: The request headers for the 'virtual' request being pushed
The Push Sequence:
-
Client requests resource — Standard HEADERS frame on odd stream ID (e.g., Stream 1)
-
Server sends PUSH_PROMISE — On the original stream (1), server announces it will push a resource. The PUSH_PROMISE contains:
- The promised stream ID (even, e.g., Stream 2)
- The pseudoheaders for the pushed request (:method, :path, :authority, :scheme)
- Any other headers that would appear in a real request
-
Client reserves stream — Upon receiving PUSH_PROMISE, client reserves Stream 2 for the promised response. The stream enters 'reserved (remote)' state.
-
Server sends pushed response — On the promised stream (2), server sends HEADERS and DATA frames as normal.
-
Push completes — Server sends DATA with END_STREAM on Stream 2. Client caches the response.
-
Original response continues — Stream 1 proceeds normally, interleaved with push.
A critical rule: PUSH_PROMISE for a resource must be sent BEFORE the response DATA that references it. The server must push CSS before the HTML that links to it arrives, otherwise the browser might parse the HTML and request the CSS before the push arrives—wasting the push.
Push Stream States
Pushed streams follow a specific lifecycle that differs from regular client-initiated streams.
Push Stream State Machine:
+--------+
recv PP | | send PP
,--------->+ idle +<---------.
| | | |
| +---+----+ |
v | v
+----------+ | +----------+
| | | | |
| reserved | | | reserved |
| (remote) | | | (local) |
| | | | |
+----+-----+ | +----+-----+
| | |
| recv H | send H |
| | |
v v v
+----------+ +--------+ NOT USED
| half | | | for push
| closed | | open |
| (local) | | |
| | +--------+
+----+-----+
|
| recv DATA + ES
|
v
+--------+
| |
| closed |
| |
+--------+
The client's perspective:
- idle → reserved (remote): Upon receiving PUSH_PROMISE
- reserved (remote) → half-closed (local): Upon receiving HEADERS
- half-closed (local) → closed: Upon receiving DATA with END_STREAM
Why Half-Closed (Local)?
A pushed stream is half-closed (local) from the client's perspective because:
- The server sends data (HEADERS, DATA)
- The client doesn't send data (the request was synthetic, from PUSH_PROMISE)
However, the client CAN send:
- RST_STREAM: Cancel the push (don't want/need it)
- WINDOW_UPDATE: Flow control for incoming data
Canceling Pushes:
Clients can reject pushes by sending RST_STREAM with error code CANCEL or REFUSED_STREAM:
Client receives: PUSH_PROMISE (Stream 2, /style.css)
Client has cached: /style.css (fresh)
Client sends: RST_STREAM (Stream 2, CANCEL)
Server response: Stops sending Stream 2 data
This is crucial—without cancellation, servers might push resources clients already have, wasting bandwidth.
Server-initiated streams (for push) use even stream IDs (2, 4, 6, ...), while client-initiated streams use odd IDs (1, 3, 5, ...). This prevents ID conflicts. The server allocates push stream IDs from its pool, typically sequentially.
Cache Interaction
Server push's interaction with browser caching is one of its most complex aspects—and a primary reason for its limited adoption.
The Fundamental Challenge:
When a server pushes /style.css, the browser may already have it cached. Options:
- Accept push into cache — Wastes bandwidth if cached version is fresh
- Cancel push after checking cache — RST_STREAM has latency; data may arrive first
- Accept push, compare with cache — Complex, potential for stale data
Browsers typically implement option 2, but the race condition is problematic:
T0: Server sends PUSH_PROMISE for /style.css
T1: Server begins sending CSS data
T2: Browser receives PUSH_PROMISE, checks cache
T3: Browser finds cached CSS, sends RST_STREAM
T4: Server receives RST_STREAM, stops sending
Problem: Bytes sent between T1 and T4 are wasted
| Cache State | Push Behavior | Outcome | Efficiency |
|---|---|---|---|
| No cache entry | Accept push | Resource cached | Optimal ✓ |
| Fresh cache entry | Cancel push | Use existing cache | Wasted bytes ✗ |
| Stale cache entry | Accept push or revalidate | Complex decision | Depends ⚠ |
| Vary mismatch | May accept or reject | Difficult to validate | Problematic ✗ |
Cache Digest Proposals:
Several proposals attempted to solve the cache coordination problem:
1. Cache Digests (abandoned): Client sends a Bloom filter of cached resources to the server. Server consults digest before pushing. Never standardized due to privacy concerns (reveals browsing history) and implementation complexity.
2. Accept-Push-Policy (proposed): Header signaling client's push preferences. Never gained traction.
3. No automatic solution (current state): Servers push blindly; clients cancel as fast as possible. Wasteful but functional.
The lack of cache coordination is a major reason server push hasn't achieved its potential. Servers can't know what clients have cached, so they either over-push (wasting bandwidth) or under-push (missing optimization opportunities).
Aggressive pushing on repeat visits is particularly problematic. A returning user with warm caches sees zero benefit from push—every pushed resource is canceled. On bandwidth-constrained connections, this overhead can actually hurt performance compared to not pushing at all.
Browser Behavior
Different browsers handle server push differently, and these differences affect practical deployment.
Push Handling Flow:
- Browser receives PUSH_PROMISE
- Browser validates the push is allowed:
- Same origin as initiating request
- Not disabled via SETTINGS_ENABLE_PUSH = 0
- Within concurrent stream limits
- Browser checks cache for matching entry
- If cached, browser may cancel (RST_STREAM) or accept and compare
- If not cached, browser accepts push and stores response
- •Chrome: Accepts pushes into a push cache separate from HTTP cache. Resources matched when navigation creates a request. Cancels pushes for cached resources (with race condition).
- •Firefox: Similar to Chrome with separate push cache. Has historically had varying levels of push support.
- •Safari: Supports push but with more conservative acceptance. May reject pushes more aggressively.
- •Edge (Chromium): Follows Chrome behavior since adopting Chromium engine.
- •Mobile browsers: Often disable or limit push due to bandwidth concerns and data sensitivity.
Push Cache vs HTTP Cache:
Browsers typically maintain a separate "push cache" distinct from the standard HTTP cache:
Push arrives → Stored in push cache (per-connection)
↓
Navigation requests resource → Check push cache first
↓
If found: Move to HTTP cache, use response
If not: Normal HTTP cache check, then network request
The push cache is usually:
- Connection-scoped (cleared when H2 connection closes)
- Keyed by URL and request headers (from PUSH_PROMISE)
- Checked before HTTP cache for matching requests
- Time-limited (unclaimed pushes may be discarded)
Pushed resources must be used quickly. If the HTML takes too long to parse and reference the pushed CSS, browsers may evict the push from the temporary push cache. Servers should push only resources that will be requested within seconds of the push arriving.
When Push Helps
Server push provides genuine benefits in specific scenarios. Understanding these scenarios helps identify where push deployment is worthwhile.
Optimal Push Scenarios:
-
First-Time Visitors: Empty cache means pushed resources are always useful. No wasted bandwidth on cache collisions.
-
Critical Path Resources: CSS that blocks rendering, Web Fonts that cause FOIT, JS required for interactivity. Eliminating one RTT for these high-impact resources matters.
-
Dynamic Resource Discovery: Resources discovered only after executing JavaScript (not in static HTML). Push delivers them before the JS even runs.
-
Known Application Structure: SPA frameworks where the server knows exactly what resources the shell needs. Every push is certain to be used.
| Scenario | Typical Benefit | RTT Saved | Recommendation |
|---|---|---|---|
| First visit, critical CSS | 100-200ms improvement | 1 RTT | Push (high value) |
| First visit, web fonts | 100-300ms reduction in FOIT | 1-2 RTT | Push (high value) |
| First visit, hero image | 50-150ms improvement | 1 RTT | Maybe push (large size) |
| Repeat visit, cached resources | 0ms (negative with overhead) | 0 | Don't push |
| SPA navigation | Varies by architecture | 1+ RTT | Consider alternatives |
Quantifying the Benefit:
Consider a page with critical CSS (15KB):
Without push (100ms RTT):
T0: Request HTML
T100: Receive HTML, start parsing
T100: Request CSS (discovered in HTML)
T200: Receive CSS, start rendering
Total: 200ms to first render
With push:
T0: Request HTML
T0-50: Push CSS arrives (started immediately)
T100: Receive HTML, CSS already in push cache
T100: Start rendering immediately
Total: 100ms to first render
Savings: 100ms (1 RTT) — Significant for perceived performance.
Push works best when: (1) Resources are critical for rendering, (2) Visitors have cold caches, (3) Resources are small-to-medium size (not large images/videos), (4) The server confidently knows what to push. Meeting all four criteria is the sweet spot.
When Push Hurts
Server push can actually degrade performance in several scenarios. Understanding anti-patterns prevents well-intentioned push implementations from causing harm.
Push Anti-Patterns:
- •Repeat visitors: Cached resources are pushed and canceled—pure waste
- •Large resources: Pushing a 2MB image consumes bandwidth that could carry other responses
- •Uncertain resources: Pushing JS that user behavior might not require
- •Third-party resources: Can't push cross-origin resources
- •Resources behind decisions: User-dependent content that varies
- •Many RST_STREAM CANCEL frames in traffic
- •Higher bandwidth usage without faster loads
- •Time to interactive increases (push delays critical path)
- •Mobile users complain about data usage
- •Performance varies wildly between first/repeat visits
The Priority Inversion Problem:
One of push's most serious issues: pushed resources compete for bandwidth with the main response.
Scenario: Server pushes 50KB CSS while sending 30KB HTML
Bandwidth allocation might be:
[HTML chunk][CSS chunk][HTML chunk][CSS chunk]...
Result:
- HTML takes longer to complete
- Browser can't parse HTML until more arrives
- Net effect: SLOWER first render despite push
The HTML (which the client explicitly requested and needs to discover other resources) is delayed by the pushed CSS. Proper priority handling helps, but many servers don't implement it correctly.
On bandwidth-constrained connections (mobile networks, slow WiFi), push can be actively harmful. Every byte sent as push is a byte not used for the primary response. If the pushed resource isn't needed immediately or is already cached, that bandwidth is wasted at the expense of what the user actually requested.
Real-World Disappointment:
Many high-profile sites experimented with push and abandoned it:
- Google: Tested aggressively, found minimal benefits, backed off
- Facebook: Limited push use; relies on preloading instead
- Cloudflare: Offered push support; usage remained low
The consensus: Push's complexity exceeds its benefits in most cases. The cache coordination problem is essentially unsolvable at the protocol level, making push a footgun unless carefully tuned for specific first-visit scenarios.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.