Early hints and `modulepreload`
Currently in the spec, early hints work only for preload (and soon for preconnect).
In Chromium source code (as I understand it) and in WPT, modulepreload is also supported, but it's treated like a preload in the sense that it doesn't start loading module dependencies. It still uses the special fetching defaults/parameters of modulepreload like having script as the default for as.
I see how it's problematic to start creating actual script objects before the document is initialized, but it also changes the semantics of modulepreload when it's an early hint, which might be confusing - e.g. if the developer doesn't add an actual equivalent <link rel=modulepreload> to the document, the dependencies will only be fetched when the module is imported in practice.
I suggest the following strategy in the spec (and implementation):
- Keep the existing Chromium behavior when the early hint headers are processed
- Once the document is created, also preload the script graph immediately as if the document had a regular
modulepreloadheader.
+1 for the suggestion. I'd like to hear @hiroshige-g's opinion.
Note that Chromium's current implementation is somewhat different from the ideal. As the design document explains it makes a request upon a reception of an Early Hints preload and put the response in the HTTP cache (no network request happens when there is a fresh response in the cache already). When the document makes a request for the same resource the response hopefully comes from the HTTP cache. It doesn't recognize the semantics of modulepreload now.
We don't think the current approach is a long-term solution and we would like to have a proper implementation in the future -- doing so requires a lot of engineering work and we are trying to figure out a reasonable approach.
cc: @yutakahirano
I suggest the following strategy in the spec (and implementation):
- Keep the existing Chromium behavior when the early hint headers are processed
- Once the document is created, also preload the script graph immediately as if the document had a regular
modulepreloadheader.
Just to clarify, does this mean the following?
- When the early hint
modulepreloadheaders are processed, preload the scripts as if they werepreload. - Once the document is created, trigger preload the script graph.
Note that preload the script graph anyway doesn't fetch the dependencies (at least in Chromium). Step 3 is marked as "optionally perform".
I suggest the following strategy in the spec (and implementation):
- Keep the existing Chromium behavior when the early hint headers are processed
- Once the document is created, also preload the script graph immediately as if the document had a regular
modulepreloadheader.Just to clarify, does this mean the following?
- When the early hint
modulepreloadheaders are processed, preload the scripts as if they werepreload.
Right, with the "special" aspects of modulepreload such as treating empty as as script.
- Once the document is created, trigger preload the script graph.
Exactly, passing in the response we already have and modifying that algorithm to optionally accept a response argument.
Note that preload the script graph anyway doesn't fetch the dependencies (at least in Chromium). Step 3 is marked as "optionally perform".
Right, but at the very least the module is added to the module map, and this "optionally perform" would become viable for early hints.
Thanks for clarification!
After looking further at the Chromium Early Hints source code, "preload the scripts as if they were preload" is actually a tweaked version for module scripts (and thus there are no exactly corresponding case in <link rel=preload>).
cc @domenic @irori.
Thanks for clarification!
After looking further at the Chromium Early Hints source code, "preload the scripts as if they were
preload" is actually a tweaked version for module scripts (and thus there are no exactly corresponding case in<link rel=preload>).cc @domenic @irori.
Yes, that's what I meant by "special" aspects of modulepreload.
Anyway, I can work on a PR along those lines, where early hints performs the modulepreload steps all the way until the response, and continues to fetch the script graph with that response once the document is initialized. Perhaps a PR would be easier to discuss.
Created a PR to try to express the proposal.
In a project I'm working on, I made the choice to adopt ES6+ syntax and ESM native modules, with an "importmap" to faccilitate versioning/cache busting. Upon transitioning the preload strategy, I believe I am now stuck with no options available that aren't actively harmful in some way. The reason the project has a preload strategy, is that some endpoints involve non-trivial server-side logic where Page Load Time benefits from concurrently requesting static assets (flush headers before the HTTP body. HTTP body is cannot feasibly be flushed in parts).
Assumptions
Let me back up a bit, and share what my assumptions are at this point. Correct me if any of this is wrong!
-
rel=preloadsupports scripts but not modules. Modules are requested with different CORS default settings than scripts, and so the eventual real request for<script type=module src=…>would not match the preloaded fetch forrel=preload;as=script. When faced with a proposal to add inconsistent CORS behaviour to rel=preload, it was decided (understandably) to favour creating a separaterel=modulepreloadkeyword instead. — https://github.com/w3c/preload/issues/136 -
rel=modulepreloadworks and even has cross-browser support for importmaps and eager dependency resolution. But, the standard does not specify "Has Link processing" for this, thus is currently limited to HTML<link>tags, and not available to Early Hints / HTTP Link headers. – https://html.spec.whatwg.org/multipage/links.html#linkTypes:link-type-preload -
I considered using
rel=preload;as=script;crossoriginas a way to take responsibility for CORS on my own and help the browser. This works in Chrome, and thus I can use this today to preloadmain.js?v1. I can also optionally preload the flattened list in its entirety and addfoo.js?v1andbar.js?v1too. In this way, I would consider rel=preload to be fulfilling the need of an a low-level extendable primitive, in the spirit of The Extensible Web Manifesto.Unfortunately, Firefox differs in how it makes or matches requests and ends up making a duplicate request. Is this a bug in Chrome for cache over-use, or Firefox bug for cache under-use?
-
rel=preload;as=script;type=scriptwas briefly supported by Firefox, including in Link headers, and appears to have worked correctly for this purpose, the same way as the above still does in Chrome today. But, Mozilla removed this feature afterrel=modulepreloadwas standardised. – https://bugzilla.mozilla.org/show_bug.cgi?id=1803744 -
Contrary to the spec, Firefox already allows
rel=modulepreloadto be used in HTTP Link headers. This was implemented and shipped last year in Firefox 116 (https://bugzilla.mozilla.org/show_bug.cgi?id=1773056#c4, https://bugzilla.mozilla.org/show_bug.cgi?id=1798319, https://hg.mozilla.org/mozilla-central/rev/5cafcb0a03c8).There is also a spec proposal by @noamr at https://github.com/whatwg/html/pull/7862 and https://github.com/whatwg/html/issues/7854.
Unfortunately, this is too powerful for its own good. When given
Link: </static/main.js?v1>;rel=modulepreload, Firefox 123 (latest stable as of writing) correctly preloadsmain.js?v1, but then also ends up making unused duplicate requests for (unversioned)foo.jsandbar.js. This can't be mitigated at the moment, since recursion is on by default (not configurable/extensible) and importmaps support is still pending standardisation at https://github.com/whatwg/html/issues/9274.
Observations
It seeems thus that today, when transitioning from having 3 classic scripts to 3 ESM files in a project, there is currently no option available that provides the same level of correct/complete preloading as was already available to classic scripts.
What I tried:
- preload with
rel=preload;as=script;crossorigin.
- Chrome: fine to DIY (one works, flat list upfront also works if developer wants to invest in that)
- Firefox: actively harmful, makes an unused preload fetch then also makes a duplicate for the real one.
- Safari: actively harmful, makes an unused preload fetch then also makes a duplicate for the real one.
- preload either one or all scripts with
rel=modulepreload. Given
Link: </static/bar.js?dev>;rel=modulepreload,</static/foo.js?dev>;rel=modulepreload,</static/main.js?dev>;rel=modulepreload
<script type="importmap">
{
"imports": {
"/static/foo.js": "/static/foo.js?dev",
"/static/bar.js": "/static/bar.js?dev",
"/static/main.js": "/static/main.js?dev"
}
}
</script>
<link rel="modulepreload" href="/static/foo.js?dev">
<link rel="modulepreload" href="/static/bar.js?dev">
<script type="module" src="/static/main.js?dev"></script>
-
Firefox 123: correctly preloads and re-use 3 files, but also eagerly downloads bare import references, causing early-duplicate requests for dependencies. Not completely harmful since the correct cache did (also) end up warmed early and used to satisfy the later demand.
-
Safari 17.4: broken beyond my understanding.
- Makes requests for all 3 modulepreload URLs from the Link header. It appears to make these with the corret CORS setting (Origin header) and doesn't recurse into dependencies, which is fine.
- Makes additional (duplicate) requests for the 2 modulepreload URLs in the HTML link element. Fails reuse.
- Makes additional (duplicate) request for the main URL in the HTML script element. Fails reuse.
- Makes additional (duplicate) requests for the imported dependencies. Fails reuse.
Safari is making 9 requests, 3x for each of the 3 files. All with the correct URL (no bare ones, WebKit Inspector hides querystring) and CORS setting. It even fetches
main?v1three times despite only being requested twice (Link header and script tag). The middle one corresponds to a line an unrelated element after the importmap script. That seems to trigger Safari into making a request somehow. Notice it is doing the same for stylesheets as well. It is requestingstyle.cssthree times. Once for the Link header. Once for no apparent reason after reading an importmap relating to JS files. And a third time for the actual stylesheet link tag. And CSS isn't even at issue in terms of potential preload mismatch. I'm going to assume I've hit an edge case and that under some definition of normal circumstances, simple CSS preloads aren't broken in this way in Safari. -
Chrome 123: correctly preloads and re-use 3 files. Like Firefox, and unlike what @noamr observed in 2022, Chrome is now also eagerly resolving dependencies from the modulepreload, and doing so without the importmap. Although it seems to be doing that last step very late (e.g. near DOMContentLoaded). Which means the waterfall looks rather funny. Well-after the
main?devpreload has been consumed by the DOM, and the definitive importmap is known, utilized, and satisfied; Chrome then starts to revisit the old modulepreload response it got earlier and decides to make two additional duplicate requests for the 2 unversioned imports.
So... in conclusion, it appears there are no options available to progressively enhance performance by preloading some (or all) ESM files as module scripts. Each of the options I found is actively harmful in at least one major browser. I'm leaving this here in case I've missed something. I'd love to know of a way that I can at least declare one of the JS resources for preloading, in a way that works in 1 browser and is safely ignored in any browsers that don't support it yet.
@zqianem
@Krinkle, apologies for the delay; my understanding of the modulepreload side of the problem is this:
Early Hints modulepreload and import maps can't work together because conceptually, Early Hints prepends a <link rel="modulepreload"> to the document's head^1, which since it comes before the <script type="importmap">, should disable the import map according to spec^2.
However, Chromium seems to have a bug^3 where Early Hint modulepreloads don't disable the import map, (but having an actual <link rel="modulepreload"> element in the HTML will); this may have led to the belief that the above combination was feasible when it really wasn't.
Here's a minimal repro of the current situation: https://github.com/zqianem/modulepreload-krinkle-repro
For both Firefox and Chromium, the recursion from the Early Hints only starts after index.html is parsed, even when index.html doesn't have any content.