Faster page transitions with document prefetching

When migrating my blog from Next.js to Remix, I realized that the JavaScript I had was only used to:

  • Prefetch pages in the background for faster page transitions
  • Lazy load images

Since Remix encourages using Web Standards, HTTP, and HTML, and makes it easy to disable JavaScript, I wanted to recreate these functionalities without the 200k+ of JavaScript that come from React and Remix.

I will cover progressive image loading in another post.

Link prefetching

Link prefetching can be used to prefetch various resources such as CSS and JavaScript. But it can also prefetch the document of the page as follows:

<link rel="prefetch" href="/blog" as="document" />

I use that for my site's primary navigation. If you visit my homepage and open the network tab, you should see the /blog page prefetched.

Network tab of https://www.themosaad.com showing the /blog document prefetched

From there, when you click on the /blog link, you should see that it loaded /blog in 3ms from the prefetch cache.

Network tab of https://www.themosaad.com/blog showing that the document loaded in 3ms from the prefetch cache

Double data request

If you're not using a Chromium-based browser, you're probably seeing the document being requested again from server after you click on the link. Which deafets the whole purpose of prefetching.

This is because Chromium-based browsers will cache any prefetched resources for 5 minutes unless the response came with Cache-Control: no-store. I got this piece of info from Tim Kadlec's post on prefetching and age.

For other browsers that support prefetching, you have to set a browser cache to prevent double data request.

Sergio wrote a great Remix-specific article on how to fix double data request when prefetching in Remix.

Setting max-age=300 in the Cache-Control will cache both direct requests and prefetched requests for 5 minutes.

Browser cache per route

export const headers: HeadersFunction = () => ({
  "Cache-Control": "public, max-age=300, s-maxage=31536000",
});

Browser cache for all routes

import type { EntryContext } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { renderToString } from 'react-dom/server'

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

	responseHeaders.set('Content-Type', 'text/html')
	responseHeaders.set('Cache-Control': 'public, max-age=300, s-maxage=31536000')

	return new Response('<!DOCTYPE html>' + markup, {
		status: responseStatusCode,
		headers: responseHeaders,
	})
}

Browser cache for prefetch requests only

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

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

  responseHeaders.set("Content-Type", "text/html");

  let purpose =
    request.headers.get("Purpose") || // chrome, safari, and edge
    request.headers.get("X-Purpose") || // old chrome
    request.headers.get("Sec-Purpose") || // future chrome
    request.headers.get("Sec-Fetch-Purpose") || // future chrome
    request.headers.get("X-Moz"); // firefox

  let isPrefetch = purpose === "prefetch";

  responseHeaders.set(
    "Cache-Control",
    `public, max-age=${isPrefetch ? 300 : 0}, s-maxage=31536000`
  );

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

Prefetch on hover or focus

If you have a huge list of blog posts, you might not want to prefetch all of them. In such case, you can prefetch the first couple of posts normally, and only prefetch the rest if the user hover or focus on them.

For that, I use the below snippet <script dangerouslySetInnerHTML>.

const addPrefetchLinkAfterRelativeLink = (event) => {
  const href = event.target.getAttribute("href");
  const prefetchLink = document.querySelector(
    "link[as='document'][href='" + href + "']"
  );
  if (prefetchLink || href === window.location.pathname) {
    return;
  }
  const link = document.createElement("link");
  link.setAttribute("rel", "prefetch");
  link.setAttribute("href", href);
  link.setAttribute("as", "document");
  event.target.after(link);
};

document.addEventListener("DOMContentLoaded", () => {
  const relativeLinkNodes = document.querySelectorAll("a[href^='/']");
  relativeLinkNodes.forEach((node) => {
    node.addEventListener("focus", addPrefetchLinkAfterRelativeLink);
    node.addEventListener("mouseenter", addPrefetchLinkAfterRelativeLink);
    node.addEventListener("touchstart", addPrefetchLinkAfterRelativeLink, {
      passive: true,
    });
  });
});