Phase 3: HTTP 103 Early Hints
Goal
Send preload hints to the browser while SSR is still rendering, so the browser starts fetching CSS/JS/fonts before the 200 response body arrives. FrankenPHP's signature feature — we get it for free because we own the socket.
Current State
- No informational response support
- actix-web sends one response per request
- Static assets are discovered by the browser only after parsing the HTML
<head> - Cold SSR renders take 50-200ms — the browser sits idle during this time
Why This Matters
Without Early Hints:
0ms Browser sends GET /products
150ms Server finishes SSR, sends 200 + HTML
160ms Browser parses <head>, discovers <link rel="stylesheet">
180ms Browser requests /css/main.css
250ms CSS loaded, page renders
Total: 250ms
With Early Hints:
0ms Browser sends GET /products
1ms Server sends 103 with Link: </css/main.css>; rel=preload
2ms Browser starts fetching /css/main.css
150ms Server finishes SSR, sends 200 + HTML
152ms CSS already loaded (fetched during SSR)
152ms Page renders
Total: 152ms — 40% faster
Cloudflare reports 30% improvement in LCP. The gains are largest on cold SSR and ISR cache misses.
Design
Per-Route Preload Configuration
Extend route rules with preload_hints:
[[route_rules]]
pattern = "/products/*"
render = "isr"
# Assets to preload via 103 Early Hints
[[route_rules.preload_hints]]
path = "/css/main.css"
as = "style"
[[route_rules.preload_hints]]
path = "/js/app.js"
as = "script"
[[route_rules.preload_hints]]
path = "/fonts/inter.woff2"
as = "font"
crossorigin = "anonymous"
Global Preload Hints
Common assets shared across all routes:
[server.early_hints]
enabled = true # Default: true when H2 enabled
[[server.early_hints.global]]
path = "/css/main.css"
as = "style"
[[server.early_hints.global]]
path = "/fonts/inter-var.woff2"
as = "font"
crossorigin = "anonymous"
Auto-Discovery (Optional, Phase 3b)
Scan the SSR HTML template or build manifest to automatically discover critical assets:
- On first render of a route, parse the
<head>for<link>,<script>,<style>tags - Cache the discovered assets as preload hints for that route pattern
- Subsequent requests get 103 Early Hints automatically
This is how Cloudflare's Smart Early Hints works — but we can do it at the server level.
Implementation
How HTTP 103 Works Over H2
HTTP/2 supports multiple HEADERS frames before the final response. A 103 response is just an additional HEADERS frame with :status: 103:
HEADERS frame (103)
:status = 103
link = </css/main.css>; rel=preload; as=style
link = </fonts/inter.woff2>; rel=preload; as=font; crossorigin
... server renders page ...
HEADERS frame (200)
:status = 200
content-type = text/html
DATA frame
<html>...
actix-web Limitation
actix-web's HttpResponse doesn't natively support sending 103 before 200. Two approaches:
Option A: Raw H2 frame injection
Use actix-web's on_connect extension to capture the underlying h2::SendResponse and inject a 103 HEADERS frame before the response pipeline runs.
Option B: Protocol-level middleware
The shared request abstraction from Phase 2 (BextRequest / BextResponse) can support a hints: Vec field. The H2 handler serializes hints as a 103 frame before the 200.
Recommended: Option B — cleaner, works for H3 too, and the abstraction already exists from Phase 2.
Request Flow
async fn handle_request(req: BextRequest, state: &AppState) -> BextResponse {
// 1. Match route rules
let route = state.route_rules.match_path(&req.uri.path());
// 2. Collect preload hints (global + per-route)
let hints = collect_early_hints(&state.config, &route);
// 3. Send 103 Early Hints (if H2/H3 and hints exist)
if !hints.is_empty() && req.protocol.supports_informational() {
req.send_early_hints(&hints).await;
}
// 4. Normal request processing (cache check → SSR → response)
let response = process_request(req, state).await;
// 5. Return 200 response
response
}
EarlyHint Type
struct EarlyHint {
path: String,
as_type: AsType, // style | script | font | image | fetch
crossorigin: Option,
nopush: bool, // Hint only, don't push (H2 push is deprecated)
}
enum AsType {
Style,
Script,
Font,
Image,
Fetch,
}
impl EarlyHint {
/// Serialize as Link header value
fn to_link_header(&self) -> String {
let mut link = format!("<{}>; rel=preload; as={}", self.path, self.as_type);
if let Some(ref co) = self.crossorigin {
link.push_str(&format!("; crossorigin={}", co));
}
if self.nopush {
link.push_str("; nopush");
}
link
}
}
Link Header Aggregation
Multiple hints are combined into a single Link header (comma-separated):
Link: </css/main.css>; rel=preload; as=style, </fonts/inter.woff2>; rel=preload; as=font; crossorigin=anonymous
Interaction with ISR Cache
- Cache HIT: 103 hints are sent, then cached 200 is served immediately (hints still valuable — browser starts fetching assets before parsing HTML)
- Cache MISS: 103 hints are sent, then SSR executes (maximum benefit — browser fetches assets during SSR latency)
- Stale-while-revalidate: 103 hints + stale 200 (hints still useful for subsequent navigations)
Config Reference
[server.early_hints]
enabled = true # Enable 103 responses (default: true with H2)
auto_discover = false # Auto-discover from rendered HTML (Phase 3b)
max_hints_per_route = 10 # Cap hints to prevent header bloat
# Global hints (all routes)
[[server.early_hints.global]]
path = "/css/main.css"
as = "style"
# Per-route hints (in route_rules)
[[route_rules]]
pattern = "/**"
[[route_rules.preload_hints]]
path = "/js/app.js"
as = "script"
Testing Plan
| Test | Type | What it validates |
|---|---|---|
| Link header serialization | Unit | EarlyHint::to_link_header() produces valid syntax |
| Hint collection | Unit | Global + per-route hints merged, deduped |
| 103 before 200 over H2 | Integration | Client receives 103 frame before 200 frame |
| H1 fallback | Unit | No 103 sent over HTTP/1.1 (not supported) |
| Cache HIT + hints | Integration | 103 sent even for cached responses |
| Cache MISS + hints | Integration | 103 sent during SSR wait |
| Max hints cap | Unit | Excess hints truncated |
| Auto-discovery (3b) | Integration | <link> tags in first render become hints for subsequent requests |
| No hints when disabled | Unit | enabled = false suppresses 103 |
| H3 Early Hints | Integration | 103 works over QUIC |
Browser Compatibility
| Browser | 103 Support |
|---|---|
| Chrome 103+ | Yes |
| Edge 103+ | Yes |
| Firefox 102+ | Yes |
| Safari 17+ | Yes |
All modern browsers support 103. Older browsers ignore informational responses gracefully.
Done Criteria
- Per-route
preload_hintsin config - Global
early_hintsin server config - 103 response sent before 200 over HTTP/2
- 103 response sent before 200 over HTTP/3
- Graceful no-op on HTTP/1.1
- Works with ISR cache (HIT and MISS)
-
max_hints_per_routecap prevents header bloat - Auto-discovery (Phase 3b) as opt-in
- All tests passing