I pushed a change to the site. Opened it on the actual deployment URL, looked right, new build, exactly what I expected. Then I navigated to the real domain, the one people actually visit. Old version. Stale UI, like the deploy had never happened.
Same site. Same deploy. Two completely different experiences depending on which door I walked through.
The obvious suspect
Safari has a reputation for this, and it's earned. It caches aggressively, more aggressively than other browsers, and it doesn't always respect the signals a server sends telling it "this content is fresh, go get the new version." The deployment-specific URL is unique every time, so there's nothing to cache against. The custom domain is the same address every time, which makes it Safari's favorite kind of thing to hoard.
So the fix seemed obvious: tell Safari explicitly, in no uncertain terms, not to cache the HTML. Add cache headers to the site's Vercel config (no-cache, no-store, must-revalidate) on the root and on HTML routes specifically. The static assets, the JS and CSS bundles, those are already safe to cache aggressively because they get unique fingerprinted filenames on every build. It's only the entry point HTML that needs to stay fresh.
Deployed it. Confident this was solved.
Then the site returned a 403
Not stale content. Forbidden. The site stopped loading entirely for a meaningful chunk of requests.
This is the part where you have to resist the instinct to panic-revert without understanding what actually broke, because reverting blind means you'll hit the same wall again the next time you try to fix the original problem.
What the headers change actually triggered
The site has middleware, code that runs before a request reaches its destination, often used for things like redirects, auth checks, or rewriting routes. Middleware in this kind of deployment runs in a restricted environment, not a full Node.js server. It doesn't have access to everything a normal backend script would.
The existing middleware referenced something only available in a full Node environment, a global that simply doesn't exist in the restricted runtime it was actually executing in. That reference had apparently been sitting there, harmless, because the routes that would trigger the middleware weren't being hit under the old caching behavior.
Adding the cache headers changed which requests flowed through which paths. Routes that had previously skipped the middleware were now hitting it. And the middleware, the moment it actually ran, hit that invalid reference and threw an error, which surfaced as a 403 to anyone visiting the site.
The new cache headers weren't broken. They'd just exposed an old, unrelated bug that had been quietly waiting for the right conditions.
Separating the two problems
The instinct when something breaks right after a change is to assume the change caused the breakage and undo it entirely. Sometimes that's correct. Here it was half-correct: the change didn't introduce a new bug, it changed traffic patterns enough to finally trigger an existing one.
The right move was to revert the config back to its simpler, original state first, get the site back to a working baseline immediately, since no one should be staring at a 403 longer than necessary, and then deal with the middleware issue as its own separate problem, on its own timeline, instead of trying to fix both at once under pressure.
The actual lesson
Caching bugs and runtime bugs feel like they should be unrelated. They're not, not really, they're both about what code runs, when, and under what conditions. Change one variable in a system, even something as seemingly cosmetic as a cache header, and you can shift which code paths actually execute. Dormant bugs don't announce themselves until traffic finds them.
If a fix introduces a new failure, don't assume the fix is wrong. Ask what the fix changed about the shape of the requests hitting your app, because sometimes you didn't break anything. You just finally turned the lights on in a room that had a problem in it the whole time.