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 drawers

At 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 userId as 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 time

If 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)
  • generateStaticParams pages × 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 already

At 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:

packages/config/app.config.ts
STATIC_PARAMS: {
  LOCALE: false,      // Root locale layout — disabled
  HELP: false,        // Help center MDX pages — disabled
  CATEGORIES: true,   // Category pages — ENABLED
  ITEMS: false,       // Item detail pages — disabled
}

What actually prerenders today

Static pages (always prerender, no flag needed):

RoutePages per locale× 3 localesNotes
Homepage13getPublicListings + getCategoryTree + getAllColors
Marketing pages (about, story, pricing, etc.)~1854Mostly static content, some call category/color queries
Legal pages (terms, privacy, cookies)39Static content
Auth pages (login, signup, etc.)~1030Mostly forms, minimal queries

generateStaticParams pages (controlled by config):

RouteFlagStatusPages if enabled
Category pages (/categories/[id]/[slug])CATEGORIES: trueON~N root categories × 3 locales
Item detail pages (/items/[publicId]/[slug])ITEMS: falseoffWould be 5 items × 3 locales = 15
Help topic pages (/help/[topic])HELP: falseoffWould be ~10 topics × 3 locales = 30
Help article pages (/help/[topic]/[article])HELP: falseoffWould 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 calls

Even 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:

FunctionParamsDrawers (×3 locales)Why safe
getAllColors(locale)31 per locale
getAllConditions(locale)31 per locale
getCategoryTree(locale)31 per locale
getAllCategories(locale)31 per locale
getCategoriesGroupedByParent(parentId, locale)~60~20 parents × 3, bounded
getCategoriesByParent(parentId, locale)~60~20 parents × 3, bounded, reference data
getPlatformSettings(locale)3Singleton
getAllOptionGroupsWithValues(locale)31 per locale
getSizeOptionsByCategory(locale)31 per locale
Subtotal~141 drawersManageable 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:

FunctionParamsDrawers (×3 locales)Why acceptable
getCategoryWithAttributesEAV(categoryId, locale)~60~20 categories × 3, EAV data is static
getEffectiveSizeGroupsForCategory(categoryId, locale)~60Delegates to getCategoryWithAttributesEAV
Subtotal~120 drawersReference 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:

FunctionParamsWhy 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:

FunctionWhy 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":

FunctionWithout cache() drawersWhy 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 × 3Item detail related items
getListingSellerInfo(publicId)N itemsItem detail seller drawer
getMoreListings(excludeId, ...)N items × 3Item detail "more items"
getPublicListings(locale, ...)Unbounded paginationHomepage + search results
getBrandBySlug(slug, locale)200+ × 3 = 600+Brand detail pages
getBrandById(id, locale)200+ × 3 = 600+Brand lookups by ID
getBrandsWithPagination({...})Unbounded paramsBrands listing with search/sort/filter
getSellerItemsForProfile(sellerId)N sellersUser 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 calls headers() which throws
  • Never use next-intl <Link> inside "use cache" — it internally calls getServerLocale()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

Examples from the codebase

"use cache" — safe (3 drawers total)

packages/features/colors/queries/get-all-colors.ts
export async function getAllColors(locale = "en") {
  "use cache";
  // 3 drawers total (en, es, pt). Safe.
  return db.query.colors.findMany({ ... });
}

"use cache: private" — safe (auth-gated, zero build drawers)

packages/features/orders/queries/get-pending-order-counts.ts
export async function getPendingPurchasesCount(userId: string, locale = "en") {
  "use cache: private";
  // Only called for logged-in users inside <Suspense>.
  // Never prerendered — zero drawers at build time.
  // "private" scopes cache to the current user's session.
  return db.select({ count: count() }).from(orders).where(...);
}

React cache() — necessary (N × 3 drawers if cached)

packages/features/listings/queries/get-listing-by-public-id.query.ts
export const getListingByPublicId = cache(
  async (publicIdParam: string | number, locale = "en") => {
    // Each prerendered item page × 3 locales.
    // "use cache" would create N×3 drawers, all filling concurrently.
    return db.query.listings.findFirst({ where: { publicId } });
  }
);

React cache() — necessary (called 2× per page, dedupes)

packages/features/categories/queries/get-category-hierarchy.ts
export const getCategoryHierarchy = cache(
  async (categoryId: number, locale = "en") => {
    // Called from both generateMetadata and page content.
    // cache() dedupes to 1 query. "use cache" would double the drawers.
    return db.execute(sql`...LTREE query...`);
  }
);

React cache() — necessary (200+ brands, converted from "use cache")

packages/features/brands/queries/get-brand-by-slug.ts
export const getBrandBySlug = cache(
  async (slug: string, locale = "en") => {
    // 200+ brands × 3 locales = 600+ drawers if "use cache".
    // Per-request dedup only; no persistent cross-request cache.
    return db.select().from(brands).where(eq(brands.slug, slug)).limit(1);
  }
);

"use cache" + request-time only — safe (never prerendered)

packages/features/fulfillment/queries/get-cached-shipping-quote.ts
export async function getCachedShippingQuote(
  originPostalCode: string,
  destinationPostalCode: string,
  weightKg: number,
  insuranceValue: number,
  locale = "en",
) {
  "use cache";
  cacheLife("frequent");
  // High cardinality (postal code combos), but ONLY called at request time
  // from authenticated checkout flow. Zero drawers at build.
  // Cross-request caching benefits repeat quote lookups for the same route.
  return melhorEnvioSDK.calculateShipping(...);
}