HTTP Cache: Finding a performance improvement on frameworks on Cloudflare Workers


ยท 11 min read

At a hackathon (Platanus Build Night, 25/04/2025), I experimented with Cloudflare Workers' new Static Assets feature, which allowed me to build email handling and a static site with the same domain and deployment with ease.

Static Assets allows to, as its name says, to have static assets deployed with Workers, to serve complete web apps with all the features of Cloudflare, which wasn't possible before. It was relatively new (08/04/2025), after a 6-month beta.

This addition improves the support of deployments of popular frameworks, like Next.js with OpenNext. Going back to the story, for the hackathon I used a simple Vite + Tanstack Router app, since I didn't need SSR and queries were to my Convex backend, with the addition of Email Workers.

It went great! The deployment of the app was like 3s for Convex on save, and like 10s for Cloudflare with the deploy command, which I believe allowed me to complete the app in less than 10 hours.

But I noted something weird. The app loaded slower than I expected. This static site connected to Convex loaded in about 1.25s. Convex might be primarily responsible since it's hosted on the US versus the Chilean servers of Cloudflare, and I could optimize the startup around that. But all of the 1.25s? It couldn't be.

So I checked the performance panel, and saw this:

Performance panel of Chrome. It shows a timeline of the app load, showing a 160ms delay for the page, 3 groups of requests for JS and CSS that took 100ms each, and a long, more than 1s purple line, representing a WebSocket connection.

There it was! Ignoring (for now) the purple line that is the Convex WebSocket connection, there are pesky blocks of JS and CSS in the network panel that take close to 300ms! This is a capture of a reload with cache enabled (and populated), and made the first paint of the app take more than 1.25s.

To understand the problem, I need a detour to explain about caching.

HTTP Caching

There's a lot that could be talked about caching. Here, I will only outline some patterns that I believe are important to know. While it's about HTTP caching, some might be applicable to other types of caching between two components or computers with resources associated with a distinct key.

Without caching

When the response might change on every request, there is no point in caching.

HTTP/1.1 200 OK
Cache-Control: no-cache

It might be surprising at first that responses with that header are stored for future use in a browser! The catch is that it only reuses the response on certain scenarios, like back/forward navigation, and revalidation.

This pattern is most useful for dynamically rendered resources, like SSR pages and HTTP APIs.

The no-cache directive allows the browser to store it on the cache, but with a revalidation request if it exists.

Must revalidate

There is no need to download a resource if it hasn't changed. For that, the response can include when it was Last-Modified or an ETag hash:

HTTP/1.1 200 OK
Last-Modified: Sat, 11 May 2025 14:30:00 GMT
ETag: "9141fe0d7996ce56448a3d021ce401141e34a9f4"
Cache-Control: max-age=604800, must-revalidate

On subsequent requests, the browser will send:

GET /files/post-image.png HTTP/1.1
If-Modified-Since: Sat, 11 May 2025 14:30:00 GMT
If-None-Match: "9141fe0d7996ce56448a3d021ce401141e34a9f4"

And if the server can determine that it hasn't changed, it will respond with only:

HTTP/1.1 304 Not Modified
Cache-Control: max-age=604800, must-revalidate

If it was changed, the server returns a 200 OK response like before.

This pattern allows caching responses without validation for max-age (it revalidates only when stale), and the must-revalidate forces the browser to revalidate after it becomes stale. max-age=0 or no-cache will revalidate on every request.

Note that, for performance reasons, browsers like Chrome will not revalidate secondary resources on page reloads unless directives like no-cache or max-age=0, must-revalidate are used.

Because each resource tracks if it was modified, must revalidate works great for storage buckets, where the contents are static but might change.

Stale while revalidate

Sometimes, it's okay to serve a stale cache of the resource. For that, there is stale-while-revalidate:

HTTP/1.1 200 OK
Cache-Control: max-age=86400, stale-while-revalidate=2592000

That specific response will use the fresh content for 1 day, and if the resource is stale, it will use the stale content for 30 days while it fetches the new content in the background.

This is really useful combined with the public directive, which allows intermediary servers to cache the resource, like CDNs. This will serve a fast response on stale, while the browser or CDN re-fetches. Note that not every CDN supports stale-while-revalidate and some might use it only internally.

With the combination of CDNs request collapsing, in which multiple requests to the same resource are collapsed into a single request, allows generated content to be served with minimal load to the generator server.

Immutable

In some cases, the resource never changes, and can be cached for a long time.

HTTP/1.1 200 OK
Cache-Control: public, max-age=31536000, immutable

In that response, the resource can be cached for a year, and with immutable, it shouldn't be revalidated for any reason. Because of that, this type of caching can be problematic when applied incorrectly, since after the response has been given, there is no way to invalidate it with simple HTTP headers.

To make resources immutable, the resource is usually given a unique key. This is usually called cache busting.

GET /assets/main-1a2b3c4d5e6f7g8h9i0j.js HTTP/1.1

The key is usually composed with the original name with a hash, a version number, or a timestamp, between the name and the extension.

Since the original resources can change their key, the parent resource must be updated to reference them with the new key. Since this changes the parent, it propagates recursively to ancestors, until it reaches the root, which can be an HTML page or a Web Worker entry file.

<html lang="en">
<head>
  <link rel="stylesheet" href="/assets/main-kf62nsu2ndtp.css" />
  <script src="/assets/main-0dn37dn2ifdg.js"></script>
</head>
<body>
  <img src="/assets/logo-1a2b3c4d5e6f.png" />
</body>

Most web frameworks and bundlers like Vite do this automatically, adding a hash to the name, and serving those assets with the immutable header. So, for example, you can serve assets from a server, place a CDN in front, and the CDN will cache the resource for a year, decreasing the load of the server.

Immutability allows web pages to behave more like apps. Subsequent loads will be much faster since there isn't a request for immutable resources, not even for revalidation. Also, when the web app is updated, the browser only needs to download changed resources, and not the whole app.

Back to my static app

If you haven't seen the problem yet, knowing how Cloudflare Workers Static Assets works might help. It mostly can be boiled down to:

If you haven't seen it yet, go back to the image of the performance panel: There are multiple requests for assets. The assets are bundled with Vite, so they have a unique key, and the cache was enabled and populated.

The problem is that immutable assets are served with a revalidation strategy!

Vite bundles each immutable asset to a specific directory, that is /assets by default. So, to fix this problem, it's necessary to create a _headers file that adds specific headers to assets served with Cloudflare Workers Static Assets:

/assets/*
    Cache-Control: public, max-age=31536000, immutable

Lets look at the performance with the cache populated and the new headers:

Performance panel of Chrome. The 3 groups of requests of CSS and JS were reduced to be nearly instantaneous.

From 1250ms to around 900ms, just by adding the appropriate cache headers!

While Cloudflare Workers has a Vite Plugin, it doesn't add default headers to the build output, so the _headers file needs to be added manually. But there isn't any recommendation about it in the documentation!

Outside of the hackathon, I was using OpenNext to deploy a Next.js app, which also doesn't generate the _headers file! The next-on-pages adapter did, but is now considered deprecated. Adding a similar file, changing the /assets/* to /_next/static/* reduced network calls of the site.

From the hackathon project to the entire world

Most (if not all) Cloudflare Workers SDKs and tools are open source, and accept contributions.

The simple fix is usually very simple: Adding a _headers file, usually inside a public directory so that it can be uploaded with all the static assets. It isn't intuitive, since the immutable assets aren't really there, but it works.

I made a contribution to OpenNext, changing the instructions to set up a Next.js project in Cloudflare, to add that file. After the merge, I have already seen a couple of projects applying this fix.

For Vite-based frameworks, it's more complicated, since the path to the immutable assets can be modified. It could be assumed that it doesn't change, and it could be added to each starting template. But should that file be documented in every template and framework?

My proposal is to work with the build tool (Vite). Provide primitives like _headers, _redirects, and _routes.json, but use build tools with great defaults. That's similar to how Vercel with its build output API or Netlify with netlify.toml work, providing primitives for frameworks to optimize.

The complete push of the OpenNext fix isn't complete yet (it's missing from some official docs), and the discussion of the proposal is still open at the moment of publishing this post. It will probably take some time, especially in landing how the final API will be and implementing it in the Vite plugin.

Conclusion

While this side quest took time that I could have used to continue with the project, it not only helped me learn more about HTTP caching, but it also allowed me to improve the performance of a website that I had in production, and to improve the performance not only of my projects on Cloudflare Workers, but also will improve the user experience by default for all projects with the fix.

I believe this is the power of open collaboration. If we are able to share solutions to our problems, we all benefit ๐Ÿš€. And that's even when the companies involved could be considered competitors (see Cloudflare and Netlify on OpenNext).


Bonus: Fixing the issue I had with Convex

The problem was that the entire app was waiting for authentication to be ready before loading the router.

function App() {
  const authState = useConvexAuth();
  if (authState.isLoading) return null; // Here
  return <RouterProvider router={router} context={{ auth: authState }} />;
}

The thing is, that the useConvexAuth hook doesn't work well in Tanstack Router (and probably other frameworks), since it's a hook, and frameworks usually expect normal JS functions. What useConvexAuth really is, is a Promise in a hook's disguise. So I removed the disguise to create a promise:

export function useAuth() {
  const authState = useConvexAuth();  //                   [v] starts as false
  const [prevLoadingState, setPrevLoadingState] = useState(authState.isLoading);
  const resolvers = useRef(Promise.withResolvers<boolean>());

  if (authState.isLoading !== prevLoadingState) {
    if (authState.isLoading) {
      // isLoading went back to a loading state, reset promise
      resolvers.current = Promise.withResolvers<boolean>();
    } else {
      // Auth is ready, resolve
      resolvers.current.resolve(authState.isAuthenticated);
    }
    setPrevLoadingState(authState.isLoading);
  }

  return {
    waitAuthenticated: () => resolvers.current.promise,
  };
}

I can pass that to the framework context, and use it like this to check authentication on only routes that need it:

const authenticated = await context.auth.waitAuthenticated();

The only additional piece that's missing, which is irrelevant here, is invalidating the context when the user logs out. Anyways, here is the final result:

Performance panel of Chrome. It shows the First Paint at 170ms, after the request of the HTML and running the cached JS and CSS. The WebSocket connection and a request of an image start at 130ms, without blocking the initial paint of the page.

First paint at 170ms down from 1250ms. Nice.