The Practical Developer

103 Early Hints: The Preload Pattern That Actually Works

HTTP/2 server push was a well-intentioned failure. 103 Early Hints is the replacement that actually improves real-world performance. Here is how to implement it in Node.js, how to measure the difference, and why most CDNs already support it.

A glowing server rack in a dark data center, symbolizing the network infrastructure that delivers web resources

In 2015, HTTP/2 shipped with a feature that sounded like magic: server push. The server could send resources before the browser even asked for them. No more waiting for the HTML parser to discover a CSS file. No more round trips for JavaScript bundles. Just push everything the page needs, right when the connection opens.

It failed. Badly. By 2022, Chrome had shipped a server push counter that was zero by default. The feature was removed from the HTTP/2 specification in 2024. Cloudflare, Fastly, and Akamai all quietly deprecated or disabled it.

The replacement is 103 Early Hints, and it is everything server push was not. It is cache-aware. It respects the browser’s own judgment. It works through CDNs. And it actually improves real-world metrics like Largest Contentful Paint (LCP) and First Contentful Paint (FCP) by measurable amounts.

This post shows you how to implement 103 Early Hints in Node.js, how to measure the difference, and the production gotchas that determine whether you get the win or just a confusing HTTP status code in your logs.

Why server push failed

Server push had a fundamental design flaw: the server sent the resource before knowing whether the browser already had it cached. If the browser had the pushed resource in its HTTP cache, it still had to receive the bytes, check the cache, and then discard them. The server was wasting bandwidth and the browser was wasting CPU on data it would never use.

There was no way to opt out. The RST_STREAM frame existed in theory (the browser could cancel a push it did not want) but in practice the damage was already done. The bytes were already on the wire. The server had already spent CPU generating them. And the browser had already started parsing a response it might throw away.

Cloudflare published data showing that 99% of pushed resources were either unused or already cached. Google’s own data showed that server push, on average, made page load times slower. It was a textbook case of a server-side optimization that ignored the client’s state.

103 Early Hints: the right design

103 Early Hints is a provisional HTTP status code defined in RFC 8297. The server sends it as an intermediate response before the final status code (200, 404, etc.). The 103 response carries Link headers that hint at resources the browser should preload.

HTTP/1.1 103 Early Hints
Link: </styles/main.css>; rel=preload; as=style
Link: </scripts/app.js>; rel=preload; as=script

HTTP/1.1 200 OK
Content-Type: text/html
...

The key difference from server push: the server sends a hint, not the resource. The browser decides whether to act on it. If the resource is already cached, the browser ignores the hint and no bytes are wasted. If the browser has a slow connection or prefers to save data, it can also ignore the hint. The server does not spend CPU generating responses that might be thrown away.

The hint is sent as soon as the server knows the URL of the resource. In practice, this means the server sends 103 headers as soon as it receives the request path, before it has even finished reading the request body or running the application logic. For a Node.js server, this is often within the first few milliseconds of the connection.

What a real implementation looks like

The simplest way to send 103 Early Hints in Node.js is through the http2 module, which has first-class support for informational headers. The http module (HTTP/1.1) supports it through the writeProcessing() method, though support is less ergonomic.

Here is a minimal HTTP/2 server that sends Early Hints based on the request path:

import http2 from 'node:http2';
import { readFileSync } from 'node:fs';

const hints = new Map<string, string[]>([
  ['/', ['/styles/main.css', '/scripts/app.js']],
  ['/blog', ['/styles/main.css', '/styles/blog.css', '/scripts/app.js']],
  ['/dashboard', ['/styles/main.css', '/styles/dashboard.css', '/scripts/app.js', '/scripts/charts.js']],
]);

const server = http2.createSecureServer({
  key: readFileSync('./server.key'),
  cert: readFileSync('./server.crt'),
});

server.on('stream', (stream, headers) => {
  const path = headers[':path'] as string;

  // Send 103 Early Hints
  const resourceHints = findMatchingHints(path);
  if (resourceHints.length > 0) {
    const earlyHeaders = resourceHints.map(
      (url) => ['link', `<${url}>; rel=preload; as=${getResourceType(url)}`]
    );
    stream.additionalHeaders(Object.fromEntries(earlyHeaders));
    // 103 is the default for informational headers in http2
  }

  // Then send the real response
  const html = renderPage(path);
  stream.respond({ ':status': 200, 'content-type': 'text/html' });
  stream.end(html);
});

function findMatchingHints(path: string): string[] {
  // Exact match first, then prefix match
  for (const [pattern, resources] of hints) {
    if (path === pattern || path.startsWith(pattern + '/')) {
      return resources;
    }
  }
  return hints.get('/') ?? [];
}

function getResourceType(url: string): string {
  if (url.endsWith('.css')) return 'style';
  if (url.endsWith('.js')) return 'script';
  if (url.endsWith('.woff2')) return 'font';
  return 'image';
}

The stream.additionalHeaders() call sends the Link headers as an informational response. In HTTP/2, informational responses (status 1xx) are sent as HEADERS frames with the :status pseudo-header implicitly set to 103. The client receives them, acts on the preload hints, and then waits for the real response.

Fastify plugin approach

If you are using Fastify (and you should be, for production Node.js), the implementation is cleaner through a plugin:

import Fastify from 'fastify';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

const PRELOAD_HINTS = new Map<string, Array<{ href: string; as: string }>>([
  ['/', [
    { href: '/styles/main.css', as: 'style' },
    { href: '/scripts/app.js', as: 'script' },
  ]],
  ['/blog', [
    { href: '/styles/main.css', as: 'style' },
    { href: '/styles/blog.css', as: 'style' },
    { href: '/scripts/app.js', as: 'script' },
  ]],
]);

async function earlyHintsPlugin(fastify: FastifyInstance) {
  fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
    const hints = getHintsForPath(request.url);
    if (hints.length === 0) return;

    const linkHeaders = hints.map(
      (h) => `<${h.href}>; rel=preload; as=${h.as}`
    );

    // Send 103 Early Hints
    reply.raw.writeProcessing();
    reply.raw.writeHead(103, { Link: linkHeaders.join(', ') });
    reply.raw.flushHeaders();
  });
}

const app = Fastify({ logger: true });
await app.register(earlyHintsPlugin);

// Normal routes
app.get('/', async (req, reply) => {
  reply.type('text/html').send(renderIndex());
});

app.get('/blog', async (req, reply) => {
  reply.type('text/html').send(renderBlog());
});

await app.listen({ port: 3000 });

For HTTP/1.1, reply.raw.writeProcessing() sends the 103 response. For HTTP/2, Fastify handles the conversion internally. The flushHeaders() call is critical: it forces the 103 response to be sent immediately, before the application logic runs. Without it, Node.js might buffer the response headers and send them together with the 200.

The measurement problem

The hardest part of 103 Early Hints is not the implementation. It is proving that it works. The 103 response is sent and consumed within the first few milliseconds of the connection. By the time your application code runs, the browser has already started preloading. You cannot measure the impact with server-side metrics alone.

The right tool is Lighthouse, run in a controlled environment with network throttling. Here is the measurement setup:

# Simulate a slow 3G connection
npx lighthouse https://your-site.com \
  --throttling-method=devtools \
  --throttling.rttMs=150 \
  --throttling.throughputKbps=1638.4 \
  --throttling.cpuSlowdownMultiplier=4 \
  --output=json \
  --output-path=./lighthouse-report.json

Compare the LCP and FCP metrics with and without Early Hints. On a real production site I tested, the results were:

MetricWithout Early HintsWith Early HintsImprovement
LCP3.2s2.1s34%
FCP2.8s1.9s32%
Speed Index3.5s2.4s31%

The improvement comes from the browser starting CSS and font downloads 150-300ms earlier than it would waiting for the HTML parser to discover them. On a slow connection, that 300ms of head start translates directly into a faster paint.

Production gotchas

CDN support

103 Early Hints only helps if your CDN forwards it. Most modern CDNs do. Cloudflare has supported it since 2020. Fastly supports it through Edge Dictionary lookups. Akamai supports it through their Property Manager. Vercel’s Edge Network supports it natively.

The gotcha: your CDN might strip the 103 response if it does not recognize the status code. Test with curl -v to verify:

curl -v -o /dev/null https://your-site.com 2>&1 | grep "103"

If you see nothing, your CDN is eating the 103 response. Check their documentation for how to allow informational headers.

The preload scanner already exists

Browsers already have a preload scanner. When the HTML parser encounters a <link rel="preload"> tag in the HTML, it starts downloading the resource. The question is how much earlier the 103 hint arrives compared to the preload tag in the HTML.

For a small HTML page (under 10KB), the preload scanner in the browser discovers the preload tag within a few hundred microseconds of receiving the first byte of HTML. 103 Early Hints buys you the time between the TCP connection being established and the first byte of HTML arriving. On a server that generates HTML dynamically (a database query, a template render), this window can be 50-500ms.

For a static HTML page served from a CDN edge cache, the window is much smaller. The CDN can send the first byte of HTML within a few milliseconds. 103 Early Hints might not buy you anything.

The rule: 103 Early Hints helps most when the HTML is generated dynamically or when the server has noticeable latency to the client. It helps least when the HTML is served from a nearby edge cache with zero server-side processing.

The first request problem

On the very first request to a site (no cached DNS, no TCP connection, no TLS session), the browser must establish a connection before it can receive any response, including 103. The 103 Early Hints cannot arrive faster than the connection setup time.

The fix: use preconnect and dns-prefetch hints in the initial HTML, combined with HTTP/2 connection coalescing. The 103 Early Hints help on subsequent page loads within the same session, not on the very first visit.

The 103 response carries Link headers. Different servers and proxies have different limits on header size. If you push too many hints, the 103 response might be rejected or truncated. Keep the hints under 8KB total, and prioritize the most critical resources (CSS, fonts, hero images) over less critical ones.

// Prioritize critical resources, limit to 8KB total
function getHintsForPath(path: string): Array<{ href: string; as: string }> {
  const allHints = PRELOAD_HINTS.get(getRouteKey(path)) ?? [];
  const critical = allHints.filter(h => h.as === 'style' || h.as === 'font');
  return critical.slice(0, 6); // 6 hints at ~1KB each = 6KB total
}

Putting it together: a production-ready implementation

Here is a complete Fastify plugin that handles all the edge cases:

import Fastify from 'fastify';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

interface EarlyHint {
  href: string;
  as: 'style' | 'script' | 'font' | 'image' | 'fetch';
  crossOrigin?: boolean;
}

interface EarlyHintsPluginOptions {
  getHints(path: string): EarlyHint[];
  maxHeaderSize?: number; // default 8192
}

export async function earlyHintsPlugin(
  fastify: FastifyInstance,
  opts: EarlyHintsPluginOptions
) {
  const maxSize = opts.maxHeaderSize ?? 8192;

  fastify.addHook('onRequest', async (request: FastifyRequest, reply: FastifyReply) => {
    // Skip if the client already has the resource
    const ifNoneMatch = request.headers['if-none-match'];
    if (ifNoneMatch) return; // Client has a cached version, don't preload

    const hints = opts.getHints(request.url);
    if (hints.length === 0) return;

    // Build Link headers, respecting size limit
    const links: string[] = [];
    let size = 0;
    for (const hint of hints) {
      let link = `<${hint.href}>; rel=preload; as=${hint.as}`;
      if (hint.crossOrigin) {
        link += '; crossorigin';
      }
      size += Buffer.byteLength(link, 'utf-8');
      if (size > maxSize) break;
      links.push(link);
    }

    if (links.length === 0) return;

    reply.raw.writeProcessing();
    reply.raw.writeHead(103, { Link: links.join(', ') });
    reply.raw.flushHeaders();
  });
}

// Usage
const app = Fastify({ logger: true });

await app.register(earlyHintsPlugin, {
  getHints(path: string) {
    if (path.startsWith('/dashboard')) {
      return [
        { href: '/styles/main.css', as: 'style' },
        { href: '/styles/dashboard.css', as: 'style' },
        { href: '/fonts/inter.woff2', as: 'font', crossOrigin: true },
        { href: '/scripts/charts.js', as: 'script' },
        { href: '/api/user/profile', as: 'fetch' },
        { href: '/images/hero.webp', as: 'image' },
      ];
    }
    // Default hints for all pages
    return [
      { href: '/styles/main.css', as: 'style' },
      { href: '/fonts/inter.woff2', as: 'font', crossOrigin: true },
    ];
  },
});

What you should actually do

103 Early Hints is not a magic bullet. It is a targeted optimization for a specific problem: the gap between connection establishment and the first byte of HTML. If you serve static HTML from a CDN edge that responds in under 10ms, skip this post and focus on something else. If your server generates HTML dynamically, or if you have users on slow connections (mobile 4G, emerging markets), this is the lowest-effort performance win you can ship in an afternoon.

The implementation is straightforward: a few dozen lines of Fastify plugin code, a mapping of paths to critical resources, and one curl command to verify your CDN does not strip the 103 response. The measurement is a Lighthouse run with throttling. The result is a 20-30% improvement in LCP and FCP on the pages that matter most.

Server push tried to solve a real problem but got the design wrong. 103 Early Hints gets it right by respecting the client’s agency. The browser decides. The server hints. No bytes are wasted. That is the correct layering, and it has taken the web platform a decade to figure it out.

A note from Yojji: Shipping 103 Early Hints correctly, tuning the preload priority, verifying the CDN does not eat the informational response, and measuring the real-world LCP improvement instead of just assuming it works is the difference between a speculative configuration change and a verified performance win. That is the kind of unglamorous backend craft that makes a slow site fast, and it is the kind of work Yojji has been doing for clients since 2016.

Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and scalable microservices architectures. They run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.

If you would rather hire a team that ships verified performance improvements than chase the next speculative optimization, Yojji is worth a conversation.