fetch
does not respect hard reload2024-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"
.setHeader("Cache-Control", "max-age=900")
responsereturn 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.
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.
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”?
skipCache
identificationThere 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');
.src = '/api/cache-control.js';
scriptEldocument.head.appendChild(scriptEl);
Then, we setup a very basic route on our server3, to respond in kind:
.get('/cache-control.js', (req: Request, res: Response) => {
routerconst cacheControl = req.headers['cache-control'];
const pragma = req.headers['pragma'];
const noCache = cacheControl === 'no-cache' || pragma === 'no-cache';
.send(`window.skipCache = ${noCache};`);
res; })
This brings us to being able to do the following,
// window.d.ts
declare global {
interface Window {
?: boolean;
skipCache
}
}
// logic.ts
fetch(`/api/config/${name}`, {
: window.skipCache ? 'reload' : 'default',
cache })
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.