Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Early hints and modulepreload #7854

Open
noamr opened this issue Apr 25, 2022 · 9 comments · May be fixed by #7862
Open

Early hints and modulepreload #7854

noamr opened this issue Apr 25, 2022 · 9 comments · May be fixed by #7862

Comments

@noamr
Copy link
Contributor

noamr commented Apr 25, 2022

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 modulepreload header.
@bashi
Copy link

bashi commented Apr 25, 2022

+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

@hiroshige-g
Copy link
Contributor

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 modulepreload header.

Just to clarify, does this mean the following?

  • When the early hint modulepreload headers are processed, preload the scripts as if they were preload.
  • 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".

@noamr
Copy link
Contributor Author

noamr commented Apr 25, 2022

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 modulepreload header.

Just to clarify, does this mean the following?

  • When the early hint modulepreload headers are processed, preload the scripts as if they were preload.

Right, with the "special" aspects of modulepreload such as treating empty as as script.

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.

@hiroshige-g
Copy link
Contributor

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.

@noamr
Copy link
Contributor Author

noamr commented Apr 25, 2022

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.

@noamr
Copy link
Contributor Author

noamr commented Apr 25, 2022

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.

@noamr noamr linked a pull request Apr 26, 2022 that will close this issue
3 tasks
@noamr
Copy link
Contributor Author

noamr commented Apr 26, 2022

Created a PR to try to express the proposal.

@Krinkle
Copy link
Member

Krinkle commented Apr 3, 2024

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=preload supports 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 for rel=preload;as=script. When faced with a proposal to add inconsistent CORS behaviour to rel=preload, it was decided (understandably) to favour creating a separate rel=modulepreload keyword instead. — Consider adding type=module for preload as=script w3c/preload#136

  • rel=modulepreload works 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;crossorigin as 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 preload main.js?v1. I can also optionally preload the flattened list in its entirety and add foo.js?v1 and bar.js?v1 too. 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=script was 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 after rel=modulepreload was standardised.
    https://bugzilla.mozilla.org/show_bug.cgi?id=1803744

  • Contrary to the spec, Firefox already allows rel=modulepreload to 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 modulepreload in Link header & early hints #7862 and Early hints and modulepreload #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 preloads main.js?v1, but then also ends up making unused duplicate requests for (unversioned) foo.js and bar.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 Import maps cannot be used with HTTP Link rel=modulepreload  #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:

  1. 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.
  1. 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.
    Screenshot

    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?v1 three 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 requesting style.css three 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?dev preload 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. Screenshot

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.

wmfgerrit pushed a commit to wikimedia/labs-codesearch that referenced this issue Apr 3, 2024
* The majority of time (e.g. 350ms out of 370ms) is spent
  in the backend Hound API request.

* The php-mustache compiler (the Apache/PECL version, not the
  Composer dev version) is about 2x faster than the JS version was
  prior to I3eb612748,
  e.g. 40-50ms (render JS) vs 8-10ms (render php-mustache).

We can make things even faster by flushing the HTTP headers
with a few things so that the browser can start pulling these
(likely browser cache hits) from its disk and/or from the network
if following a permalink.

I've not added HTTP Link headers for preloading the JavaScript files
because:

1. The JS code in this repo is (as of I3eb612748) entirely progressive
   enhancement. Using a preload for these would needly starve bandwidth
   from the HTML and CSS responses, since preloads are naturally given
   a high fetch priority. There is no benefit to preloading with low
   fetchpriority either, since browsers  already discover these early
   in the `<head>`. We don't have deep chains of indirects requests,
   where low fetchpriority could solve a discovery bottleneck.

2. I made the choice to adopt ES6 syntax and ESM native modules,
   with an "importmap" to ensure cache busting. Unfortunately,
   despite preload for classic JS being nearly 10 years old in
   browsers, preloading is still pretty broken for ES6/ESM even
   in the latest version of Firefox/Chrome/Safari.

   We could go compromise and use modern ES6+ via regular <script>
   instead of ESM (with a global variable instead of imports),
   but I kept what we have given little to no benefit per reason 1.

   Longer sad story about lack of ESM preloading at:
   whatwg/html#7854 (comment)

Change-Id: Ic7d01d9de8bcb78aff8c55fe4d3485be0428f098
@smaug----
Copy link
Collaborator

@zqianem

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Development

Successfully merging a pull request may close this issue.

6 participants