<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ashim Sapkota</title>
    <description>The latest articles on DEV Community by Ashim Sapkota (@ashim_sapkota_89bffa22087).</description>
    <link>https://dev.to/ashim_sapkota_89bffa22087</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4122815%2Fde5f0504-70ee-4c72-937f-65f972e73bc7.png</url>
      <title>DEV Community: Ashim Sapkota</title>
      <link>https://dev.to/ashim_sapkota_89bffa22087</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashim_sapkota_89bffa22087"/>
    <language>en</language>
    <item>
      <title>Why Your React Hotfix Isn't Reaching Users - And How to Fix It with Nginx</title>
      <dc:creator>Ashim Sapkota</dc:creator>
      <pubDate>Sun, 13 Sep 2026 05:37:29 +0000</pubDate>
      <link>https://dev.to/ashim_sapkota_89bffa22087/why-your-react-hotfix-isnt-reaching-users-and-how-to-fix-it-with-nginx-2h0m</link>
      <guid>https://dev.to/ashim_sapkota_89bffa22087/why-your-react-hotfix-isnt-reaching-users-and-how-to-fix-it-with-nginx-2h0m</guid>
      <description>&lt;p&gt;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:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"It's still broken for me."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You've hit the &lt;strong&gt;browser cache problem&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Let's go deep.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layers of caching in a typical React + Nginx setup
&lt;/h2&gt;

&lt;p&gt;When a user visits your React app, there are two places a response can be cached before it reaches them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The browser cache&lt;/strong&gt;, stored on the user's own machine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nginx itself&lt;/strong&gt;, but only if you've explicitly enabled &lt;code&gt;proxy_cache&lt;/code&gt; (more on this below)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For most React apps, Nginx is just a static file server. It reads your &lt;code&gt;dist/&lt;/code&gt; folder from disk and sends files to the browser. There is &lt;strong&gt;no in-memory Nginx cache&lt;/strong&gt; in this setup. Nginx reads the file on every request. The caching happens entirely in the browser.&lt;/p&gt;

&lt;p&gt;This distinction matters because a lot of developers assume Nginx is caching on their behalf. It isn't, unless you've explicitly configured &lt;code&gt;proxy_cache&lt;/code&gt;, which is a separate feature used when Nginx sits in front of a backend server like Node or Django.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the browser decides whether to cache a file
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 1. You set explicit &lt;code&gt;Cache-Control&lt;/code&gt; headers
&lt;/h3&gt;

&lt;p&gt;The browser follows exactly what you tell it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max-age=86400&lt;/code&gt; means cache for one day&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-cache&lt;/code&gt; means always revalidate before using&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;immutable&lt;/code&gt; means never check again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You are in full control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenario 2. You set no headers at all
&lt;/h3&gt;

&lt;p&gt;This is where most developers get surprised. The browser doesn't just skip caching. It applies &lt;strong&gt;heuristic caching&lt;/strong&gt;, defined in &lt;a href="https://datatracker.ietf.org/doc/html/rfc7234" rel="noopener noreferrer"&gt;RFC 7234&lt;/a&gt;. Here's the formula it uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Heuristic TTL = (Date response received - Last-Modified) x 10%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nginx automatically sends a &lt;code&gt;Last-Modified&lt;/code&gt; header for every static file it serves. It's the file's modification timestamp on disk. The browser uses that to calculate a TTL.&lt;/p&gt;

&lt;p&gt;Some real examples:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File last modified&lt;/th&gt;
&lt;th&gt;Heuristic TTL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 day ago&lt;/td&gt;
&lt;td&gt;2.4 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 days ago&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 days ago&lt;/td&gt;
&lt;td&gt;6 days &lt;em&gt;(most browsers cap around 7 days)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;This is the trap.&lt;/strong&gt; Right after a fresh deploy, &lt;code&gt;Last-Modified&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full browser cache lifecycle
&lt;/h2&gt;

&lt;p&gt;Understanding the exact sequence is important. Here's what happens step by step when a user visits your app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1. First visit
&lt;/h3&gt;

&lt;p&gt;Browser downloads the file. Nginx sends it with a &lt;code&gt;Last-Modified&lt;/code&gt; header and an &lt;code&gt;ETag&lt;/code&gt; (a fingerprint of the file content, something like &lt;code&gt;"65a3f-abc123"&lt;/code&gt;). The browser stores the file, the ETag, and calculates a heuristic TTL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2. User visits again (or presses F5)
&lt;/h3&gt;

&lt;p&gt;Here's the critical part that most people miss. &lt;strong&gt;The browser does not immediately contact the server.&lt;/strong&gt; It first asks: &lt;em&gt;is the TTL still valid?&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTL still valid&lt;/strong&gt;: serve from cache. Zero network request. The server is never contacted. The ETag is never checked. Your deploy never gets picked up. &lt;strong&gt;This is the bug.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTL expired&lt;/strong&gt;: send a conditional request to Nginx with &lt;code&gt;If-None-Match: "abc123"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3. Conditional request (only after TTL expires)
&lt;/h3&gt;

&lt;p&gt;Nginx receives the request and compares the ETag the browser sent against the current file on disk.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File unchanged&lt;/strong&gt;: Nginx replies &lt;code&gt;304 Not Modified&lt;/code&gt;. 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).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File changed&lt;/strong&gt;: Nginx replies &lt;code&gt;200 OK&lt;/code&gt; with the fresh file and a new ETag. The browser downloads it and recalculates TTL (which is now small, since the file was just modified).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The key insight:&lt;/strong&gt; the &lt;code&gt;ETag&lt;/code&gt; / &lt;code&gt;If-None-Match&lt;/code&gt; system only kicks in &lt;em&gt;after&lt;/em&gt; 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What about hard refresh?
&lt;/h2&gt;

&lt;p&gt;When a user presses &lt;code&gt;Ctrl+Shift+R&lt;/code&gt; (or &lt;code&gt;Cmd+Shift+R&lt;/code&gt; on Mac), the browser sends &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; in the request and skips its local cache entirely. It gets a fresh response, and yes, the cache is updated with the new file.&lt;/p&gt;

&lt;p&gt;But this is a &lt;strong&gt;developer tool, not a user solution.&lt;/strong&gt; Consider the problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your real users don't know what a hard refresh is&lt;/li&gt;
&lt;li&gt;Even if they did, you can't instruct every user to do it after every deploy&lt;/li&gt;
&lt;li&gt;After the hard refresh, the heuristic TTL starts again from near-zero and will silently grow again over time&lt;/li&gt;
&lt;li&gt;Your next deploy will run into the same problem&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Hard refresh fixes &lt;em&gt;your&lt;/em&gt; browser. It fixes nobody else's.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How Vite partially solves this (and the gap it leaves)
&lt;/h2&gt;

&lt;p&gt;If you're using Vite (or Create React App), you already have filename hashing working for you. Every build produces output like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dist/
  index.html
  assets/
    main.a1b2c3.js
    style.f4e5d6.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash in the filename (&lt;code&gt;a1b2c3&lt;/code&gt;) changes whenever the file content changes. A new deploy produces &lt;code&gt;main.f9e8d7.js&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But here's the gap:&lt;/strong&gt; Vite does not hash &lt;code&gt;index.html&lt;/code&gt;. It's always just &lt;code&gt;index.html&lt;/code&gt;. And &lt;code&gt;index.html&lt;/code&gt; is the entry point that contains the &lt;code&gt;&amp;lt;script src="assets/main.a1b2c3.js"&amp;gt;&lt;/code&gt; tag. If the browser serves a cached old &lt;code&gt;index.html&lt;/code&gt;, it will load the &lt;em&gt;old&lt;/em&gt; JS bundle, even if the new bundle is sitting right there on the server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So the whole chain breaks at &lt;code&gt;index.html&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: two Nginx location blocks
&lt;/h2&gt;

&lt;p&gt;The solution is to set the right &lt;code&gt;Cache-Control&lt;/code&gt; header for each type of file. Here's the complete logic:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File type&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index.html&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;no-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always revalidate. Forces an &lt;code&gt;If-None-Match&lt;/code&gt; check on every visit. Tiny 304 if unchanged; instant fresh HTML on deploy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashed &lt;code&gt;.js&lt;/code&gt; / &lt;code&gt;.css&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max-age=31536000, immutable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The filename itself changes on every deploy, so an old hash is never referenced again. Caching for a year is completely safe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images and fonts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;max-age=604800&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rarely change; the cost of a stale image is low. Cache for a week.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the full Nginx config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/myapp/dist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;index&lt;/span&gt; &lt;span class="s"&gt;index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;# index.html: always revalidate&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-cache,&lt;/span&gt; &lt;span class="s"&gt;no-store,&lt;/span&gt; &lt;span class="s"&gt;must-revalidate"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Pragma&lt;/span&gt;        &lt;span class="s"&gt;"no-cache"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Expires&lt;/span&gt;       &lt;span class="s"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Hashed JS/CSS: cache forever&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(js|css)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=31536000,&lt;/span&gt; &lt;span class="s"&gt;immutable"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Images, fonts: cache for one week&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)&lt;/span&gt;$ &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"public,&lt;/span&gt; &lt;span class="s"&gt;max-age=604800"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# React Router SPA fallback&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="n"&gt;/index.html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After updating the config, test and reload Nginx with zero downtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-s&lt;/span&gt; reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What happens after every deploy now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On a deploy:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User visits your app. Browser sends &lt;code&gt;GET /index.html&lt;/code&gt; with &lt;code&gt;If-None-Match: "abc"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Nginx sees the file changed, replies &lt;code&gt;200 OK&lt;/code&gt; with the new &lt;code&gt;index.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The new &lt;code&gt;index.html&lt;/code&gt; references &lt;code&gt;main.f9e8d7.js&lt;/code&gt;, a URL the browser has never seen&lt;/li&gt;
&lt;li&gt;Browser fetches &lt;code&gt;main.f9e8d7.js&lt;/code&gt; fresh from the server&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User sees your hotfix. Instantly. No hard refresh. No user action.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;On a visit where nothing has changed:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User visits. Browser sends &lt;code&gt;GET /index.html&lt;/code&gt; with &lt;code&gt;If-None-Match: "abc"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Nginx sees the file is unchanged, replies &lt;code&gt;304 Not Modified&lt;/code&gt; (headers only, no body)&lt;/li&gt;
&lt;li&gt;Browser uses cached &lt;code&gt;index.html&lt;/code&gt;, which still references &lt;code&gt;main.a1b2c3.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;main.a1b2c3.js&lt;/code&gt; is still in cache, cached forever, served instantly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast load. Zero unnecessary downloads.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the 304 response is almost free
&lt;/h2&gt;

&lt;p&gt;A common concern with &lt;code&gt;no-cache&lt;/code&gt; is performance: &lt;em&gt;"doesn't this mean a network request on every visit?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Yes, but a &lt;code&gt;304&lt;/code&gt; response is just HTTP headers with &lt;strong&gt;no body&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The browser cache is not binary. It has a layered decision process, and understanding the sequence unlocks why the fix works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser checks &lt;strong&gt;TTL first&lt;/strong&gt;. If valid, it serves from cache with zero network contact.&lt;/li&gt;
&lt;li&gt;Only after TTL expires does it send a &lt;strong&gt;conditional request&lt;/strong&gt; with &lt;code&gt;If-None-Match&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nginx replies with either &lt;strong&gt;304&lt;/strong&gt; (use cache) or &lt;strong&gt;200&lt;/strong&gt; (here's the new file).&lt;/li&gt;
&lt;li&gt;TTL is &lt;strong&gt;recalculated on every response&lt;/strong&gt;, growing on 304, resetting small on 200.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;With two Nginx location blocks, you replace that unpredictability with a simple contract:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;index.html&lt;/code&gt; is always fresh. Hashed assets are always fast.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two lines. Every deploy reaches every user. Every time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
