Table of contents

Preload As Fetch, In Practice

Introduction

You add <link rel="preload" as="fetch"> to your HTML, expecting data fetches to start earlier and finish faster. You test it — and the browser sends two requests instead of reusing the preload. The data arrives no sooner than if you hadn’t preloaded at all.

Getting preloading right is tricky. The <link> tag and fetch() use different knobs to control the same request behaviour, and the two need to match. In this article, I break down which option combinations work, which don’t, and where browsers disagree.

Table of contents

How preloading works

The following code snippets show how to preload a resource using a link tag.

<!DOCTYPE html>
<html>
    <head>
        <link rel="preload" as="fetch" href="./resource" />
        <script src="./script.js"></script>
    </head>
    <body></body>
</html>
/* script.js */
const response = await fetch('./resource', { mode: 'no-cors', credentials: 'include' });
/* Do something with response */

When the browser sees the link tag, it starts fetching the resource. This helps start data loading before the script that needs it has loaded. Later, when some JavaScript fetches the same data, the browser can reuse the preload response if already ready (figure: Early preload response) or wait for it to finish if not (figure: Late preload response).

Early preload response sequence diagram

Early preload response: In this example, the browser receives the response to the preloaded resource before the script attempts fetching it. The browser keeps the response in its cache and reuses it once the script requests it.

Late preload response sequence diagram

Late preload response: In this example, the script attempts fetching the resource before a response is received for the preloading fetch. The browser does not send a second request. Instead, it holds the second fetch until the preloading response is received, reusing it.

How to correctly preload data fetches

When we preload data using link tags, we want the browser to reuse the response to our preload on later fetches. For that to work, the preloading fetch and the fetch done by the JavaScript code have to have matching options, so that the browser considers them as the same request.

Fetch options

Two fetch() options — mode and credentials — determine whether the browser treats a subsequent request as matching the preload:

Preload options

Preload link tags for data fetches have the following form, accepting an optional crossorigin attribute:

<link rel="preload" as="fetch" href="URL-of-data" crossorigin="use-credentials|anonymous" />

If the value given for the crossorigin attribute is an invalid keyword or an empty string, it will be handled as crossorigin="anonymous". Notice also that <link rel="preload"> does not have a way to send requests with mode=no-cors and credentials=omit.

Now that we’ve seen both sides — the fetch() options and the <link> attributes — the question is: which combinations actually work? I ran experiments to find out.

Passing the right options to allow fetch to reuse preloaded responses

In the repo preload-as-fetch-experiments, I conducted two experiments to find out when browsers are able to reuse <link rel="preload" as="fetch"> data by later fetch()s.

Experiment 1 includes a matrix showing which combinations of <link rel="preload"> and fetch() options allow preloaded data to be reliably reused across browsers.

The results show that preloaded data is reused when the following elements are consistent with each other:

I derived the following rules:

If you stray from these rules, some combinations might still work — but not reliably across all browsers:

To make things concrete, here are the three combinations that work reliably across Chrome, Firefox, and Safari:

Same-origin requests

<link rel="preload" as="fetch" href="URL" />
<script>
    fetch("URL", { mode: "no-cors", credentials: "include" });
</script>

Cross-origin requests with credentials

<link rel="preload" as="fetch" href="URL" crossorigin="use-credentials" />
<script>
    fetch("URL", { mode: "cors", credentials: "include" });
</script>

Cross-origin requests without credentials

<link rel="preload" as="fetch" href="URL" crossorigin="anonymous" />
<script>
    fetch("URL", { mode: "cors" });
</script>

These three cover the standard case — no custom headers. But what happens when your fetch() sends custom request headers?

Are preloads reused for fetches with custom headers

In experiment 2, I tested if the browser reuses preloaded data when handling a fetch() which sends custom request headers.

Since we cannot specify custom request headers when using <link rel="preload" as="fetch">, you would expect the following fetch() with custom headers to trigger a new request, but testing showed a more complicated picture.

  1. Chrome always reuses the preloaded data. If the preloading is in progress when the fetch() is triggered, Chrome waits for it to finish and reuses its response.
  2. Firefox reuses preloaded data if it is cacheable. If the preloading is in progress when the fetch() is triggered, Firefox waits for it to finish.
    • If the preload response is cacheable, it is reused by the fetch(), completing the request sooner than if a second request were sent.
    • If the preload response is not cacheable, the browser sends a second separate request, completing later than if it had not waited for the preload.
  3. Safari ignores the preload (whether finished or in progress) and sends a separate HTTP request for the fetch().

Safari’s behavior is restrictive but always correct: a fetch with custom headers is never considered the same request as a preload with standard headers. Chrome’s behavior can lead to showing the wrong data if the server response is different based on request headers. As for Firefox, it lets the server decide whether the preloading response is cached or not. Since Firefox relies on explicit server response headers, its reuse of preloads is less error-prone than Chrome’s.

Conclusion

Preloading fetch data can be tricky. In this article, I documented the narrow set of options that allow you to do it reliably across Chrome, Firefox, and Safari.

To summarize the key takeaways:

When custom request headers are involved, browser behavior diverges significantly:

I observed these behaviors when implementing the pattern “SSR Publicly Cacheable Content And Preload Dynamic Content” using TanStack Start and SolidStart’s server functions, which typically send custom request headers.

The code used to derive the findings of this article is available in the preload-as-fetch-experiments repository.