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

Loading CSS asynchronously without FoUC #195

Open
noamr opened this issue Jan 27, 2025 · 10 comments
Open

Loading CSS asynchronously without FoUC #195

noamr opened this issue Jan 27, 2025 · 10 comments

Comments

@noamr
Copy link

noamr commented Jan 27, 2025

Introduction

The following user paint points existed for a long period of time in the web:

  • pages loading slowly due to slow external resources
  • Flashes of unstyled content (FoUC).

This has been tricky for web developers to tackle, as often solving one of these can damage the other. For example, loading all the styles asynchronously would load the page faster, but would lead to more FoUCs. Web developers are currently able to achieve this effect, albeit by using scripts (changing the rel or media on the link element's load handler).

whatwg/html#3983 shows a long conversation about this topic.

The crux of the problem is that in the majority of cases, CSS is imperative for correctly displaying the content that relies on it. So loading it asynchronously is almost guaranteed to result in a FoUC.

However, there are certain use cases to loading CSS asynchronously that are worth exploring.

Use Cases

Style into existence

A common and valid use case for asynchronously loading stylesheets is for elements that are hidden by default, but the existence of the asynchronously loading style would change that and display them:

<!-- main.html -->
<style>
.help-widget { display: none } 
</style>
<link rel=stylesheet href="help-widget.css">
// help-widget.css
. help-widget { display: block } 

In this case, the help widget would appear at some time in the future, when help-widget.css is loaded. This would not be a FoUC.

Font-swap

Font loading is a pain point for performance optimization across the web. One common technique today is "swapping" - displaying a local font, and swapping it with a heavy web-font once loaded:

@font-face heavy-sans {
  font-family: "Heavy-Sans";
  src: local("Sans-Serif") url ("https://fonts.example.com/heavy-sans");
  font-display: swap;
}

This works, however usually the CSS that loads the font is remotely-loaded by itself, so we might be render-blocked before we even know that we have a swappable fonts.

Goals (Optional)

This would be successful if it provides one or more solutions to the above use cases (or to other similar use cases), that makes it more ergonomic and less error prone to develop and load efficiently, without adding to the risk of FoUC.

Non-goals (Optional)

A generic "async CSS without scripting" solution is a non-goal. HTML should be opinionated about preventing FoUC by default, and expert approaches that can cause FoUC can remain in JS-land.

Proposed Solution

Proposing to treat the "style-into-existence" and "font swap" problems as separate ones.

"Hidden until resources loaded"

Make it easier for developers to use this technique without requiring script, e.g.

<div id="help-widget" hidden="until-ready">
  <link rel=stylesheet href="help.css">
</div>

In the above example, the help.css stylesheet would not block the parser, or anything outside of #help-widget, but #help-widget would remain hidden until it is loaded (+ async scripts). We'd have to make it so that styles loaded this way are scoped, which should be doable with nesting.

Font stylesheet

It is a valid use case to asynchronously load stylesheets with @font-face descriptor that swap fonts. Perhaps we can allow that?

<link rel="font stylesheet" href="https://font.example.com/descriptors.css?display=swap">

The above link would be downloaded asynchronously, but would only parse font-descriptors from the stylesheet.
It's backwards-compatible as it would behave like a regular stylesheet on browsers that don't support it.

Examples (Recommended)

See above.

Privacy & Security Considerations

(TBD, a bit early)

Let’s Discuss

  • Did we forget important use cases for async CSS?
  • Anything else?
@scottjehl
Copy link

scottjehl commented Jan 29, 2025

Thanks for writing up this alternative to consider, @noamr.

There's a lot to address here and I am sorry it'll be a little long. I wrote an answer a few different ways before deciding it'd be easiest to talk about the pros and cons of the existing solution we already have today, and then getting into why I feel this new proposal falls short of the pattern we already have. I'll end with the (perhaps unsurprising) recommendation that I feel is most flexible and intuitive.

Existing Solution Benefits

As the prior issue notes, we have a long-standing pattern that is relatively well-known for loading a stylesheet asynchronously, and it gives authors a tool to address every use case described in the prior issue. That pattern is this one, known as the print media hack:

<link rel="stylesheet" href="my-low-priority.css" media="print" onload="this.media='all'">

To give an example, authors can already use this workaround for "styling elements into existence," and they can do so without any concerns for a Flash of Unstyled Content (FOUC).

Reusing the help widget example from above, this works great today:

<style>
.help-widget { display: none } 
</style>
<link rel=stylesheet href="help-widget.css" media="print" onload="this.media='all'">
 ...
 ...
<div class="help-widget">...</div>

Let's highlight some benefits of this existing pattern:

  1. Ability to style one or many elements: A nice benefit to this workaround is that an async stylesheet can contain styles for all sorts of disparate elements. Maybe it has styles for several async features of a UI, and those pieces of UI happen to appear in many places within the HTML, using different elements and selectors. It's flexible enough to handle that.
  2. Link DOM-Location Independence: Another benefit to this workaround is that it doesn't matter where the link is located in the DOM. The link might be near the element it's styling, or inside it, or more likely just in the head of the page. Any of those locations are fine because the attributes do the work. This is important and expected for resource loading in general, as teams frequently control their resources from the head, and they may not even have the ability to place link elements piecemeal throughout the body to make them load in a different manner. Much like how script[async] behaves, authors shouldn't need to change the DOM location of a particular link, or add attributes to its parent elements, to change a link's render blocking behavior, or its fetch priority, etc. We've come to know that the convention is to change the element's attributes for that sort of thing.
  3. Async HTML Compatibility: Another nice benefit of this existing approach is that the HTML it "styles into existence" doesn't need to be in the initial HTML at all. I find for widgets, that's a pretty common use case. For example, <div class="help-widget">...</div> might get injected by a third-party script, as many help and chat widgets do. The print media hack lets you load the html and the css for a widget asynchronously.
  4. Handles the "fonts" use case: The existing approach already addresses loading a "fonts" CSS file asynchronously, a common way people use async CSS today. Say you're loading a CSS file that will ultimately define font-face rules that use font-display: swap, or perhaps you're even handling your own font-display:block behavior for selected elements in the page while their font CSS loads asynchronously (an admittedly more advanced use case). I've done both of these in projects and love that the existing solution makes it possible.
  5. Flexibility for the future: Lastly, the existing workaround supports miscellaneous async CSS loading needs that we aren't listing here, and I'd argue that shouldn't be discounted. Just like async scripts, this is a low-level tool that can be used for all sorts of things. It allows flexibility for future innovation. If a team deems a stylesheet or two unnecessary to block the initial page rendering for any reason, it'd be great for them to have a standard and intuitive means of controlling that.

Existing Solution Drawbacks

The print-media workaround addresses all use cases well, but it has the following problems:

  1. It's not intuitive for authors: an author who wants to load a stylesheet asynchronously is unlikely to think of adding media="print" onload="this.media='all'". They'd likely try or search for attributes that describe the behavior (eg async).
  2. It's not intuitive for maintainers: a future maintainer of a codebase who encounters this pattern may not know what it's doing at a glance. As a counter-example, an async attribute is very clear in what it's there for.
  3. It relies on JavaScript: we shouldn't need to add JavaScript onload event handlers to get a resource to load without blocking rendering. Even JavaScript files themselves can load asynchronously without needing to add JavaScript onload handlers :)
  4. It's not standard. The workaround relies on side effects. There's no direct reassurance that the way the print-media hack works today will work that way tomorrow, because it's relying on the loading behavior of non-matching-media stylesheets. If a future browser or standards specification changes how they load non-matching-media stylesheets in any way, it could impact this workflow. A standard documented approach doesn't have that same problem.

In summary, the drawbacks of the existing workaround have nothing to do with how it works. The problem is mostly just that it's not documented and intuitive as a standard.

Drawbacks of the new proposals in this issue

This issue proposes two means of triggering an asynchronous stylesheet request:

  1. A new until-ready value for the hidden attribute intended to support the "styling into existence" use case.
  • This falls short of the print-media hack with regards to: Intuitive pattern, Styling one or many elements, DOM-Location Independence, Async HTML Compatibility, and Flexibility for the future (as it's quite prescriptive for styling a descendent element).
  1. A new "font" rel value, which would load async and ignore non-font rules in the linked stylesheet.
  • This falls short of the print-media hack for: it's limited to loading font-face rules and a new value of rel=font stylesheet sounds like a prominent, default pattern for loading fonts, as opposed to one font loading approach among many.

In all, these proposals are not as flexible or intuitive as the approach below:

Recommended Standardization Approach

Based on the benefits and drawbacks outlined above, standardizing the behavior authors already use via the "print media" workaround is the most flexible, intuitive option we have:

<link rel="stylesheet" href="my-low-priority.css" async>

Thanks so much!

@zcorpan
Copy link

zcorpan commented Jan 29, 2025

To give an example, authors can already use this workaround for "styling elements into existence," and they can do so without any concerns for a Flash of Unstyled Content (FOUC).

This is not true; authors have to take steps like specifying display: none inline on the relevant elements to avoid FOUC.

@scottjehl
Copy link

scottjehl commented Jan 29, 2025 via email

@noamr
Copy link
Author

noamr commented Jan 30, 2025

Thanks for writing up this alternative to consider, @noamr.

There's a lot to address here and I am sorry it'll be a little long. I wrote an answer a few different ways before deciding it'd be easiest to talk about the pros and cons of the existing solution we already have today, and then getting into why I feel this new proposal falls short of the pattern we already have. I'll end with the (perhaps unsurprising) recommendation that I feel is most flexible and intuitive.

Existing Solution Benefits

As the prior issue notes, we have a long-standing pattern that is relatively well-known for loading a stylesheet asynchronously, and it gives authors a tool to address every use case described in the prior issue. That pattern is this one, known as the print media hack:

<link rel="stylesheet" href="my-low-priority.css" media="print" onload="this.media='all'">

To give an example, authors can already use this workaround for "styling elements into existence," and they can do so without any concerns for a Flash of Unstyled Content (FOUC).

Reusing the help widget example from above, this works great today:

<style>
.help-widget { display: none } 
</style>
<link rel=stylesheet href="help-widget.css" media="print" onload="this.media='all'">
 ...
 ...
<div class="help-widget">...</div>

Let's highlight some benefits of this existing pattern:

  1. Ability to style one or many elements: A nice benefit to this workaround is that an async stylesheet can contain styles for all sorts of disparate elements. Maybe it has styles for several async features of a UI, and those pieces of UI happen to appear in many places within the HTML, using different elements and selectors. It's flexible enough to handle that.

I see this as a bug and not a feature, It is very FoUC-risky if not done by an expert.

  1. Async HTML Compatibility: Another nice benefit of this existing approach is that the HTML it "styles into existence" doesn't need to be in the initial HTML at all. I find for widgets, that's a pretty common use case. For example, <div class="help-widget">...</div> might get injected by a third-party script, as many help and chat widgets do. The print media hack lets you load the html and the css for a widget asynchronously.

If you're loading the widget via script, you can also load its style into adoptedStylesheet or do whatever you want with its load event.

  1. Handles the "fonts" use case: The existing approach already addresses loading a "fonts" CSS file asynchronously, a common way people use async CSS today. Say you're loading a CSS file that will ultimately define font-face rules that use font-display: swap, or perhaps you're even handling your own font-display:block behavior for selected elements in the page while their font CSS loads asynchronously (an admittedly more advanced use case). I've done both of these in projects and love that the existing solution makes it possible.
  2. Flexibility for the future: Lastly, the existing workaround supports miscellaneous async CSS loading needs that we aren't listing here, and I'd argue that shouldn't be discounted. Just like async scripts, this is a low-level tool that can be used for all sorts of things. It allows flexibility for future innovation. If a team deems a stylesheet or two unnecessary to block the initial page rendering for any reason, it'd be great for them to have a standard and intuitive means of controlling tha

It handles both these use cases poorly, unless done by an expert. Experts can already achieve this with JS. As per the last WHATWG meeting, we are not going to make something "more intuitive" that requires expertise to achieve without FoUCs and already has a JS way of achieving.

Existing Solution Drawbacks

The print-media workaround addresses all use cases well, but it has the following problems:

  1. It's not intuitive for authors: an author who wants to load a stylesheet asynchronously is unlikely to think of adding media="print" onload="this.media='all'". They'd likely try or search for attributes that describe the behavior (eg async).
  2. It's not intuitive for maintainers: a future maintainer of a codebase who encounters this pattern may not know what it's doing at a glance. As a counter-example, an async attribute is very clear in what it's there for.
  3. It relies on JavaScript: we shouldn't need to add JavaScript onload event handlers to get a resource to load without blocking rendering. Even JavaScript files themselves can load asynchronously without needing to add JavaScript onload handlers :)
  4. It's not standard. The workaround relies on side effects. There's no direct reassurance that the way the print-media hack works today will work that way tomorrow, because it's relying on the loading behavior of non-matching-media stylesheets. If a future browser or standards specification changes how they load non-matching-media stylesheets in any way, it could impact this workflow. A standard documented approach doesn't have that same problem.

For 1, 2 & 4 above: you can also fetch the CSS yourself and apply it once it's fetched. It's still JS, but it's very straightforward.

In summary, the drawbacks of the existing workaround have nothing to do with how it works. The problem is mostly just that it's not documented and intuitive as a standard.

Drawbacks of the new proposals in this issue

This issue proposes two means of triggering an asynchronous stylesheet request:

In this whole writeup you ignore the drawbacks of the current solution, which is that without expertise it would create a FoUC. This makes your argument incomplete.

Note that we have these problems in the field today with <script async>. People don't understand how it works and create race conditions for themselves. We shouldn't use it as an example we want to replicate.

@scottjehl
Copy link

Note that we have these problems in the field today with <script async>. People don't understand how it works and create race conditions for themselves. We shouldn't use it as an example we want to replicate.

My understanding and experience is that script[async] is an occasionally useful and uncontroversial feature, and I was not aware that it is considered a bad precedent or that it's popularly misused. My advocacy for borrowing its syntax has been based in a presumed agreement that it's a good model. If it's the position of the WG that script[async] is poor precedent, that definitely explains the opposition to reusing it, but I hope the feature remains standard as I find it quite useful at times.

@noamr
Copy link
Author

noamr commented Jan 30, 2025

Note that we have these problems in the field today with <script async>. People don't understand how it works and create race conditions for themselves. We shouldn't use it as an example we want to replicate.

My understanding and experience is that script[async] is an occasionally useful and uncontroversial feature, and I was not aware that it is considered a bad precedent or that it's popularly misused. My advocacy for borrowing its syntax has been based in a presumed agreement that it's a good model. If it's the position of the WG that script[async] is poor precedent, that definitely explains the opposition to reusing it, but I hope the feature remains standard as I find it quite useful at times.

No worries, it's too late to remove it:)
A safe way to present it it that it's at the very least not a consensus at WHATWG that <script async> is a good precedence.

@zachleat
Copy link

zachleat commented Jan 30, 2025

Just to establish a baseline for the discussion, how might one compartmentalize <link async> separate from something like <img loading="lazy"> (which has special behavior when nested inside of a [hidden] element)?

There might be some middle ground here that mixes the two approaches:

<div id="help-widget" hidden>
  <link rel=stylesheet href="help.css" async>
</div>

I’m fine with the above but I wouldn’t consider it (or hidden="until-ready" by itself) a complete solution to the problems I’m facing as an author, especially since most folks still operate in bundler-world where I’d want to consolidate my async stylesheet requests together.

Are we saying that something like <link async> wouldn’t be acceptable because we need to know what elements it would affect ahead of download to prevent a FoUC? And if so, how can we break that barrier without relying on attributes on an ancestor? Something like an invokers-esque relationship between the elements?

<!-- not this—just for illustrative purposes -->
<link rel=stylesheet href="help.css" async id="my-bundle">
<div id="help-widget" hidden="until-ready" hiddenfor="my-bundle"></div>

@noamr
Copy link
Author

noamr commented Jan 30, 2025

Are we saying that something like <link async> wouldn’t be acceptable because we need to know what elements it would affect ahead of download to prevent a FoUC? And if so, how can we break that barrier without relying on attributes on an ancestor? Something like an invokers-esque relationship between the elements?

We can structure it like this, but we'd need to solve the scoping problem.
As in, part of the proposal that help.css would only include styles that are scoped to its ancestor.

We could say that whenever hiddenfor (or whatever) is used, only selectors that are scoped to this element would apply.

This would be similar to the following, which would also work bundling-wise:

<div id="help-widget" hidden="until-ready">
  <link rel=stylesheet href=bundle.css>
</div>

<div id="shopping-cart" hidden="until-ready">
  <link rel=stylesheet href=bundle.css>
</div>

the stylesheet would be loaded twice, but only parts of it would be used.
Also, I wonder if the @sheet proposal can help with the bundling aspect of this:

// bundle.css
@sheet help {
 ...
}

@sheet cart {
 ...
}
<div id="help-widget" hidden="until-ready">
  <link rel=stylesheet href=bundle.css sheet=help>
</div>

<div id="shopping-cart" hidden="until-ready">
  <link rel=stylesheet href=bundle.css sheet=cart>
</div>```

@zachleat
Copy link

It’s giving scoped attribute vibes (though I’m not aware of the reasons why that proposal didn’t work out but I’ll link to that discussion whatwg/html#552)

@noamr
Copy link
Author

noamr commented Jan 30, 2025

It’s giving scoped attribute vibes (though I’m not aware of the reasons why that proposal didn’t work out but I’ll link to that discussion whatwg/html#552)

Yea but :scope and nesting have been shipped in the meantime... it's changed shape but it exists.

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

No branches or pull requests

4 participants