home

fetch does not respect hard reload

2024-11-09

In a series of poor choices, I ended up working on an SPA, which fetched a “configuration” object from the server for rendering the layout of a page.

This “configuration” object was coming from an API that was cached server-side, and I thought it would be a fun idea, to allow the server to skipCache if the client is doing a “hard reload” on their browser (so they end up seeing the “latest” configuration).

On the server-side, this was fairly straightforward,

@Path("/config/{name}")
@GET
fun getConfig(
    @PathParam("name") name: String,
    @Context headers: HttpHeaders,
    @Context response: HttpServletResponse,
): Config {
    val cacheControl = headers.getHeaderString("Cache-Control")
    val pragma = headers.getHeaderString("Pragma")
    val skipCache = cacheControl == "no-cache" || pragma == "no-cache"
    response.setHeader("Cache-Control", "max-age=900")
    return mgr.config(name, skipCache)
}

The source of all my troubles began with deciding to also have the client cache the configuration object for about 15 minutes ("Cache-Control: max-age=900") to reduce a bit of wasted calls since this configuration does not change very often.

This brings us to the problem.

“Hard Reload” does not clear the browser cache for the page.

Instead, the way “hard reload” works is by sending "Cache-Control: no-cache" and "Pragma: no-cache" headers with all HTML/CSS/javascript requests 1. It is then, upto the cache-control header respecting server, to serve an un-cached copy of the content.

All’s well, but where’s fetch? There’s an open issue on this, with no conclusion yet.

intended workaround

The intended workaround for forcing a refresh seems to be using the cache option in fetch, like so.

fetch(`/api/config/${name}`, {cache: "reload"})

This works, and sends the same, "Cache-Control" and "Pragma" headers as a “Hard Reload”, but how do we now hook this with just a “Hard Reload”?

convoluted “Hard Reload” / skipCache identification

There are ways to identify if a hard-reload with some service-worker registration shenanigans, which I did not want to explore2.

Abusing the fact that a <script src= is invalidated on a “Hard Reload”, and the fact that this is for any script (that is loaded after document render too), we do the following.

const scriptEl = document.createElement('script');
scriptEl.src = '/api/cache-control.js';
document.head.appendChild(scriptEl);

Then, we setup a very basic route on our server3, to respond in kind:

router.get('/cache-control.js', (req: Request, res: Response) => {
    const cacheControl = req.headers['cache-control'];
    const pragma = req.headers['pragma'];
    const noCache = cacheControl === 'no-cache' || pragma === 'no-cache';
    res.send(`window.skipCache = ${noCache};`);
});

This brings us to being able to do the following,

// window.d.ts
declare global {
    interface Window {
        skipCache?: boolean;
    }
}

// logic.ts
fetch(`/api/config/${name}`, {
    cache: window.skipCache ? 'reload' : 'default',
})

should you do it?

no.4

I am certain react-18 and SSR have ways to deal with this, where the “configuration” fetch happens entirely server-side, but there’s levels of indirection which are reaching a “what-in-the-PHP” is going on over there.

Until I have wrapped my head around it, I am going to continue writing client-side rendered applications, and this is a reasonable solution to me.

I would hope for whatwg to come up with a reasonable cache option, but I am not holding my breath.


  1. unable to find any “official” documentation for this↩︎

  2. because, a. what’s a service-worker? and b. seems overkill to register a service-worker for identifying if a hard-reload happened anyway↩︎

  3. why is one server route in kotlin, and the other in javascript? don’t worry about it.↩︎

  4. Betteridge’s law of headlines↩︎


home