Skip to content <?xml version="1.0" encoding="UTF-8"?>

Custom 404 Page With Cloudflare Workers

I was working recently on a project and I couldn’t configure the server to return a custom 404 page. Returning the server’s default error page is not user-friendly at all, I really wanted to have a custom 404 page in place to help users find the content they are looking for.

I had Cloudflare already set up in front of the application and I thought it would be a good opportunity to play with Cloudflare Workers to get around this issue.

The most straightforward way to do it is to write the worker like so:

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
  const response = await fetch(request);

  // if the page was not found
  if (response.status === 404) {
    // we return our custom 404 page
    return await fetch("https://www.example.com/404.html");
  }

  // otherwise we return the original response
  return response;
}

There are nevertheless 2 problems with this approach.

This first one is that the HTTP client requesting the server might not accept a HTML response. It can be annoying to get a HTML page when requesting a JSON for example. So instead of hijacking all 404 errors, we want to return a HTML page only when the client accepts the content type text/html.

Since we have access to the original request in the worker context, we can inspect the HTTP Accept header to determine which content types the client accepts.

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

function isHTMLContentTypeAccepted(request) {
  const acceptHeader = request.headers.get("Accept");
  return (
    typeof acceptHeader === "string" && acceptHeader.indexOf("text/html") >= 0
  );
}

async function handleRequest(request) {
  const response = await fetch(request);

  // if the page was not found and the client accepts HTML content type
  if (response.status === 404 && isHTMLContentTypeAccepted(request)) {
    // we return our custom 404 page
    return await fetch("https://www.example.com/404.html");
  }

  return response;
}

The second problem is that the 404 page will be returned with the incorrect HTTP status 200 OK. We need to alter the response before returning it to the client.

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

function isHTMLContentTypeAccepted(request) {
  const acceptHeader = request.headers.get("Accept");
  return (
    typeof acceptHeader === "string" && acceptHeader.indexOf("text/html") >= 0
  );
}

async function handleRequest(request) {
  const response = await fetch(request);

  if (response.status === 404 && isHTMLContentTypeAccepted(request)) {
    // To avoid returning the 404 page with a incorrect HTTP status
    // we extract the status and statusText from the original response (i.e. 404 Not Found)
    const { status, statusText } = response;
    // we fetch the 404 page
    const resp = await fetch("https://www.example.com/404.html");
    // we read the response content
    const html = await resp.text();
    // we extract the response's headers to forward them to the client
    const { headers } = resp;
    // we then return our custom 404 page
     return new Response(html, {
      status,
      statusText,
      headers
     });
  }

  return response;
}

At this point we have a working solution, however, as I was digging into the documentation, I realised that Cloudflare Workers allows us to fetch and stream the response back to the client. Since we are only passing the response through, we can use TransformStream and stream the response body to the client.

addEventListener("fetch", event => {
  event.respondWith(handleRequest(event.request));
});

async function fetchAndStreamNotFoundPage(resp) {
  const { status, statusText } = resp;
  const { readable, writable } = new TransformStream();

  const response = await fetch("https://www.example.com/404.html");
  const { headers } = response;

  response.body.pipeTo(writable);

  return new Response(readable, {
    status,
    statusText,
    headers
  });
}

function isHTMLContentTypeAccepted(request) {
  const acceptHeader = request.headers.get("Accept");
  return (
    typeof acceptHeader === "string" && acceptHeader.indexOf("text/html") >= 0
  );
}

async function handleRequest(request) {
  const response = await fetch(request);

  if (response.status === 404 && isHTMLContentTypeAccepted(request)) {
    return fetchAndStreamNotFoundPage(response);
  }

  return response;
}

That’s it!

Conclusion

This is a very simple example, however, I think that being able to inspect the HTTP request and response at the edge of the network opens up interesting possibilities for developers.

The Cloudflare team has also developed a nice set of tools to test and debug workers which makes the experience really painless.