How to serve ClojureScript files in development
When I develop ClojureScript projects, I almost always use Figwheel. It’s a great tool, but sometimes in development I would end up with stale files. This led to some very confusing debugging sessions. It only happened some of the time, and was always fixed after a hard refresh. I thought about just disabling the browser cache, but I didn’t like ignoring the issue. After seeing colleagues struggle with stale caching, I decided to figure out what was going on, and fix it once and for all.
Cache-Control rules everything around me
The first thing to do was to add a Cache-Control: no-cache
header to all static file responses. Despite the name, no-cache
tells the browser it can cache files, but must always validate them with the server before using them. If the browser’s cached version is up-to-date, a compliant HTTP server should return a 304 Not Modified response, otherwise it serves the new file.
If you don’t provide a caching header to an HTTP response, the browser can choose its own caching behaviour. The browser’s caching heuristics are much more aggressive than you want in development, and lead to the weird caching behaviour I was seeing.
I thought this had fixed the issue, but occasionally I would still notice stale files were being used. After looking closely at the compiled output files, I made a surprising discovery.
ClojureScript copies file modification times
ClojureScript (as of March 2018) copies the last-modified date of ClojureScript source files to the compiled JavaScript target files. This is so that the Clojure compiler can detect changes to source files. JavaScript from the Closure compiler (e.g. goog.base
), gets a modification time that matches the time it was compiled.
Using a Last-Modified
date header for caching is often useful, but was causing two problems here:
- Closure compiled JavaScript doesn’t change from run to run, so caching based on the last modified date will cause it to be unnecessarily re-downloaded.
- JavaScript compiled from ClojureScript that uses macros from other Clojure files will receive the modification date of the consuming ClojureScript file, even if the macro files have changed more recently and triggered a recompilation. This was leading to the second round of (very confusing) caching issues that I saw.
To avoid these issues, I recommend removing the Last-Modified
header from the response when in development.
ETags
To knock both problems on the head once and for all (hopefully), I added a CRC32 checksum based ETag to Figwheel for static file responses. I packaged this up in a library ring-etag-middleware so that other projects could also use it.
Serve 304 Not Modified responses
At this point the browser will check with the server for every ClojureScript file, on every pageload. However, this causes all of the files to be downloaded each time, even if they haven’t changed. The last step is to add ring’s ring.middleware.not-modified/wrap-not-modified
middleware. This will return a “304 Not Modified” response if the ETag provided in the If-None-Match
request header matches the ETag
header in the response.
Summary
As best as I can tell, this has completely solved all of the odd caching issues that I was seeing, while still keeping the app snappy to load by reusing as much of the cache as possible. If you are serving ClojureScript files in development and not using Figwheel, I recommend you follow these three steps:
- Set a
Cache-Control: no-cache
header - Add an ETag to your static file responses
- Remove the
Last-Modified
header - Wrap your responses in
ring.middleware.not-modified/wrap-not-modified
or the equivalent in your Clojure web framework.