On the documentation site, /docs redirects to the pages for the latest version. Links on other sites, such as the product site, point to /docs. For the v4 release, the nginx config was edited to change the target from /docs/v3/ to /docs/v4/.
location = /docs { return 301 /docs/v4/; }
301 is the status code meaning the requested URL has moved permanently to another URL. The /docs URL itself will not change, so it was set up as a 301.
After the change, reports keep coming in: "When I open it from the link on the product site, I get v3." Running curl against /docs locally returns a 301 to v4.
$ curl -sI https://<host>/docs
HTTP/1.1 301 Moved Permanently
Location: https://<host>/docs/v4/
I reproduced this with nginx and browsers and measured why some users land on v3.
Impact
Some of the users who opened /docs from the product site link while it pointed to v3 keep reading the v3 pages after the v4 release. No error appears, so only the users who notice send a report. The ones who don't notice go on configuring and working by the v3 instructions.
This does not go away, however many days pass. It happens again every time a release changes the target.
Reproduction
I reproduced the scenario above with nginx 1.31.6 and three browsers driven by Playwright 1.63.0: Chromium, WebKit (the engine behind Safari), and Firefox.1
A page containing only a link to /docs is served on a host name separate from the documentation site, and /docs is opened from that page. /docs responds with the same return 301 as above.
While the target is v3, open /docs from the link page. Then change the nginx config to v4 and reload. After that, open /docs from the link page three times: right after the switch, 70 seconds later, and after restarting the browser. Each time, record which version's page the browser landed on.
Chromium and WebKit landed on v3 all three times. Firefox landed on v4 from right after the switch.2
When /docs was opened while the target was v3, all three browsers left a /docs line in the nginx access log.
<client IP> - - [15/Oct/2026:23:13:51 +0000] "GET /docs HTTP/1.1" 301 169 ...
After the switch, there was no /docs line for any of the three times Chromium and WebKit landed on v3. Firefox, which landed on v4, left a /docs line all three times. Chromium and WebKit went to v3 without asking the server.
Why v3 keeps showing
The /docs 301 does not say how long it may be stored and reused. How to treat a 301 without that lifetime is up to the browser. Chromium treats it as never expiring and WebKit as valid for one year, and both reuse the stored 301 without asking the server.34 So users who stored the 301 to v3 still land on v3 after the switch. Firefox does not reuse the stored 301 as is. It asks the server every time the page is opened.
These are the headers of the /docs 301:
$ curl -sI https://<host>/docs
HTTP/1.1 301 Moved Permanently
Server: nginx/1.31.6
Date: Thu, 15 Oct 2026 23:14:03 GMT
Content-Type: text/html
Content-Length: 169
Location: https://<host>/docs/v4/
Connection: keep-alive
Cache-Control: max-age=<seconds> is how a response tells the browser it may store the response and use it without asking for that many seconds. This response has no such header. Nor does it have Last-Modified, the header that gives the time the file was last modified.
Firefox computes the lifetime of a 301 without a stated lifetime from Last-Modified.5 This response has no Last-Modified, so the lifetime is 0, which means the response is already expired when it is stored. That is why Firefox asks the server every time.
Fix
Switch to a 302, or give the 301 a lifetime with max-age. Either way, users who have already stored the 301 to v3 stay on v3. Those users are covered in the notes at the end.
Use a 302
302 is the status code meaning the requested URL has moved temporarily to another URL. In nginx:
location = /docs { return 302 /docs/v4/; }
For a 302 with neither a stated lifetime nor Last-Modified, Chromium, WebKit, and Firefox all set the lifetime to 0 and ask the server for /docs every time it is opened.345
Add max-age to the 301
You can also keep the 301 and state a lifetime with max-age in Cache-Control. The verification used 60 seconds.
location = /docs {
add_header Cache-Control "max-age=60";
return 301 /docs/v4/;
}
Headers added with add_header are also attached to the 301 sent by return.
The tradeoff is that users who opened /docs just before a release land on the previous version for up to max-age seconds after the release.
Which to choose
I choose the 302. At the start, /docs got a 301 because its URL does not change. A 301, however, says that the move from /docs to its target is permanent. The target changes with every release, so the move is temporary.
I don't choose max-age. It saves one round trip to /docs during the lifetime, but in exchange, users who opened /docs just before a release land on the previous version.
Effect of the fixes
With a 302
With the config already using return 302 while the target was v3, I opened /docs with the same steps as the reproduction.2
| Time | Chromium | WebKit | Firefox |
|---|---|---|---|
| Right after the switch | v4 | v4 | v4 |
| 70 seconds later | v4 | v4 | v4 |
All three browsers landed on v4 from right after the switch.
With max-age on the 301
With the config already returning the 301 with max-age=60 while the target was v3, I opened /docs with the same steps as the reproduction.2
| Time | Chromium | WebKit | Firefox |
|---|---|---|---|
| Right after the switch | v3 | v3 | v3 |
| 70 seconds later | v4 | v4 | v4 |
Right after the switch, Firefox also used the stored 301 and landed on v3. At 70 seconds, after the 60-second lifetime had passed, all three landed on v4.
Notes
Affected browsers
Chromium-based browsers and Safari keep landing on v3. Firefox 139 and later are not affected. Firefox 138 and earlier, and the ESR releases based on them, are. Firefox 138 and earlier treated a 301 without a stated lifetime as never expiring.6
Chromium stores the 301 separately depending on whether the page was opened from a link on another site or typed into the address bar.7 In the verification, after the 301 to v3 was stored by opening /docs from the link page and the target was switched to v4, opening /docs directly, without going through a link on another site, took Chromium to v4 and WebKit to v3.2
To check this in your own Chrome, open /docs from a link on another site, the way users do.
Users who still land on v3 after changing to 302
I also ran the case where the 301 to v3 is stored first and the config is changed to return 302 at the switch, opening /docs with the same steps as the reproduction. Chromium and WebKit landed on v3 all three times: right after the switch, 70 seconds later, and after restarting the browser.2 They keep using the stored 301 to v3, so the change to 302 never reaches these users.
Clear-Site-Data is a header that tells the browser receiving it to delete that host's cache and other stored data. In the verification, the v4 page, on the same origin as /docs, returned this header.
location /docs/v4/ { add_header Clear-Site-Data '"cache"'; }
For users who opened this page, the stored 301 was deleted, and Chromium and WebKit also landed on v4 from /docs.2 The header only reaches users who open a v4 page. Users being sent to v3 never get to a v4 page from /docs.
Note that the header tells the browser to delete the entire cache for that host, not just the v4 page. The header also works only over HTTPS (or on localhost).8
Setups that give the redirect a lifetime
If expires is set at the server level, the 301 sent by return in a location also gets a Cache-Control header.9 In the verification, with expires 30d at the server level and only return 301 in the /docs location, the 301 for /docs got Cache-Control: max-age=2592000 (30 days).2 Users land on the previous version for up to 30 days after the release.
Setups not tested
- Sites with a registered Service Worker that answers requests to
/docswith a locally held response - Setups with a CDN in front of nginx that stores the
/docs301 and serves it
In both, the request does not reach nginx, so the /docs lines in the access log cannot tell you whether the browser used a stored 301.
-
Run on GitHub Actions (ubuntu-24.04). Browser versions: Chromium 153.0.8010.12, WebKit 26.6, Firefox 155.0. The logs and curl output in the text have the per-condition paths and host names rewritten to
/docsand<host>. The config, scripts, and raw logs are in show-your-work. ↩ -
Chromium's http_response_headers.cc (
GetFreshnessLifetimes). A 301 without a stated lifetime never expires. A 302 gets 0. ↩ -
WebKit's CacheValidation.cpp (
computeFreshnessLifetimeForHTTPFamily). A 301 without a stated lifetime gets one year. Anything else gets a lifetime computed fromLast-Modified, or 0 if there is none. ↩ -
Firefox's nsHttpResponseHead.cpp (
ComputeFreshnessLifetime). A 301 without a stated lifetime gets a lifetime computed fromLast-Modified, or 0 if there is none. A 302 without a stated lifetime gets 0. ↩ -
Mozilla Bug 1957619. ↩
-
Chromium's http_cache.cc. Top-level navigations coming from another site are stored under a separate cache key. ↩
-
W3C's Clear Site Data.
"cache"deletes the cache for the same host as the response URL (4.2.3). If the response URL is not treated as secure (HTTPS and localhost URLs are), nothing is deleted (4.2). ↩ -
nginx's ngx_http_headers_module.
expiresaddsExpiresandCache-Controlto responses with status code 301 as well. ↩












