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:

  1. On first render of a route, parse the <head> for <link>, <script>, <style> tags
  2. Cache the discovered assets as preload hints for that route pattern
  3. 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_hints in config
  • Global early_hints in 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_route cap prevents header bloat
  • Auto-discovery (Phase 3b) as opt-in
  • All tests passing