I replaced an image and deployed it, but users kept seeing the old image. Refreshing the browser did not change anything, and other PCs also showed the old image for a while.
The CDN cache was always invalidated after each deploy by calling the Akamai Purge API, so at first I thought it was a CDN cache problem. Contrary to what I expected, the cause was the browser cache.
Below I go through why the image did not change even after the purge, how I fixed it with cache busting, and how I verified that it also works correctly on the Akamai CDN.
Why didn't the image change even after the purge?
Previously, the shared sprite image was referenced with a fixed URL like this.
html
1<imgsrc="/images/gnb.webp" />
After replacing the image, I called the Akamai Purge API to invalidate the CDN cache.
But in some environments, the old image kept showing.
At first I thought the purge had not run properly or that the CDN cache was still there. Checking Chrome DevTools, though, I was able to find the cause.
Checking the browser cache
First, I checked the response headers of the image request.
The response had no Cache-Control header.
In HTTP caching, when there is no explicit cache policy such as Cache-Control or Expires, the browser can perform heuristic caching based on information like Last-Modified or ETag.
In other words, even without an explicit expiration policy, the browser can cache a resource by its own criteria.
Figure 1. Response Headers of the image request
The Cache-Control header is missing
Last-Modified
ETag
Date
The decisive hint, though, came from the Network tab.
text
1200 OK (from disk cache)
At first I took it as a plain 200 response, but from disk cache meant the browser had not sent a network request at all.
So even though the CDN cache had been cleared through the Akamai Purge API, the browser was still using the image it had already saved on the local disk.
Figure 2. Chrome DevTools Network tab showing 200 OK (from disk cache)
This made the cause clear.
The browser uses the entire URL as the cache key.
For example, gnb.webp and gnb.webp?v=1 are treated as different resources.
Conversely, if the URL is the same, the browser keeps using the existing cache even if the file content has changed.
In the end, a purge alone could not invalidate the browser cache, and I needed to change the URL itself so the browser would see it as a new resource.
The fix: timestamp-based cache busting
When I researched cache busting methods, the most common approach was including a content hash in the file name.
For example, if the hash is part of the file name itself, as in logo.a1b2c3.svg, a new URL is created only when the file content changes. Files that have not changed can keep using the existing cache, so this approach gives the best cache efficiency.
However, the image that caused this problem was not a resource managed by the bundler but a shared image managed separately, so it was hard to apply a content hash automatically.
So this time I chose to add the build-time timestamp as a query string.
First, I made a fileUrl helper that builds the image URL.
now a new timestamp is added to the URL on every build, so the browser sees it as a new resource and downloads the latest image again.
I did not apply this approach to all images.
For images that almost never change, such as car manufacturer logos or the refresh icon, keeping the cache is more efficient. On the other hand, for images that may change during a deploy, such as GNB (global navigation bar) images or banners, it was important that users get the latest image right away.
So I limited the scope: timestamp-based cache busting applies only to shared images that may change on deploy, and images unlikely to change keep using the existing cache.
Unlike a content hash, this approach does not invalidate the cache only for the files that changed. But it is simple to implement and could be applied only to the images that actually had the problem, so I was able to keep unnecessary re-downloads to a minimum while making the latest images show up right away.
Does it also work correctly on the Akamai CDN?
The browser cache problem was solved, but there was one more thing to check.
The Akamai CDN sat in front of the images, so I needed to confirm whether Akamai also treats a new URL with a timestamp as a separate object.
First, I turned on Disable cache in Chrome DevTools and did a hard reload. This bypasses the browser cache so that a real network request is made.
In the response headers, Server-Timing showed the following value.
text
1Hit from child
This means the URL already existed in the Akamai cache and the CDN returned the cached image.
Server-Timing: Hit from child shown when requesting the image with Disable cache turned on
But this alone was not enough.
The URL was already in use on the live service, so it may have been stored in the cache by an earlier request.
What I really wanted to confirm was whether a new timestamp that had never been requested would also be treated as a separate object.
To check this, I used curl to request a URL with a new query string directly.
bash
1curl -sI "https://.../gnb.webp?v=1872007758"
The response was as follows.
text
1Server-Timing: Miss from child, Miss from parent
This means the object did not exist in the Akamai cache, so the request was passed all the way to the origin server.
If Akamai did not include the query string in the cache key, it would have used the existing cache, so it should have returned a Hit.
Instead, I saw that the new ?v= value was treated as a separate object and resulted in a Miss. This confirmed that the query string is part of Akamai's cache key and cache busting works correctly.
Figure 3. Miss from child, Miss from parent shown when running curl with a new ?v= value
Looking back
At first I thought the Akamai CDN cache was the cause. But while checking the browser's Network tab I found from disk cache, and learned that the real cause was the browser cache.
Through this, I learned what rules the browser cache works by, that heuristic caching can apply when there is no Cache-Control, and that cache busting can be done by changing the URL.
I think it was a good experience, because besides fixing the problem I got to understand how caching works.