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.

npm install etag && npm i -D @types/etag

Using ETags for data requests

Now we can import the etag function inside our entry.server, here we need to export a handleDataRequest function where we are going to manually add the header.

import etag from "etag";
import type { HandleDataRequestFunction } from "remix";

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  let body = await response.text(); // parse the response body as text

  // only add the ETag for GET requests
  if (request.method.toLowerCase() === "get") {
    response.headers.set("etag", etag(body)); // and use it to create the ETag
  }

  return response; // return the response
};

If we navigate client-side inside our application, we will see in the Network tab of our devtools the request to get the data and it should have our Etag, try navigating to the same page and the Etag should remain the same as long as the data didn't change.

Using ETags for document requests

The same way we did with data requests, we can do it in entry.server with the document request, the requests that returns HTML.

To do that, update the handleRequest function in entry.server (the one you export by default) and add the Etag header there.

import etag from "etag";
import { renderToString } from "react-dom/server";
import type { EntryContext } from "remix";
import { RemixServer } from "remix";

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

  headers.set("Content-Type", "text/html");
  // add the Etag header using the markup as value
  headers.set("ETag", etag(markup));

  return new Response("<!DOCTYPE html>" + markup, { status, headers });
}

All Together

Putting all together, this is how our entry.server should look like:

import etag from "etag";
import { renderToString } from "react-dom/server";
import type { EntryContext, HandleDataRequestFunction } from "remix";
import { RemixServer } from "remix";

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

  headers.set("Content-Type", "text/html");
  headers.set("ETag", etag(markup));

  return new Response("<!DOCTYPE html>" + markup, { status, headers });
}

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response
) => {
  let body = await response.text();
  response.headers.set("etag", etag(body));
  return response;
};

Send the 304 Not Modified response

Finally, we need to send a 304 Not Modified response if the If-None-Match header of the request match the Etag. So let's do that.

import etag from "etag";
import { renderToString } from "react-dom/server";
import type { EntryContext, HandleDataRequestFunction } from "remix";
import { RemixServer } from "remix";

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

  headers.set("Content-Type", "text/html");
  headers.set("ETag", etag(markup));

  // check if the `If-None-Match` header matches the ETag
  if (request.headers.get("If-None-Match") === headers.get("ETag")) {
    // and send an empty Response with status 304 and the headers.
    return new Response("", { status: 304, headers });
  }

  return new Response("<!DOCTYPE html>" + markup, { status, headers });
}

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  let body = await response.text();

  if (request.method.toLowerCase() === "get") {
    response.headers.set("etag", etag(body));
    // As with document requests, check the `If-None-Match` header
    // and compare it with the Etag, if they match, send the empty 304 Response
    if (request.headers.get("If-None-Match") === response.headers.get("ETag")) {
      return new Response("", { status: 304, headers: response.headers });
    }
  }

  return response;
};

And that's it, now go a navigate your app, you will see how the size of response you get (the browser may still show a 200) will be way smaller for both, client-side navigations (loaders) and first request.

Note: Ensure you don't have the cache disabled in your Network Devtools.