There's a joke that's been circulating in programming for years: "There are only two hard things in Computer Science: cache invalidation and naming things" — meaning that in computer science there are supposedly only two hard problems: invalidating cache and naming things. For a long time I thought the first part was a bit overstated. But now I'd extend that saying to cover all of cache management, not just invalidation.
A hypothetical scenario: a user opens a product page on Artovnia and sees a price of 199 PLN. They click "add to cart", and in the cart the product already costs 149 PLN. Nobody changed anything — two different cache layers simply didn't manage to agree with each other before the user clicked.
When you're building a simple website, cache can indeed seem straightforward. You have a page that changes once every few hours or days, you push it to a CDN, set a sensible TTL, and in most cases it works.
In a classic store things get harder, but the environment is still fairly controlled. A product has a price, stock level, variants, and possibly a promotion. You manage the product catalog yourself and usually know well which data can change and when.
In a marketplace the number of dependencies grows quickly, and cache starts to concern not just response speed but also data consistency between different parts of the application.
One product card, several cache layers
Going back to the scenario from the beginning of this article. If the listing uses a different cache layer than the PDP and both layers have different freshness rules, it's easy to end up in a situation where the user sees a different price or a different promotion right after navigating to the product. A similar problem can occur between the PDP and the cart, which is why price, promotion, and availability require a much more careful approach than a product description or a category name.
In Artovnia, several layers are involved:
Simply asking whether an endpoint is cached tells you very little. You also need to know which layer can return the result, how long it can hold it, what data it contains, and exactly what triggers its invalidation.
A marketplace complicates even a simple PDP
In a simple store, a product page can rely on one large payload and a few additional requests. In a marketplace, a product is tied to a much larger number of data points.
On a typical Artovnia product page I need, among other things:
That's a lot, and more importantly, all of this data has completely different freshness requirements. A category name can be cached for a very long time, and so can a product description, which usually doesn't need refreshing every minute — but a seller's status should react faster, and stock level and price require even more care, because a promotion can start or end at a specific time. There is no single good TTL for something that on the frontend we simply call a "product", and that is one of the main problems to solve in a marketplace scenario. There is no single path — only deliberate trade-offs.
Every marketplace handles this slightly differently, which is visible at first glance when browsing Etsy, Amazon, eBay, or Allegro. Each has different behaviors, different rendering priorities, fetching strategies, and UX. Etsy fares the worst, using cheap tricks especially in the desktop version.
Promotions worked correctly, but it could be done much better
Artovnia already had on-demand invalidation. A promotion change in the backend triggered a call to the storefront's revalidation endpoint, and the appropriate cache tags were invalidated. Users weren't browsing the marketplace and seeing stale prices for six hours after a manual promotion change.
The problem was mainly with the architecture and performance of the whole mechanism.
promotions/batch had a cache keyed on the exact set of product IDs. One grid had 8 products, another 15, another 20, the homepage could send 77 or 78 IDs, and each such set created a different key. The cache formally worked, but with dynamic marketplace listings it had relatively few opportunities to hit exactly the same set.
Telemetry also showed that around 75 to 80 percent of MISS time was spent recomputing context that didn't depend on the product set at all. The list of active promotions, the mapping of promotions to sellers, and some shipping data were shared across many requests.
The solution was to split this into two layers. L1 still caches the ready response for a specific set of IDs, so if the identical set appears again, we get a very cheap HIT. L2 stores the shared promotion context under a stable key, so on an L1 MISS we don't have to redo the most expensive part of the work every time.
On top of that came a TTL of 60 to 120 seconds, a distributed lock protecting against stampede, waiting for the write to complete, and a context revision guarding against a late write of a stale result after invalidation. Promotions separately got their own TTL of 120 seconds instead of inheriting the six-hour TTL of product data.
Event-based invalidation remains one of the most important elements: create, update, and delete of promotions or campaigns, as well as relevant changes to delivery options, invalidate the cache and storefront tags, while the short TTL additionally covers changes that result purely from time — for example, an automatic campaign start.
An event provides a fast reaction to a known change, while a short TTL limits the maximum staleness time for time-based changes and cases that event-based invalidation doesn't cover.
One category snapshot and several ways to accidentally bypass it
Categories went through several stages. The idea itself was simple and sensible — instead of fetching the category tree repeatedly, the storefront used a shared snapshot.
Later, additional layers needed by the PDP and category pages appeared, and derived operations — fetching the hierarchy, looking up a category by handle, or preparing category page data — were also wrapped in unstable_cache for safety.
And that's exactly where the problem arose. In simplified form, it looked like this:
const getCategoryHierarchyCached = unstable_cache(
async (...) => {
const snapshot = await getCategorySnapshotCached()
// ...
}
)getCategorySnapshotCached() also used unstable_cache, so at the code level it was easy to assume we simply had two cache layers. In the version of Next.js 15 I was using, however, this composition didn't behave the way I expected.
In paths with a nested unstable_cache I observed the expected Data Cache entry being bypassed, and internal fetches were re-executing as if they had force-no-store. In practice, the extra layer that was supposed to reduce work was causing the existing Data Cache to be bypassed on those paths.
Telemetry showed 10 snapshot fetches in 30 minutes with two warm instances, despite a TTL of 24 hours. Interestingly, the layout itself used the snapshot correctly — the problem only appeared in specific derived paths, used among other things by the PDP and category logic.
The fix was to restore a single authoritative snapshot in the Data Cache and use React cache() for request-level memoization of operations performed on that snapshot. Searching a tree of several hundred categories in memory is very cheap, unlike another HTTP request to the backend.
The more independent copies and derived snapshots start to exist in different caches, the more things you need to coherently invalidate later. A simpler model — one cache of source data and cheap in-memory operations — turned out to be easier to control.
Cache can also effectively store bad HTML
During an ISR experiment for the PDP, the dynamic product page was switched to on-demand ISR using:
generateStaticParams() {
return []
}Combined with the loading.tsx skeleton, a behavior appeared that the build didn't detect.
The first request triggered a prerender, loading.tsx delivered the fallback, and the actual product data was only in the RSC payload. The problem was that the prerendered HTML contained only the skeleton, which the CDN then efficiently cached.
The request had 200 OK, X-Nextjs-Prerender: 1 and X-Vercel-Cache: HIT, but inside <main> the product name, price, description, gallery, breadcrumbs, and seller data were all missing. The data was available in the RSC payload but had not been rendered into HTML. For a user with JavaScript the situation could look different than for a crawler fetching the document.
The PDP went back to dynamic rendering and streaming. loading.tsx and Suspense can provide immediate feedback during navigation, and a crawler reading the full HTTP response after the stream completes still receives the rendered product content in HTML, while data can be independently handled by the Data Cache, request-level cache(), and the CDN.
The same bug, two different symptoms
A skeleton frozen in cache wasn't the only consequence of generateStaticParams() { return [] }. The same combination — ISR without parameterized paths — triggered two more separate production incidents, each with a completely different symptom.
The first involved useSearchParams() without a Suspense boundary. On a dynamically rendered route the problem ended with part of the tree falling back to client-side rendering — something that happens silently and doesn't break the page. After switching the same route to prerendering, the same missing Suspense boundary became a rendering error and in production ended with a 500 response. A script added generateStaticParams to 23 category routes at once, and the chain that exploded ran through page.tsx, renderCategoryPage, SmartProductsListing, ProductListing, all the way down to useSearchParams(). Another route with the identical pattern would have failed the same way — it just received less traffic, so it surfaced less often.
The second incident involved metadata. Title, description, canonical, og and twitter tags instead of landing in <head> were ending up in <body>. The cause lies in the same loading.tsx, only in combination with async generateMetadata. Metadata streaming is a deliberate Next.js mechanism, not broken HTML, but in this particular composition it started working against us: loading.tsx creates a Suspense boundary at the entire route level, Next.js streams its fallback as the initial HTML, and generateMetadata — waiting for the region and product data — couldn't resolve before </head> was sent, so the mechanism responsible for streaming metadata pushed it into <body>.
A test on four products via curl with a Googlebot UA showed this very concretely. Without cache-busting all four had metadata in the body. With cache-busting — i.e. on a fresh render — three of the four had metadata correctly in the head, but one, the newest of the four, had it in the body even on a fresh render. That product hadn't yet made it into the Data Cache, so fetching its data took longer, and generateMetadata had even less time before the head was sent.
Three different symptoms, one cause: loading.tsx combined with ISR without parameterized paths. Which piece of code happened to be waiting for something asynchronous at the moment the response was sent determined whether production got an empty skeleton, a 500 error, or metadata absent from the initial <head> of the response.
Full HTML and a fast PDP can coexist
This is especially important in a marketplace. The user doesn't need to receive every section of the page at the same moment. The title, images, price, variants, availability, and the ability to purchase are important right away, while the carousel of similar products, other products from the seller, or some reviews can appear later.
You can therefore build the page with a clear split between the primary render path and deferred data. Streaming lets you deliver the essential part of the interface quickly while the remaining sections continue to render.
You also need to check the actual HTML of the response. The mere fact that data exists in the RSC payload, or that the user sees a correct page after hydration, doesn't mean the crawler received the same document. A next build test wasn't enough to detect the ISR problem — a production next start and checking the real HTML via curl were needed.
Recommendations and the fan-out problem
Suggested products used a similarly expensive strategy — they looked for recommendations at every level of the category hierarchy. For a product located in:
Dom
└── Lampy
└── Lampy stołowea request was made for each level.
In one session, telemetry showed 48 backend calls, even though many requests had identical arguments — two products belonging to the same category could send exactly the same query.
The first instinct might be to add another cache layer, but a better solution turned out to be changing the algorithm. The leaf-first strategy first fetches products only from the most specific category, and if the result is enough to fill the carousel, the work stops — only when there aren't enough products are the parent categories fetched.
After the change, a behavioral test confirmed one fetch for a category that could fill the carousel on its own, and three for a sparse leaf at a tree depth of three levels.
Cache still matters here, but it's worth reducing the number of operations that need caching in the first place.
Router Cache is part of UX
Cache doesn't end at the API. A typical shopping session looks like this:
listing → produkt → listing → kolejny produkt
The user expects that going back to the listing will be practically instant.
In Next.js 15, the Router Cache for dynamic routes had a default staleTime of 0 seconds. In production this wasn't always obvious, because the next request often hit the CDN, but locally the absence of cache was much more noticeable.
The first fix was to set:
experimental: {
staleTimes: {
dynamic: 30,
static: 180,
},
}Thirty seconds was supposed to cover the typical browsing loop: category, product, back. In practice it didn't, because looking at a product for longer than half a minute is the norm, not the exception, so returning regularly hit an already-expired entry. That value was never actually measured — it was just rounded down to the old default threshold from Next.js 14.
The real problem only became clear when I looked at that number together with its neighbors in the chain. Router Cache is not the only mechanism deciding whether returning to a product is fast. The prefetch triggered by touching a card has its own window during which it refuses to re-fetch the same route, and the CDN has its own response lifetime.
Required ordering: prefetch dedup window ≥ Router Cache ≤ CDN TTL. Before the fix it was the opposite — prefetch held 5 minutes, Router Cache only 30 seconds, so returning to a product between the 30-second and 5-minute mark hit an expired entry while prefetch refused to rebuild it.
For a while it was the other way around. The prefetch dedup held five minutes, Router Cache only thirty seconds. Every return to a product viewed between the thirtieth second and the fifth minute hit an entry that had already expired, while prefetch still refused to rebuild it. That return therefore went the full network path and triggered the full-screen loading overlay, even though the user had been looking at the same product moments earlier.
The fix was to extend Router Cache to three hundred seconds, not to shorten the prefetch window. Prefetch fires on every touch of a product card, including during ordinary scrolling of a listing on a phone, so a shorter dedup window would turn scrolling into a series of fetches for the same route. In our case, extending Router Cache from 30 to 300 seconds didn't worsen the effective data freshness: the same payload arrives from the CDN with a six-hour lifetime, so by the time it enters Router Cache it may already be many hours old. Tightening the last link in the chain achieved nothing when the earlier link was dozens of times more permissive — it only bought an extra round trip for the same, equally stale response.
The cart and account are safe regardless of these numbers: their mutations go through Server Actions with revalidateTag, which in the version of Next.js we use clears the entire client-side Router Cache at once.
The loading overlay itself had a separate problem, independent of cache. It appeared after a threshold of fifty milliseconds, chosen to fit within the hundred-millisecond interaction feedback budget, on the assumption that navigation is either instant or slow. Production measurement showed it was neither: the round trip for the PDP took between 240 and 275 milliseconds. The overlay therefore appeared for fifty milliseconds and disappeared about two hundred milliseconds later — a full-screen skeleton flashing for a fraction of a second, which reads as a UI glitch rather than progress.
The solution was two separate numbers instead of one. The overlay show threshold was raised to four hundred milliseconds — clearly above typical navigation, not just above the instant threshold. Alongside it a minimum visibility time was added, also four hundred milliseconds: without it, every navigation landing just past the threshold would produce exactly the same flash the threshold was meant to prevent. The thin progress bar above categories stayed at fifty milliseconds, because it doesn't replace the entire layout and covers nothing, so there's nothing to flash there. The same number would fit one mechanism and harm the other: when to show feedback and how to prevent it from flashing are two different questions, and a single constant doesn't answer both at once.
Router Cache, the prefetch window, CDN, and the overlay thresholds are five different numbers in five different places in the code, and none of them is a standalone configuration detail. They are interconnected, so changing one without checking the others can reproduce exactly the same bug in a completely different place.
Ultimately, you need to measure real requests
One of the most important elements of the whole investigation, alongside personal manual testing, was telemetry. Simply counting function invocations wasn't enough — the Data Cache can replay a response including headers, so even looking at X-Cache can lead to wrong conclusions.
I added caller, fetchInvocations, and the backend-generated X-Origin-Response-Id to my measurements. This makes it possible to distinguish:
- the number of wrapper invocations,
- the number of cache reads,
- the number of actual requests to Medusa.
After the last recommendations pass on the PDP there were 32 fetch invocations, but only 20 unique backend responses. 12 invocations were therefore served without a new origin request — 37.5 percent. For the general listProductsLean, four invocations generated only one unique origin request.
The final promotions measurement
After the changes, promotions/batch looked much better. In a test session:
OPTIONS /store/products/promotions/batch: 0,- direct browser → Medusa GETs: 0,
- server-side Next.js → Medusa GETs: 39,
- all requests used the new cache version.
The median response time was around 40.6 ms. 26 of 39 requests completed below 50 ms, 34 of 39 below 100 ms, and 37 of 39 below 300 ms.
Two requests were slower. One involved the homepage and a batch of 77 products, so it wasn't a typical PDP case, and the other was a single outlier related primarily to slower product reads and a write to Redis. The 300 ms threshold was met in 94.9 percent of samples.
In a dynamic marketplace some keys will naturally be unique, so looking only at the cache HIT ratio isn't enough. Sometimes making a MISS cheap matters more than increasing the hit rate.
How I think about caching today
I used to think of cache roughly like this:
request
↓
cache
↙ ↘
HIT MISS
↓
backendIn a large web application it looks more like a dependency graph. A request can pass through the Router Cache, CDN, and Data Cache, and then individual page fragments fetch more data independently of each other — the backend has its own cache, after all. Some responses depend on the region, some on the seller, some on the product, and some on the promotion, and each of them may require a different lifetime: from many hours down to just a few dozen seconds.
On top of that come event-based invalidations, TTL, streaming, SSR, RSC, bots, and client-side navigation.
Simply writing something to Redis is the relatively easy part. The most work goes into determining:
- what is the source of truth,
- where copies of the data exist,
- how long each copy can live,
- what events invalidate it,
- whether several screens share the same freshness rules,
- whether one layer's cache bypasses or disables another,
- whether the crawler receives full HTML,
- whether the user can navigate between pages quickly,
- how many origin requests the application actually makes.
Conclusions
Not everything was misconfigured from the start. The suggested products cache was only created after that component was added, and some of the later changes to it were the result of experiments that didn't always pan out but yielded valuable knowledge. As the PDP evolved and additional cache layers were built out, the complexity of the entire chain grew until it required thorough analysis and architectural — and sometimes business — decisions.
The changes were verified with k6 load tests in two variants: an HTTP test across the full sitemap and a separate browser test measured via Grafana Cloud. The latter has a limit of 10 Browser VU on the current account, so the full 100 VU profile ran only in the HTTP variant, where backend load during the test stayed at up to 1% of the maximum plan.
It's worth separating two levels of measurement.
From the same span, the database time alone is p99=110 ms (78% of the span, 18% of the full E2E), with a constant 22 SQL queries per request.
The difference between p99 E2E (608 ms) and p99 backend (141 ms) is 467 ms — 77% of the k6 value. That is time outside the Medusa handler: CDN, edge, Next.js SSR, network RTT, HTML transfer.
It's worth noting that p50 is higher in OTel (93 ms) than in k6 (84 ms) — these are two different populations, not the same measurement from a different vantage point. k6 p50 is a CDN cache hit (backend not touched), OTel p50 is a cold miss in the backend. The cache ratio for the PDP is close to 74%: k6 ran approximately 4220 PDP iterations, but OTel recorded only 1085 pdp.primary spans, meaning approximately 3135 requests were served from CDN/edge cache and never reached the backend.
For the homepage the cache ratio is even higher — OTel recorded count=1 against approximately 1580 home iterations in k6, giving close to 99.9% cache hit. Most importantly, skeleton behavior stabilized — including what bots see and when, and the number of requests hitting the backend. Data Cache HIT under load is 91% (Vercel dashboard). The backend under the same load responded with an average time of 45 ms (OTel global avg, all 2992 traces) and zero errors.


