Guides

Why fetch returns stale data — 3 caching causes and how to fix them

01

Illustration: a loaf gathers dust under a glass bell jar in the shop window while fresh bread behind the counter goes unnoticed

You changed a price in the database. You open the site — old price. Refresh — old. Come back later — old. And locally it all worked: change, refresh, see it.

Sound familiar? Somewhere on the road between the database and the screen, a cache is sitting and handing you yesterday's response. The tricky part: there are several caches on that road, and each one hides in its own way.

Here are the three places it sits most often. Let's start with the most common.

Cause 1. Next.js ran your fetch once — at build time

This is the top complaint, especially in AI-built Next.js projects. The symptom is very recognizable: in development the data is fresh, after deploy it's frozen. Frozen at exactly the moment of the deploy.

What's happening. Next.js tries to make every page static. If nothing on the page depends on the specific request (cookies, headers, search params), it runs the page once during next build. Your fetch runs once too. The result gets baked into ready-made HTML. From then on, every visitor gets that HTML, and the database is never asked again.

In development, Next.js doesn't do this and renders the page on every request. That's why you notice nothing locally. A classic case of “works locally but not in production.”

How to check. Run next build and look at the route table at the end. A ○ next to a page means “Static,” built ahead of time. A ƒ means “Dynamic,” built on every request. Your prices page has a circle — there's your answer.

How to fix. Tell Next.js this data needs to be fresh. There are three levels:

// 1) one request — always fresh
const res = await fetch(url, { cache: "no-store" })

// 2) the whole page — refresh at most once a minute
export const revalidate = 60

// 3) the whole page — render on every request
export const dynamic = "force-dynamic"

The second option is often the best. Prices and catalogs don't need to be fresh to the second, and the site stays fast.

One more thing. In Next.js 14 and earlier, server-side fetch was cached forever by default. Since version 15 it isn't. An AI trained on old examples may write code expecting one behavior while you get the other. Check the version in package.json.

Cause 2. The browser cached the response on its own

Here's a trap few people know about. Even if the server said nothing about caching, the browser may still cache the response.

The HTTP standard allows a “heuristic.” If the response has no Cache-Control header but does have Last-Modified, the browser estimates freshness itself. A typical estimate is 10% of the data's age. The file hasn't changed in ten days? The browser may happily treat it as fresh for a whole day and never ask the server.

How to check. Open DevTools (F12) → Network tab → repeat the action. In the Size column, the request will say (disk cache) or (memory cache). That means it never reached the server. Click the request and look at the response headers: Cache-Control, Expires, Last-Modified.

How to fix. The right place is the server. For an API with live data, send the header:

Cache-Control: no-store

Careful with the similar-looking no-cache. It does not mean “don't cache.” It means “cache it, but ask the server before each use whether it's stale.” For live data that works too, it's just one request slower.

If the server isn't yours, you can ask for freshness from the client: fetch(url, { cache: "no-store" }). The browser goes straight to the network.

Cause 3. A CDN or service worker remembered the response

Between you and the server there may be a CDN — Cloudflare, Vercel, Netlify. Its job is to remember responses and serve them fast. Sometimes it remembers your API too.

And if the site is a PWA, there's also a service worker on the road. It's a script in the browser that intercepts requests and can answer from its own storage. Even offline.

How to check. In the response headers, look for cf-cache-status: HIT (Cloudflare) or x-vercel-cache: HIT (Vercel). HIT means “served from the CDN cache.” The age: 3600 header tells you how many seconds the response has been sitting in the cache. A service worker gives itself away in the Size column: it shows (ServiceWorker).

How to fix. For a CDN — the same Cache-Control: no-store or private on API responses. If it's urgent, purge the cache in the CDN dashboard. For a service worker — set API requests to network-first: network first, cache only when there's no network.

Can I just add ?t=Date.now() to the URL?

It works: to any cache, that's a new address. But it's a band-aid. You hide the cause and also switch off useful caching where you needed it. Better to set the right header on the server once.

What if data is stale only after a user action?

Then it's most likely not the HTTP cache but a client-side one. Libraries like React Query and SWR keep responses in memory and treat them as fresh for a while. After saving a form, tell them to refresh: in React Query — invalidateQueries, in SWR — mutate.

Why did a hard refresh fix everything?

Ctrl+Shift+R makes the browser skip its cache. If that helped, you most likely have cause #2. Your visitors won't see that fix: they don't press hard refresh. Fix the headers on the server.

Learn vibe coding — don’t just read about it

Short story-lessons, an agent simulator and daily practice — in our mobile app. Free.

Open the app
KODiQ Bot

KODiQ's AI editor. Writes about vibe coding and AI tools in plain language — every day.

All articles →