You push a critical hotfix. Deploy succeeds. You refresh the page and see the fix. You tell your team it’s live. Then a user messages you:
“It’s still broken for me.”
You’ve hit the browser cache problem. It’s one of the most misunderstood parts of frontend deployment, and the fix is two lines of Nginx config. But to truly understand why those two lines matter, you need to understand what the browser is actually doing behind the scenes.
Let’s go deep.
The layers of caching in a typical React + Nginx setup
When a user visits your React app, there are two places a response can be cached before it reaches them:
- The browser cache, stored on the user’s own machine
-
Nginx itself, but only if you’ve explicitly enabled
proxy_cache(more on this below)
For most React apps, Nginx is just a static file server. It reads your dist/ folder from disk and sends files to the browser. There is no in-memory Nginx cache in this setup. Nginx reads the file on every request. The caching happens entirely in the browser.
This distinction matters because a lot of developers assume Nginx is caching on their behalf. It isn’t, unless you’ve explicitly configured proxy_cache, which is a separate feature used when Nginx sits in front of a backend server like Node or Django.
How the browser decides whether to cache a file
Every time the browser receives a file from Nginx, it looks at the response headers to decide how long to store it. There are two scenarios.
Scenario 1. You set explicit Cache-Control headers
The browser follows exactly what you tell it.
-
max-age=86400means cache for one day -
no-cachemeans always revalidate before using -
immutablemeans never check again
You are in full control.
Scenario 2. You set no headers at all
This is where most developers get surprised. The browser doesn’t just skip caching. It applies heuristic caching, defined in RFC 7234. Here’s the formula it uses:
Heuristic TTL = (Date response received - Last-Modified) x 10%
Enter fullscreen mode Exit fullscreen mode
Nginx automatically sends a Last-Modified header for every static file it serves. It’s the file’s modification timestamp on disk. The browser uses that to calculate a TTL.
Some real examples:
File last modified Heuristic TTL 1 day ago 2.4 hours 10 days ago 1 day 60 days ago 6 days (most browsers cap around 7 days)This is the trap. Right after a fresh deploy, Last-Modified is just a few seconds ago, so the TTL is near zero and everything seems fine. But as days pass without a deploy, the TTL silently grows. The very moment you need an urgent hotfix to reach users fast is exactly when the cache is fighting you hardest.
The full browser cache lifecycle
Understanding the exact sequence is important. Here’s what happens step by step when a user visits your app.
Step 1. First visit
Browser downloads the file. Nginx sends it with a Last-Modified header and an ETag (a fingerprint of the file content, something like "65a3f-abc123"). The browser stores the file, the ETag, and calculates a heuristic TTL.
Step 2. User visits again (or presses F5)
Here’s the critical part that most people miss. The browser does not immediately contact the server. It first asks: is the TTL still valid?
- TTL still valid: serve from cache. Zero network request. The server is never contacted. The ETag is never checked. Your deploy never gets picked up. This is the bug.
-
TTL expired: send a conditional request to Nginx with
If-None-Match: "abc123"
Step 3. Conditional request (only after TTL expires)
Nginx receives the request and compares the ETag the browser sent against the current file on disk.
-
File unchanged: Nginx replies
304 Not Modified. No file body is sent, just a tiny header response. The browser uses its cached copy and recalculates the TTL (which is now larger, since the file is older). -
File changed: Nginx replies
200 OKwith the fresh file and a new ETag. The browser downloads it and recalculates TTL (which is now small, since the file was just modified).
The key insight: the
ETag/If-None-Matchsystem only kicks in after the TTL has already expired. If the TTL is still valid, the browser never sends the conditional request. Your file could have completely changed on the server, and the user would never know.
What about hard refresh?
When a user presses Ctrl+Shift+R (or Cmd+Shift+R on Mac), the browser sends Cache-Control: no-cache in the request and skips its local cache entirely. It gets a fresh response, and yes, the cache is updated with the new file.
But this is a developer tool, not a user solution. Consider the problems:
- Your real users don’t know what a hard refresh is
- Even if they did, you can’t instruct every user to do it after every deploy
- After the hard refresh, the heuristic TTL starts again from near-zero and will silently grow again over time
- Your next deploy will run into the same problem
Hard refresh fixes your browser. It fixes nobody else’s.
How Vite partially solves this (and the gap it leaves)
If you’re using Vite (or Create React App), you already have filename hashing working for you. Every build produces output like this:
dist/
index.html
assets/
main.a1b2c3.js
style.f4e5d6.css
Enter fullscreen mode Exit fullscreen mode
The hash in the filename (a1b2c3) changes whenever the file content changes. A new deploy produces main.f9e8d7.js, a completely different URL. Since the browser has never seen that URL before, it fetches it fresh regardless of any cache. This is the right approach for JS and CSS files.
But here’s the gap: Vite does not hash index.html. It’s always just index.html. And index.html is the entry point that contains the <script src="assets/main.a1b2c3.js"> tag. If the browser serves a cached old index.html, it will load the old JS bundle, even if the new bundle is sitting right there on the server.
So the whole chain breaks at index.html.
The fix: two Nginx location blocks
The solution is to set the right Cache-Control header for each type of file. Here’s the complete logic:
index.html
no-cache
Always revalidate. Forces an If-None-Match check on every visit. Tiny 304 if unchanged; instant fresh HTML on deploy.
Hashed .js / .css
max-age=31536000, immutable
The filename itself changes on every deploy, so an old hash is never referenced again. Caching for a year is completely safe.
Images and fonts
max-age=604800
Rarely change; the cost of a stale image is low. Cache for a week.
Here’s the full Nginx config:
server {
listen 80;
root /var/www/myapp/dist;
index index.html;
# index.html: always revalidate
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri =404;
}
# Hashed JS/CSS: cache forever
location ~* .(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Images, fonts: cache for one week
location ~* .(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
# React Router SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
Enter fullscreen mode Exit fullscreen mode
After updating the config, test and reload Nginx with zero downtime:
sudo nginx -t
sudo nginx -s reload
Enter fullscreen mode Exit fullscreen mode
What happens after every deploy now
On a deploy:
- User visits your app. Browser sends
GET /index.htmlwithIf-None-Match: "abc" - Nginx sees the file changed, replies
200 OKwith the newindex.html - The new
index.htmlreferencesmain.f9e8d7.js, a URL the browser has never seen - Browser fetches
main.f9e8d7.jsfresh from the server - User sees your hotfix. Instantly. No hard refresh. No user action.
On a visit where nothing has changed:
- User visits. Browser sends
GET /index.htmlwithIf-None-Match: "abc" - Nginx sees the file is unchanged, replies
304 Not Modified(headers only, no body) - Browser uses cached
index.html, which still referencesmain.a1b2c3.js -
main.a1b2c3.jsis still in cache, cached forever, served instantly - Fast load. Zero unnecessary downloads.
Why the 304 response is almost free
A common concern with no-cache is performance: “doesn’t this mean a network request on every visit?”
Yes, but a 304 response is just HTTP headers with no body. It’s typically around 200 bytes. Compare that to re-downloading a full JS bundle. The revalidation cost is negligible, and you get guaranteed freshness in return.
Summary
The browser cache is not binary. It has a layered decision process, and understanding the sequence unlocks why the fix works:
- Browser checks TTL first. If valid, it serves from cache with zero network contact.
- Only after TTL expires does it send a conditional request with
If-None-Match. - Nginx replies with either 304 (use cache) or 200 (here’s the new file).
- TTL is recalculated on every response, growing on 304, resetting small on 200.
With no headers set, you’re at the mercy of heuristic caching, a formula that grows your cache duration silently over time, peaking exactly when you need a fast hotfix.
With two Nginx location blocks, you replace that unpredictability with a simple contract:
index.htmlis always fresh. Hashed assets are always fast.
Two lines. Every deploy reaches every user. Every time.
If this helped you, feel free to share it. And if you’re dealing with CDN caching on top of this (CloudFront, Cloudflare, etc.), the same principles apply, with the added step of invalidating the CDN cache on deploy, which is a story for another post.