Sergio Xalambrí

Fix double data request when prefetching in Remix

Suppose you rendered a <Link prefetch="intent" /> component in your UI with the idea of prefetching the following route code, data, and links. In that case, you may have seen that when you click the link, the browser will make the data request again.

This happens because the browser didn't cache the prefetch request for the data.

When you render a link with prefetch="intent" (or prefetch="render") what Remix does is to render a few <link rel="prefetch" /> tags when the user hover (or the app renders) the link. This causes the browser to send the requests to prefetch the code, the data, and other assets linked with a LinksFunction.

Because the browser does these things, it can also avoid making a request if the URL is already cached.

For example, if the browser has already downloaded the code for a route, it will not download it again.

The problem comes when you request data. Suppose the response doesn't come with a Cache-Control header. In that case, the browser will not cache it, so when the user clicks the link again, the browser will request the server to get the data. Because it was not in their cache!

Setup a Cache-Control header

The fix here is to send the Cache-Control header in the response of the loader. If your loader already sends a Cache-Control header, you don't need to do anything. It will work.

But suppose you don't cache the response, which could be because it's fast enough or because it uses private data. In that case, you will need to set at least a temporary cache.

export let loader: LoaderFunction = async ({ request }) => {
  let data = await getData(request);
  return json(data, {
    headers: { "Cache-Control": "private, max-age=10" },
  });
};

That loader will set a caching policy to be private, so only the browser will cache and not a CDN or proxy and cache for a maximum of 10 seconds.

Only Cache on Prefetch Requests

But that will cache every request to that loader; what happens if you only want to cache the prefetch requests? Well, there's a Purpose header you can use to detect this.

Note: In Mozilla it's Moz-Purpose, and some old browsers use X-Purpose, in the future this may chagne to Sec-Purpose and/or Sec-Fetch-Purpose. Read more on https://lionralfs.dev/blog/exploring-the-usage-of-prefetch-headers.

export let loader: LoaderFunction = async ({ request }) => {
  let data = await getData(request);
  let headers = new Headers();
  let purpose =
    request.headers.get("Purpose") ||
    request.headers.get("X-Purpose") ||
    request.headers.get("Sec-Purpose") ||
    request.headers.get("Sec-Fetch-Purpose") ||
    request.headers.get("Moz-Purpose");

  if (purpose === "prefetch") {
    headers.set("Cache-Control", "private, max-age=10");
  }
  return json(data, { headers });
};

With this, we created a Headers object. If the purpose of the request was prefetch, we set the Cache-Control header to be private and max-age 10 seconds.

Then we send the response with the headers we created; if it wasn't a prefetch request, the headers object would not add any extra header.

Cache every Loader Request

If you have a lot of loaders and you want to support prefetch in all of them, there's something else you could do.

In the entry.server file, we can export a handleDataRequest function, which lets us modify the response of data requests before sending it to the browser. We also have access to the request.

Using that function, we could detect the prefetch request and set the Cache-Control if it was not already defined for the specific response.

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  let isGet = request.method.toLowerCase() === "get";
  let purpose =
    request.headers.get("Purpose") ||
    request.headers.get("X-Purpose") ||
    request.headers.get("Sec-Purpose") ||
    request.headers.get("Sec-Fetch-Purpose") ||
    request.headers.get("Moz-Purpose");
  let isPrefetch = purpose === "prefetch";

  // If it's a GET request and it's a prefetch request and it doesn't have a Cache-Control header
  if (isGet && isPrefetch && !response.headers.has("Cache-Control")) {
    // we will cache for 10 seconds only on the browser
    response.headers.set("Cache-Control", "private, max-age=10");
  }

  return response;
};

With this change, every data request will support prefetch. However, thanks to !response.headers.has("Cache-Control"), we will still allow individual loaders to set their own caching policy. Even a no-cache one if we want specific loaders not to be cached.