Caching strategy
When to use "use cache" vs React cache() in queries — and why it matters at build and runtime
The mental model
Think of "use cache" as a filing cabinet. Each unique combination of arguments becomes a separate drawer:
getBrandBySlug("nike", "en") → drawer #1
getBrandBySlug("nike", "es") → drawer #2
getBrandBySlug("nike", "pt") → drawer #3
getBrandBySlug("adidas", "en") → drawer #4
getBrandBySlug("adidas", "es") → drawer #5
getBrandBySlug("adidas", "pt") → drawer #6
...200 brands × 3 locales = 600 drawersAt build time, Next.js opens every empty drawer at the same time and tries to fill them all concurrently. Each fill opens a database connection. 3 locales means 3x the drawers. 200 brands × 3 locales = 600 concurrent DB connections just from one query function.
React cache() has no filing cabinet. There's nothing to pre-fill. Each page just runs its queries when it renders, and the result is thrown away after the render finishes. No drawers, no concurrent filling.
"use cache: private" — the third option
There's a middle ground: "use cache: private" caches per-user, per-request. The key difference from "use cache":
- Never prerendered — pages behind auth don't generate static shells, so zero drawers at build time
- Still cached at runtime — each user gets their own cached result, shared across navigations within a session
- Auth-safe — designed for queries that take
userIdas a parameter
This makes it perfect for any query behind getSession(). It gives you cross-navigation caching without the build-time drawer explosion.
The locale multiplier
We support 3 locales: en, es, pt. Since locale is passed as a parameter to "use cache" functions (required by next-intl to avoid headers() calls), every cached query is multiplied by 3:
getAllColors("en") → 1 drawer ×3 locales = 3 drawers
getAllColors("es") → 1 drawer
getAllColors("pt") → 1 drawer
getCategoryTree("en") → 1 drawer ×3 locales = 3 drawers
getCategoryTree("es") → 1 drawer
getCategoryTree("pt") → 1 drawer
getBrandBySlug("nike", "en") → }
getBrandBySlug("nike", "es") → } ×200 brands ×3 locales = 600 drawers
getBrandBySlug("nike", "pt") → }A function that looks harmless with 1 locale becomes a problem at 3. And if we add a 4th locale, every number gets multiplied again.
Why locale matters specifically
Because of how next-intl works with cache components, we must pass locale explicitly to getTranslations(). Without explicit locale, getTranslations("namespace") internally calls getConfig(undefined) → headers(), which is forbidden in "use cache" scope and throws an uncatchable prerender error.
This means locale becomes a cache key parameter in every query that also does translations — or in any query that receives locale as a prop from the page:
// This creates a SEPARATE cache entry for each locale
async function getCategoryTree(locale = "en") {
"use cache";
const t = await getTranslations({ locale, namespace: "categories" });
// ...
}
// 3 locales = 3 cache entries, all filled concurrently at build timeIf locale weren't a parameter, there'd be 1 entry. With 3 locales, there are 3.
Important: Even in dynamic components (inside <Suspense>, not "use cache"), always pass locale explicitly to getTranslations({ locale, namespace }). This prevents breakage if someone later adds "use cache" to the component, and makes the locale dependency visible in the code.
The problem
At build time (most severe)
During next build, Next.js prerenders all static pages concurrently — not just generateStaticParams pages. This includes:
- Every static page × 3 locales — homepage (
/en,/es,/pt), help pages (×3), about pages (×3) generateStaticParamspages × 3 locales — item detail, category pages (1 route definition = many pages, each ×3)- PPR static shells × 3 locales — even pages with
<Suspense>get their static shell prerendered
Each prerendered page calls queries. Each "use cache" query creates drawers. All drawers fill at the same time.
Our Supabase connection pool has limited slots (typically 10-20). When the total concurrent fills exceed the pool size:
Error: remaining connection slots are reserved
FATAL: too many clients alreadyAt runtime (cache stampede)
When a "use cache" entry expires (via cacheLife or revalidateTag) and multiple users request it at the same time, they all try to re-fill the same drawer concurrently. This is a classic cache stampede. Less dramatic than build time, but can still spike DB connections during traffic bursts.
React cache() avoids this — there are no shared drawers to expire.
Two caching tools, two behaviors
"use cache" — filing cabinet (persistent, cross-request)
async function getBrandBySlug(slug: string, locale = "en") {
"use cache";
// Next.js creates a drawer keyed by (slug, locale).
// 200 brands × 3 locales = 600 drawers, all filled concurrently at build.
return db.query.brands.findFirst({ where: { slug } });
}- Drawers persist across requests (shared by all users)
- At build time, every drawer fills concurrently = 1 DB connection per drawer
- Locale multiplies everything by 3
React cache() — no cabinet (per-request dedup)
const getListingByPublicId = cache(async (publicId: string, locale = "en") => {
// No drawers. Each page render runs its own query.
// If called twice in one render, dedupes to 1 query.
return db.query.listings.findFirst({ where: { publicId } });
});- Deduplicates within a single request (called twice in one render = runs once)
- No drawers — nothing to fill concurrently at build
- No cross-request sharing — each request runs its own query
- Locale doesn't multiply anything
What happens during our build
Current STATIC_PARAMS config
Not everything prerenders. We control this in packages/config/app.config.ts:
What actually prerenders today
Static pages (always prerender, no flag needed):
| Route | Pages per locale | × 3 locales | Notes |
|---|---|---|---|
| Homepage | 1 | 3 | getPublicListings + getCategoryTree + getAllColors |
| Marketing pages (about, story, pricing, etc.) | ~18 | 54 | Mostly static content, some call category/color queries |
| Legal pages (terms, privacy, cookies) | 3 | 9 | Static content |
| Auth pages (login, signup, etc.) | ~10 | 30 | Mostly forms, minimal queries |
generateStaticParams pages (controlled by config):
| Route | Flag | Status | Pages if enabled |
|---|---|---|---|
Category pages (/categories/[id]/[slug]) | CATEGORIES: true | ON | ~N root categories × 3 locales |
Item detail pages (/items/[publicId]/[slug]) | ITEMS: false | off | Would be 5 items × 3 locales = 15 |
Help topic pages (/help/[topic]) | HELP: false | off | Would be ~10 topics × 3 locales = 30 |
Help article pages (/help/[topic]/[article]) | HELP: false | off | Would be ~96 articles × 3 locales = 288 |
Total today: ~96+ static pages + (N categories × 3), all building concurrently.
Why it still matters even with flags off
The flags control generateStaticParams — but every static page still prerenders regardless. The homepage, marketing pages, and auth pages all prerender × 3 locales. Each one calls shared queries like getCategoryTree, getAllColors, getAllConditions. Those "use cache" drawers still fill concurrently.
The real danger is if we enable more flags. Turning on ITEMS: true would add 5 × 3 = 15 item pages, each calling 4+ queries. Turning on HELP: true would add 96 × 3 = 288 pages. The drawer count explodes.
The math with everything enabled
If all flags were on and queries used "use cache":
Static pages: ~96 × 3 locales = ~288 pages
Category pages: ~20 × 3 locales = ~60 pages
Item pages: 5 × 3 locales = ~15 pages
Help pages: 96 × 3 locales = ~288 pages
─────────
Total prerendered pages: ~651 pages
× avg 3 queries per page: ~1,953 query callsEven with deduplication, the unique drawer count would be in the hundreds. The pool (10-20 connections) collapses.
With React cache() on the high-cardinality queries, those queries run within each page render — no persistent drawers, no concurrent fill storm.
Inventory: what uses which
"use cache" — safe (low cardinality)
These are fine because the total drawer count stays small:
| Function | Params | Drawers (×3 locales) | Why safe |
|---|---|---|---|
getAllColors | (locale) | 3 | 1 per locale |
getAllConditions | (locale) | 3 | 1 per locale |
getCategoryTree | (locale) | 3 | 1 per locale |
getAllCategories | (locale) | 3 | 1 per locale |
getCategoriesGroupedByParent | (parentId, locale) | ~60 | ~20 parents × 3, bounded |
getCategoriesByParent | (parentId, locale) | ~60 | ~20 parents × 3, bounded, reference data |
getPlatformSettings | (locale) | 3 | Singleton |
getAllOptionGroupsWithValues | (locale) | 3 | 1 per locale |
getSizeOptionsByCategory | (locale) | 3 | 1 per locale |
| Subtotal | ~141 drawers | Manageable for 10-20 connections |
"use cache" — safe (moderate cardinality, reference data)
These have higher cardinality but acceptable drawer counts because the data is bounded and rarely changes:
| Function | Params | Drawers (×3 locales) | Why acceptable |
|---|---|---|---|
getCategoryWithAttributesEAV | (categoryId, locale) | ~60 | ~20 categories × 3, EAV data is static |
getEffectiveSizeGroupsForCategory | (categoryId, locale) | ~60 | Delegates to getCategoryWithAttributesEAV |
| Subtotal | ~120 drawers | Reference data, long cacheLife("reference") |
"use cache" — safe (request-time only, never prerendered)
These have high cardinality (postal code combinations) but are only called at request time inside <Suspense> on authenticated pages. They never create drawers during build:
| Function | Params | Why safe |
|---|---|---|
getCachedShippingQuote | (origin, dest, weight, value, locale) | Only called from item detail checkout flow |
getCachedLocalDeliveryQuotes | (sellerZip, buyerZip, addr, weight, locale) | Only called from local delivery checkout |
Zero drawers at build time. Cross-request caching at runtime benefits repeat quote lookups.
"use cache: private" — safe (behind auth)
These only run at request time for logged-in users, never during build:
| Function | Why safe |
|---|---|
getUserCompleteProfile(userId) | Auth-gated, never prerendered |
getUserProfileWithBalance(userId) | Auth-gated |
getUserSettings(userId) | Auth-gated |
getUserFavoriteItems(userId) | Auth-gated |
getUserActiveItems(userId) | Auth-gated |
getUserDraftItems(userId) | Auth-gated |
getUserSoldItems(userId) | Auth-gated |
getUserInactiveItems(userId) | Auth-gated |
getUserItemsForSale(userId) | Auth-gated |
getUserSalesCounts(userId) | Auth-gated |
getCart(userId) | Auth-gated |
getActionNeededCounts(userId, locale) | Auth-gated, per-user counts |
getPendingPurchasesCount(userId, locale) | Auth-gated, per-user count |
getPendingSalesCount(userId, locale) | Auth-gated, per-user count |
getLiveActivities(userId, locale) | Auth-gated, per-user activities |
getSellerPendingOrders(userId) | Auth-gated |
getSellerCompletedSales(userId) | Auth-gated |
getSellerOrdersInTransit(userId) | Auth-gated |
getBuyerOrdersToConfirm(userId) | Auth-gated |
Zero drawers at build time. These can safely use "use cache: private".
Why private matters for userId queries: Without private, "use cache" would create a shared drawer per userId — meaning User A could theoretically see User B's cached data if the cache key collides. private scopes the cache to the current user's session, which is both safer and more correct.
React cache() — necessary (high cardinality × 3 locales)
These would create too many concurrent drawers if they used "use cache":
| Function | Without cache() drawers | Why dangerous |
|---|---|---|
getListingByPublicId(publicId, locale) | N items × 3 = hundreds+ | Each prerendered item = 3 drawers |
getPublicListingsByCategory(categoryId, ...) | ~20 categories × 3 = 60+ | Each category page = 3 drawers |
getCategoryHierarchy(categoryId, locale) | ~20 × 3 = 60+ | Called 2× per page (metadata + content), cache() dedupes |
getListingsBySeller(sellerId, locale) | N sellers × 3 | Item detail related items |
getListingSellerInfo(publicId) | N items | Item detail seller drawer |
getMoreListings(excludeId, ...) | N items × 3 | Item detail "more items" |
getPublicListings(locale, ...) | Unbounded pagination | Homepage + search results |
getBrandBySlug(slug, locale) | 200+ × 3 = 600+ | Brand detail pages |
getBrandById(id, locale) | 200+ × 3 = 600+ | Brand lookups by ID |
getBrandsWithPagination({...}) | Unbounded params | Brands listing with search/sort/filter |
getSellerItemsForProfile(sellerId) | N sellers | User profile item listings |
These stay as React cache() — no drawers, no concurrent fills.
The decision rule
1. Is this query behind auth? (userId param, never prerendered)
YES → "use cache: private" ✅
(zero drawers at build, per-user at runtime)
2. How many unique (params × 3 locales) combinations exist?
Small & bounded (< ~50 total drawers):
getAllColors() → 3 drawers ✅ "use cache"
getCategoryTree() → 3 drawers ✅ "use cache"
getPlatformSettings() → 3 drawers ✅ "use cache"
Moderate & bounded (50-200 drawers, reference data):
getCategoriesByParent() → ~60 drawers ✅ "use cache" (categories rarely change)
getCategoryWithAttributesEAV() → ~60 ✅ "use cache" (EAV structure is static)
Large or unbounded:
getListingByPublicId() → N × 3 drawers ❌ use cache()
getCategoryHierarchy() → 20+ × 3 drawers ❌ use cache()
getBrandBySlug() → 200 × 3 drawers ❌ use cache()
3. Is it called twice per page? (generateMetadata + page content)
YES → React cache() dedupes to 1 query per render
"use cache" would create the drawer twice (metadata + page)
4. Is it only called at request time? (checkout flow, user-specific pages)
YES and high cardinality → "use cache" is OK (no build drawers)
Example: getCachedShippingQuote (postal code combos, only called from item detail)Adding a new locale
When we add a 4th locale, every "use cache" drawer count increases by 33%:
Before (3 locales): getAllColors → 3 drawers
After (4 locales): getAllColors → 4 drawers (+1, fine)
Before (3 locales): getBrandBySlug → 200 × 3 = 600 drawers
After (4 locales): getBrandBySlug → 200 × 4 = 800 drawers (+200!)This is why high-cardinality queries must use React cache() — the locale multiplier makes them untenable as the platform grows.
The next-intl locale pattern
Every query that accepts locale exists because of next-intl's constraint: inside "use cache" scope, getTranslations() must receive locale explicitly or it falls back to headers(), which is forbidden.
The three patterns
Pattern 1: "use cache" component (receives locale as prop):
async function CachedComponent({ locale }: { locale: string }) {
"use cache";
const t = await getTranslations({ locale, namespace: "my.namespace" });
// ✅ No headers() call — locale is explicit
}Pattern 2: Dynamic component inside <Suspense> (locale from params or getLocale):
async function DynamicContent({ locale }: { locale: string }) {
// No "use cache" — this runs at request time inside <Suspense>
const t = await getTranslations({ locale, namespace: "my.namespace" });
// ✅ Explicit locale, even though headers() would work here
}Pattern 3: Server action (getLocale is safe):
"use server";
export async function myAction() {
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: "my.namespace" });
// ✅ Server actions can use getLocale()
}What to avoid
- Never use
getLocale()inside"use cache"— it callsheaders()which throws - Never use next-intl
<Link>inside"use cache"— it internally callsgetServerLocale()→headers() - Always pass locale explicitly to
getTranslations(), even in dynamic components — it prevents breakage if someone later adds"use cache"and makes the dependency visible