My fix was correct. The browser was still running the old file.
I lost a good part of a day to bugs that did not exist. I would edit a file, reload the page, and the thing I had just removed would still be there. I'd re-read the diff, convince myself the change was right, add a log
I lost a good part of a day to bugs that did not exist.
I would edit a file, reload the page, and the thing I had just removed would still be there. I'd re-read the diff, convince myself the change was right, add a log line, reload again. Nothing. Eventually the change would "start working" on its own, I'd move on, and an hour later the same thing would happen with a different file.
It happened repeatedly, on the same day, and every single time the code was fine. The browser was running an older copy of the file than the one on disk.
This post is about why that happened (the short version: a dev server that sends no caching headers at all), the one behavior that surprised me even after I'd found the cause, and the habit I took away from it: "I fixed it" and "it's fixed on the screen" are two different claims, and only the second one is what the user sees. You check the second one by looking at the bytes that were actually delivered, not by re-reading the code.
The setup
The project was a small 3D browser project: plain HTML and JavaScript, a pile of 3D model files, and a video, all served as static files from a local folder. No bundler, no framework. For serving, I used the thing everyone uses for this:
python -m http.server
It's the right tool for "I just need these files on localhost." It also turned out to be the root of the problem.
The symptom
The pattern was always the same:
- Edit a
.jsfile. The change is clearly correct. - Reload the page.
- The old behavior is still there. No error in the console. No 404. Nothing that says "this is stale."
Because nothing looked wrong, every time my first instinct was to debug the code. Is the function actually being called? Did I edit the right file? Is there a second copy of this logic somewhere? Those are all reasonable questions, and they were all the wrong questions, because the code that was running wasn't the code I was reading.
My first workaround was the classic one: append a version query string to every script tag.
<script src="main.js?v=2"></script>
That works, in the sense that a different URL is a different cache entry. But it's a symptom fix. You have to remember to bump it, it spreads across every HTML file that references the script, and it does nothing for files loaded from inside JavaScript. I ended up ripping all of them out once I understood the real cause.
Why the browser didn't ask
Here's what python -m http.server sends when it serves a file. I didn't want to rely on memory for this, so I read the handler in CPython's Lib/http/server.py. In SimpleHTTPRequestHandler.send_head, the file response gets these headers:
self.send_header("Content-type", ctype)
self.send_header("Content-Length", str(fs.st_size))
self.send_header("Last-Modified",
self.date_time_string(fs.st_mtime))
There's a Last-Modified. There's no Cache-Control, no Expires, no ETag.
That combination matters. When a response has no explicit freshness information, HTTP caches are allowed to make one up. RFC 9111 calls this heuristic freshness: if the response has a Last-Modified, a cache can treat it as fresh for some fraction of the time since that date, and the RFC suggests that "a typical setting of this fraction might be 10%" [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-4.2.2].
Walk through what that means in a dev loop:
- A file that hadn't changed in a while gets loaded once. Its
Last-Modifiedis old, so the browser decides the cached copy is good for a meaningful stretch of time. - I edit the file. Its modification time on disk changes, but the browser doesn't know that. It never asks.
- I reload. The browser checks its cache, finds an entry it considers fresh, and uses it without touching the network.
The server would have happily told the browser that the file had changed. CPython's handler even supports conditional requests and answers 304 Not Modified when appropriate. But a conditional request only happens if the browser decides to revalidate, and with heuristic freshness it didn't think it needed to.
So the "bug" was a perfectly standards-compliant cache doing what it's allowed to do, against a server that never told it not to.
The fix: put the policy in the server
Instead of cache-busting every URL, I wrote a tiny replacement for python -m http.server that adds explicit no-cache headers to every response:
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cache-Control", "no-store, must-revalidate")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
and started it like this:
httpd = ThreadingHTTPServer(("", port), handler)
Then I replaced every launch config and helper script that used to call python -m http.server with this one, and deleted the ?v= strings.
Side trap: don't downgrade to a single-threaded server
My first version of this script used plain HTTPServer, because that's what most "custom handler" examples show. It looked fine with one file. With the real page, which requests a few dozen model files and a multi-megabyte video at the same time, page loads stalled until my automation's navigation timed out after several minutes.
The reason: HTTPServer handles one request at a time. python -m http.server doesn't have this problem because it already uses the threading variant internally. If you subclass the handler to change headers, keep ThreadingHTTPServer. Otherwise you swap a caching bug for a concurrency bug that looks like a network problem.
What each of those four headers actually does
I copied that header block from the usual "disable caching" snippets, and only later read what each line means. It's worth doing, because they are not four ways of saying the same thing, and one of them is a reasonable alternative to what I chose.
no-store is the strong one. RFC 9111 says a cache "MUST NOT store any part of either the immediate request or the response" [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.5]. Every reload downloads every file again. For a local dev server that is exactly the guarantee I wanted: there is no cached copy, so there is no stale copy.
no-cache sounds like the same thing and isn't. It allows the browser to store the response, but says it "MUST NOT be used to satisfy any other request without forwarding it for validation" [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.4]. In practice the browser keeps the file and asks the server on every load, sending the Last-Modified value back as If-Modified-Since. If the file hasn't changed, the server answers 304 Not Modified and the browser reuses what it has; the RFC describes a 304 as meaning the stored response "can be updated and reused" [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-4.3.3]. If the file has changed, the server sends the new one. Since CPython's handler already answers conditional requests, no-cache should also have fixed my bug (I didn't try it), while skipping the re-download of unchanged model files. I went with no-store because I wanted no cached copy at all while I was still distrusting the cache, and I haven't measured whether the difference matters for a page like this. If your dev page loads large assets that rarely change, no-cache is the one I'd try first.
must-revalidate only applies once a response is already stale: from then on, a cache "MUST NOT reuse that response" without checking with the server [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.2]. Next to no-store it does nothing, because nothing is stored to go stale. It's harmless, but it isn't what fixed anything.
Expires: 0 and Pragma: no-cache are for older caches. The RFC says invalid dates, especially the value 0, must be interpreted "as representing a time in the past", so the response counts as already expired [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-5.3]. Pragma is officially deprecated [src:official:https://www.rfc-editor.org/rfc/rfc9111#section-5.4]. A modern browser follows Cache-Control, so these two lines are belt and braces for whatever else might sit in between.
The practical takeaway: if you only remember one line, make it Cache-Control, and choose between no-store (never keep a copy) and no-cache (keep a copy, but always ask first) on purpose rather than by copy-paste. Either should have prevented my day of phantom bugs. Neither would have cleaned up the entries that were already cached, which is the next part.
The part that surprised me: no-store doesn't evict what's already cached
With the new server running, I reloaded and... the stale file was still there.
This was the moment I nearly concluded the fix didn't work. It did work. What I'd missed is that Cache-Control: no-store is a property of a response. The browser only learns about it when it actually receives a new response. But the old entry in the cache was still, by its own heuristic, fresh. So the browser served it from cache without going to the network, which means it never received a response carrying the new header.
The new policy was correct, and the browser was never going to see it until something forced a network request for that URL.
What forced it was a one-liner from the page's DevTools console:
await fetch('/main.js', { cache: 'reload' }).then(r => r.text());
MDN describes cache: 'reload' as: the browser fetches from the server "without first looking in the cache, but then will update the cache with the downloaded resource" [src:official:https://developer.mozilla.org/en-US/docs/Web/API/Request/cache]. That's exactly the shape I needed: skip the stale entry, get a real response from the new server, and let that response replace what was cached.
It was also the fastest way to tell the two layers apart. If the text that comes back from fetch(..., {cache: 'reload'}) contains my change and the page still doesn't behave that way, the cache is the suspect. If the text doesn't contain my change, the server is serving an old file and the problem is upstream of the browser.
For a regular browser tab that already had the stale entries, a single hard reload (Ctrl+Shift+R) was needed once. After that, every response carried no-store and the problem didn't come back.
The general lesson: "fixed" is a claim about a specific copy
Looking back, I made the same mistake every time: I verified the change in the place I had made it. I read the file I edited and concluded it was right. That was true. It just wasn't the file that was running.
There are usually several copies of "the code" between your editor and the user's screen, and any one of them can be stale:
source file → build output → deployed version → CDN / edge cache → browser cache → what runs
My incident was entirely in the last hop: a local server and a browser, no build, no deploy, no CDN. But the question is the same at every hop: which copy is actually being served here, and does it contain my change? And the answer should come from a value you can look at, not from re-reading the source.
Here is the checklist I use now. It's not clever. The point is that every step ends in something concrete you can compare.
1. Source: is the change in the file you think is being used?
Not "I edited it." Grep the exact string you changed in the file the page actually loads. Duplicated files and similarly named folders are common, and a quick grep is cheaper than an hour of debugging the wrong copy.
2. Build output: did the change survive the build?
If there's a bundler, grep the built artifact for something unique to your change, or compare its hash before and after. A build that silently reused an old output looks exactly like a code bug.
3. Deploy: is the deployed version the one you built?
Most hosts show a deployment ID or commit for what's live. Compare it to the commit you think you shipped. "I pushed" and "that push is what's serving traffic" are different statements.
4. CDN / edge: what does the public URL actually return?
Look at the headers and the body, not the dashboard:
curl -sI https://example.com/main.js
Check Cache-Control, ETag, Last-Modified, and Age (a large Age means you're looking at a cached copy). Many CDNs also add a cache-status header (Cache-Status per RFC 9211, or a vendor-specific equivalent) that tells you whether it was a hit. Then compare the bytes:
curl -s https://example.com/main.js | sha256sum
sha256sum dist/main.js
If the hashes differ, the copy at the edge is not the copy you built. I do this now for static files I deploy, even small ones like images: fetch the public URL and confirm the bytes match the local file before calling it done.
5. Browser: which copy did this tab use?
In the DevTools Network panel, the Size column shows (disk cache) or (memory cache) when a response didn't come from the network. "Disable cache" in DevTools only applies while DevTools is open, so it can hide the problem from you while your users still hit it. And if you're unsure, fetch(url, {cache: 'reload'}) from the console gives you the current server copy to compare against.
6. Dev servers: does it send any caching headers at all?
This is the one that got me. curl -sI against your localhost. If there's no Cache-Control, the browser is allowed to guess, and its guess may not match your edit loop.
A second, smaller version of the same mistake
On the same day, I hit a different bug with the same shape. An object in the 3D scene appeared tilted when it should have been upright. I spent time in the physics and placement code, because that's what I'd been editing. The cause was in the model file itself: the orientation was baked into the asset.
What finally separated the layers was the same move as with the cache: measure a value instead of reasoning about code. I logged the object's tilt right after spawning it, and it was zero. That one number said the code was placing it correctly, which meant the problem was in what was being placed.
That's the version of the lesson I actually try to apply now: don't search for a cause only in the layer you happen to be working in. The layer you're editing is the one you can see, which makes it the one you suspect first, and the first suspect isn't always the right one.
What I do now
-
Before debugging "my change didn't take effect," prove which copy is running. One
fetch(..., {cache: 'reload'})or onecurlplus a hash comparison usually settles it quickly. -
Put cache policy in the server, not in URLs. For local dev, a server that sends
no-storeremoves a whole class of phantom bugs. Query-string busting is a patch you have to keep re-applying. - Remember that new headers only reach new responses. Changing the server's policy doesn't clean up entries the browser already considers fresh. Expect to force one real request per stale URL.
-
Keep the threading. If you replace
python -m http.serverwith your own handler, useThreadingHTTPServer. - Report "fixed" with a delivered value. "The served file's hash matches the build" or "the response has my change" is a claim someone else can check. "I edited the code" isn't.
None of this is new. Caching has been biting web developers for as long as there have been caches. What was new for me was how convincing the wrong diagnosis was: every time, the diff was right, so every time I believed the diff.
I'm building an AI code reviewer (getdevreview.com) that tries to flag the bugs that matter instead of everything it can find. A bug like this one would never show up in a diff, which is part of why I've become strict about checking the delivered copy myself. If you have a favorite trick for telling "stale" from "broken" quickly, I'd like to hear it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.