Sergio Xalambrí

Use ETags in Remix

ETags are strings used to identify a particular version of a resource. If the URL is the identity of a resource the ETag is the version of that resource.

To understand why ETags are useful let's imagine a case, we have a URL like /articles/use-etags-in-remix, if we wanted to cache this resource we could add a Cache-Control header to the response sent by the server and now the browser will store it in its cache, the next time the user access this same resource it will use the cached version until the cache expires and it will request it from the server and then cache it again and the cycle repeats.

Now, imagine we have a way to identify what version of the resource of that URL the server is sending to the browser, let's say we sent the v1, the flow will be the same, browser send the request, server send the response but this time with the ETag including the string v1, the next time the browser cache expires the browser will send a header If-None-Match with the previous ETag (the v1), the server then can check this and check the version of the resource, if the version is the same it can avoid sending the resource and instead send an empty response with the header 304 Not Modified status code.

The browser will not use the previously cached version and consider it as a fresh resource. The benefit of doing this is that we can save bandwidth, on both server and user, by not sending the body and relying on the already cached resource.

Now, usually we don't have a specific version for the resource like v1, instead the common way to generate this version is by hashing the content of the resource. You can do this however you want.

Adding ETags to Remix

Remix doesn't add ETags to the responses by default, but because we have control of almost everything we can do it manually.

Let's start by installing the package etag from npm.

yarn add etag && yarn add -D @types/etag

Because of how this package works to use it with Remix inside a route file we will need to re-export it, so we can create an etag.server.ts with this content:

import etag from "etag";
export default etag;

Now, if we import this file in any other file it will only be included in the server bundle and ignored by the client bundle.

Using ETags in data loaders

Now we can import our etag function inside any route and use it in the loader. For this we need to first get the data of the loader from wherever we are getting it (an API, file system, database, etc.), then convert the data to string and pass it to the etag function, and finally add the result of the etag function to the headers of the response, to do this we can use the json function exported by Remix.

import type { LoaderFunction } from "remix";
import { json } from "remix";
import etag from "../etag.server";

export let loader: LoaderFunction = async () => {
  let data = await getRouteDataSomehow();
  return json(data, {
    headers: { Etag: etag(JSON.stringify(data)) },
  });
};

Using weak ETags in routes

A weak ETag is an ETag which partially match the response content but not byte by byte, to understand it, in our case if we use the Etag of the data returned by the loader as the ETag of the document response (the HTML rendered server-side) this ETag should be considered weak since while the ETag identify the data it will not identify the HTML, so if our route rendered HTML change, let's say we add a new class to the HTML, and the data is always the same we can use the ETag of the data and improve our cache, so the browser will send the ETag of the data and if that's the same it will consider the previously cached document as fresh, even if it may have chanhed.

To do this, we can do two things, first change our loader to generate a weak etag.

export let loader: LoaderFunction = async () => {
  let data = await getRouteDataSomehow();
  return json(data, {
    headers: { Etag: etag(JSON.stringify(data), { weak: true }) },
  });
};

This will automatically add W\ before the ETag, then we can define a headers function in the route, grab the ETag header from the loader headers and return it.

import type { HeadersFunction } from "remix";

export let headers: HeadersFunction = ({ loaderHeaders }) => {
  return { Etag: loaderHeaders.get("Etag") };
};

But, this means a client-side request to the loader, e.g. when Remix does a client-side navigation, will use the weak ETag too, to avoid this, keep the loader as in the previous step without the { weak: true } and manually add the W\ to the ETag in the headers function.

import type { HeadersFunction } from "remix";

export let headers: HeadersFunction = ({ loaderHeaders }) => {
  return { Etag: `W\${loaderHeaders.get("Etag")}` };
};

Now, if you go to your root you will see in the response a ETag header with the W\ prepended, if you navigate to your route from another route with JS enabled the request to get the data of the route which only request the loader will the ETag as a strong one, without the W\ at the beginning.

Using String ETags in document requests

A last option we have is to use a strong ETag for the document requests, to do this, in your entry.server file where you have the handleRequest function import the etag function, after you get the markup of the page in the Response created add the Etag header using the markup to generate it. Here we can use a strong ETag because any change to the HTML will change the ETag, even if the data of the loader is still the same.

import ReactDOMServer from "react-dom/server";
import type { EntryContext, Request } from "remix";
import { RemixServer } from "remix";
import etag from "./utils/etag.server";

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let markup = ReactDOMServer.renderToString(
    <RemixServer context={remixContext} url={request.url} />
  );

  return new Response("<!DOCTYPE html>" + markup, {
    status: responseStatusCode,
    headers: {
      ...Object.fromEntries(responseHeaders),
      "Content-Type": "text/html",
      Etag: etag(markup),
    },
  });
}