Skip to content

Proposal: 103 Early Hints from @angular/ssr/node, with measurements #33819

Description

@nigrosimone

Command

build

Description

In #26484, @alan-agius4 wrote:

Early Hints are something we want to explore further. However, we need to be cautious about adding Early Hints as similar to modulepreload links they can negatively impact performance.

For example, in the case of SSR, we may prefer prioritizing the download of images over JavaScript to avoid conflicts that could impact Core Web Vitals.

The next comment asked whether any experiments had been done, and the thread was auto-locked before anyone answered.
I ran that experiment. This issue is the result, and a proposal to contribute the implementation, which is written and passing its own tests.

The problem. During SSR the browser is idle. It has asked for a document, and it will not learn that the page needs main-*.js until the document arrives, which is after the server has finished rendering. The render time is therefore added to the time to hydration rather than overlapping it.

The measurement. An Angular 22 application, server-side rendered, over HTTP/2, driven by headless Chrome through the DevTools Protocol. 226 KB bundle, 35 KB document, server paced to about 1.6 Mbps. The number is time to hydration, taken server-side: the app asks the server for an endpoint from afterNextRender, so that request cannot arrive before Angular has hydrated. Seven alternating rounds per arm, medians:

server render time hydrated, with hints hydrated, without sooner by
50 ms 1451 ms 1692 ms 241 ms
300 ms 1466 ms 1941 ms 475 ms
800 ms 1464 ms 2458 ms 994 ms

The shape is the finding, more than any single number. With Early Hints the hydration time does not move:
it stays around 1460 ms whatever the server spends rendering, because the bundle downloads while the render happens.
Without them the two are serial. Early Hints make the render time free, up to what it costs to fetch the assets, so an application that renders in 20 ms gains almost nothing and one that renders in 800 ms gets nearly all of it back.

Two results worth stating because they bound the claim:

  • No double fetches, zero across every round in both arms. Every preload was reused.
    The crossorigin on the module script is what makes that true:
    a module is fetched in CORS mode, and without it the browser cannot match the preload to the fetch.
  • First Contentful Paint does not move (2688 ms in both arms). It should not.
    The page is server-rendered with critical CSS inlined, so the paint never waited for the bundle.
    This moves hydration, not paint, and I would not want the feature sold as anything else.

On the concern in #26484. The measurement does not dismiss it.
This application had no LCP image competing for the bandwidth, so I cannot claim the question is settled, which is why the proposal below is opt-in rather than a default.

Describe the solution you'd like

A method on AngularNodeAppEngine, called before handle():

app.use((req, res, next) => {
  angularApp.writeEarlyHints(req, res).catch(() => {});
  angularApp
    .handle(req)
    .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
    .catch(next);
});

It writes nothing and returns false over HTTP/1.1, once the response has started, or when the document declares nothing worth announcing.

The links come from the rendered document itself, parsed once per process and memoised.
Nothing is invented: the same resources the document declares, announced earlier.
Two deliberate restrictions:

  • Only preconnect and preload are emitted. Browsers ignore modulepreload in a 103, so a module script is announced as rel=preload; as=script; crossorigin.
  • A stylesheet the critical-CSS inliner has deferred (media="print") is skipped: it is off the critical path on purpose, and pulling it forward would compete with what is on it.

HTTP/2 only. Browsers only act on a 103 over HTTP/2 and HTTP/3, and an informational response on an HTTP/1.1 connection can confuse intermediaries that do not expect one.
The node adapter already types Http2ServerRequest and Http2ServerResponse throughout, and writeResponseToNodeResponse already distinguishes the two by the presence of stream, which is the same test used here.

early-hints.ts

/**
 * Matches the `<link>` elements of the document, so their `rel` and `href` can be read.
 */
const LINK_ELEMENT_RE = /<link\b[^>]*>/g;

/**
 * Matches the `<script>` elements that load an external file.
 */
const SCRIPT_ELEMENT_RE = /<script\b([^>]*\bsrc=("|')(.*?)\2[^>]*)>/g;

/**
 * Reads an attribute out of a matched start tag.
 */
function attribute(tag: string, name: string): string | undefined {
  const match = tag.match(new RegExp(`\\b${name}=("|')(.*?)\\1`));

  return match?.[2];
}

/**
 * Resolves a document-relative href against the base href, so the value in the header does not
 * depend on the path the request happened to arrive on.
 */
function resolveHref(href: string, baseHref: string): string | undefined {
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) {
    // an absolute url: usable as it is, and the only kind worth a `preconnect`
    return href;
  }

  if (href.startsWith('/')) {
    return href;
  }

  const base = baseHref.endsWith('/') ? baseHref : `${baseHref}/`;

  return `${base.startsWith('/') ? '' : '/'}${base}${href}`.replace(/\/{2,}/g, '/');
}

/**
 * Builds the `Link` header values for a `103 Early Hints` response out of the rendered document.
 */
export function extractEarlyHintLinks(html: string, baseHref: string): string[] {
  const links = new Set<string>();

  for (const [tag] of html.matchAll(LINK_ELEMENT_RE)) {
    const rel = attribute(tag, 'rel');
    const href = attribute(tag, 'href');
    if (!href) {
      continue;
    }

    const resolved = resolveHref(href, baseHref);
    if (!resolved) {
      continue;
    }

    if (rel === 'preconnect') {
      links.add(`<${resolved}>; rel=preconnect`);
    } else if (rel === 'stylesheet' && attribute(tag, 'media') !== 'print') {
      links.add(`<${resolved}>; rel=preload; as=style`);
    } else if (rel === 'modulepreload') {
      links.add(`<${resolved}>; rel=preload; as=script; crossorigin`);
    }
  }

  for (const [, attributes, , src] of html.matchAll(SCRIPT_ELEMENT_RE)) {
    const resolved = resolveHref(src, baseHref);
    if (!resolved) {
      continue;
    }

    const isModule = attribute(attributes, 'type') === 'module';
    links.add(`<${resolved}>; rel=preload; as=script${isModule ? '; crossorigin' : ''}`);
  }

  return [...links];
}

app-engine.ts

export class AngularAppEngine {
  // ....
  async getEarlyHintLinks(request: Request): Promise<readonly string[]> {
    const serverApp = await this.getAngularServerAppForRequest(request);

    return serverApp ? serverApp.getEarlyHintLinks() : [];
  }

app.ts

async handle(request: Request, requestContext?: unknown): Promise<Response | null> {
  // ....
  getEarlyHintLinks(): Promise<readonly string[]> {
    this.earlyHintLinks ??= this.assets
      .getIndexServerHtml()
      .text()
      .then((html) => extractEarlyHintLinks(html, this.manifest.baseHref));

    return this.earlyHintLinks;
  }

node/src/app-engine.ts

  async writeEarlyHints(
    request: IncomingMessage | Http2ServerRequest | Request,
    response: ServerResponse | Http2ServerResponse,
  ): Promise<boolean> {
    // HTTP/2 only: `stream` is what distinguishes an `Http2ServerResponse`, and it is also what
    // `writeResponseToNodeResponse` uses to tell the two apart.
    if (!('stream' in response)) {
      return false;
    }

    if (response.headersSent || response.writableEnded || response.destroyed) {
      return false;
    }

    const webRequest =
      request instanceof Request
        ? request
        : createWebRequestFromNodeRequest(request, this.trustProxyHeaders);

    const link = await this.angularAppEngine.getEarlyHintLinks(webRequest);
    if (link.length === 0 || response.headersSent || response.writableEnded) {
      return false;
    }

    response.writeEarlyHints({ link: link as string[] });

    return true;
  }

Describe alternatives you've considered

  • <link rel="modulepreload"> in the document, which the CLI already emits.
    It helps, but it is discovered only when the document arrives, so it cannot use the render window at all.
    It is what the "without" column above already has.
  • A CDN generating the hints from the Link header, which is what Cloudflare does and what @naveedahmed1 suggested in Preload all js bundles required for a specific route #26484. It works, but it requires that CDN, and it can only announce what the origin puts in a header, so the origin has to build the list anyway.
  • Doing it in application code. It is possible today: read the index, build the header, call response.writeEarlyHints. That is roughly what I did to measure it.
    It means every application re-deriving the asset list from Angular's own output, which is the sort of thing that breaks quietly on a build change.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions