Your favorite framework caches better than Astro
Astro is known for being the framework that, despite having the DX and UX of a JS framework, builds to HTML instead of JS. While other frameworks always ship JS (including baseline libraries and code even for non-dynamic elements) - Next 456kB, Nuxt 150kB, Qwik 66kB, Sveltekit 68kB - Astro ships none by default. This makes page loads insanely fast and unintensive.
But this makes Astro slower in one specific case: navigation.
JS frameworks use client side navigation. They only ever send HTML on the initial page load. Otherwise, they render the page via JS in your client. This JS can be cached, and the server is completely bypassed when it is. It's "PWA-lite", close to "cache on navigation but not on load".
JS frameworks pull this off because their assets are versioned - Rollup infinitely caches assets that don't change via hashes, which they got to handle dependencies (including circular ones) correctly and update on every page load. It isn't actually just "cache on navigation but not load". Astro, and HTML in general, have no equivalent.
The most effective workaround: prefetching and prerendering
Most people hover a link for 300ms before clicking it. In that time we can add the page to our cache and, in Chromium-based browsers, start rendering it. In Astro, this looks like:
// astro.config.ts
import { defineConfig } from "astro/config";
export default defineConfig({
prefetch: { prefetchAll: true },
// Uncomment if you want. Right now prerendering only works in Chromium and makes the favicon abruptly flash.
// experimental: { clientPrerender: true },
});
(And also, some hosting platforms need you to change your default cache control from public, max-age=0, must-revalidate to something more like public, max-age=60)
If you're skeptical of this reaching parity with client side navigation, remember that only the HTML has to be refetched, and that it may 304. It's an extra request but barely noticeable, at least relative to the 12 JS chunks a basic and uninteractive SvelteKit site loads.
Less effective workarounds
Some LLMs suggest using a dynamic Cache-Control combined with Vary.
But this creates two caches instead of two strategies for caching and ends up serving too old
versions.
Others suggest making a service worker. This can get complicated quickly though, and again, this will serve too old versions.
Ideally, your content itself would be versioned. But how do you do that? You would need to version your content and instruct browsers to not refetch HTML at all if it has cached content with the correct version, basically reinventing and intercepting etags.
Addendum
I was linked The Curious Case of the Shallow Session SPAs after publishing this post. The data there shows SPAs having a 1:1 hard navigation (initial load) to soft navigation (client side) ratio, which if true makes worrying about optimal caching likely unnecessary.