You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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():
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. */constLINK_ELEMENT_RE=/<link\b[^>]*>/g;/** * Matches the `<script>` elements that load an external file. */constSCRIPT_ELEMENT_RE=/<script\b([^>]*\bsrc=("|')(.*?)\2[^>]*)>/g;/** * Reads an attribute out of a matched start tag. */functionattribute(tag: string,name: string): string|undefined{constmatch=tag.match(newRegExp(`\\b${name}=("|')(.*?)\\1`));returnmatch?.[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. */functionresolveHref(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`returnhref;}if(href.startsWith('/')){returnhref;}constbase=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. */exportfunctionextractEarlyHintLinks(html: string,baseHref: string): string[]{constlinks=newSet<string>();for(const[tag]ofhtml.matchAll(LINK_ELEMENT_RE)){constrel=attribute(tag,'rel');consthref=attribute(tag,'href');if(!href){continue;}constresolved=resolveHref(href,baseHref);if(!resolved){continue;}if(rel==='preconnect'){links.add(`<${resolved}>; rel=preconnect`);}elseif(rel==='stylesheet'&&attribute(tag,'media')!=='print'){links.add(`<${resolved}>; rel=preload; as=style`);}elseif(rel==='modulepreload'){links.add(`<${resolved}>; rel=preload; as=script; crossorigin`);}}for(const[,attributes,,src]ofhtml.matchAll(SCRIPT_ELEMENT_RE)){constresolved=resolveHref(src,baseHref);if(!resolved){continue;}constisModule=attribute(attributes,'type')==='module';links.add(`<${resolved}>; rel=preload; as=script${isModule ? '; crossorigin' : ''}`);}return[...links];}
asyncwriteEarlyHints(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'inresponse)){returnfalse;}if(response.headersSent||response.writableEnded||response.destroyed){returnfalse;}constwebRequest=requestinstanceofRequest
? request
: createWebRequestFromNodeRequest(request,this.trustProxyHeaders);constlink=awaitthis.angularAppEngine.getEarlyHintLinks(webRequest);if(link.length===0||response.headersSent||response.writableEnded){returnfalse;}response.writeEarlyHints({link: linkasstring[]});returntrue;}
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.
Command
build
Description
In #26484, @alan-agius4 wrote:
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-*.jsuntil 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: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:
The
crossoriginon 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.
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 beforehandle():It writes nothing and returns
falseover 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:
preconnectandpreloadare emitted. Browsers ignoremodulepreloadin a103, so a module script is announced asrel=preload; as=script; crossorigin.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
103over 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
Http2ServerRequestandHttp2ServerResponsethroughout, andwriteResponseToNodeResponsealready distinguishes the two by the presence ofstream, which is the same test used here.early-hints.ts
app-engine.ts
app.ts
node/src/app-engine.ts
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.
Linkheader, 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.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.