3 MIN READ

Caching Medusa: What to Cache, Where, and What Never To

Four cache layers, one rule about carts, and the invalidation strategy that stops a price change taking twelve hours to appear.

BY RAHUL MEHTAUPDATED
Illustration for “Caching Medusa: What to Cache, Where, and What Never To” — Caching

Caching is the cheapest performance work available and the easiest to get subtly wrong. The failures are not crashes — they are a customer seeing yesterday's price, or a sold-out product still buyable, which is worse because nobody notices for a week.

Four layers, in descending order of value.

The layers

LayerWhat it holdsTypical TTL
CDNRendered pages, images, assetsUntil invalidated
Storefront data cacheAPI responses per page1h with tags
Backend Redis cacheComputed values — prices, regions, shipping5–60m
PostgresQuery plans, shared buffersAutomatic

CDN first

A statically rendered storefront served from a CDN absorbs traffic without touching your backend. This single decision outweighs every server-side cache you might add.

tsx
export const revalidate = 3600

export async function generateStaticParams() {
  const { products } = await sdk.store.product.list({ fields: "handle", limit: 1000 })
  return products.map((p) => ({ handle: p.handle! }))
}

Everything identical for all visitors belongs here: home, categories, products, content. Storefront performance.

Tagged data caching

Tags are what make invalidation precise instead of blunt:

lib/data/products.tsts
import { unstable_cache } from "next/cache"

export const getProduct = (handle: string, regionId: string) =>
  unstable_cache(
    async () => {
      const { products } = await sdk.store.product.list({
        handle,
        region_id: regionId,
        fields: "*variants.calculated_price,+variants.inventory_quantity",
      })
      return products[0] ?? null
    },
    // Region is part of the key: prices differ per region.
    ["product", handle, regionId],
    { tags: [`product-${handle}`, "products"], revalidate: 3600 },
  )()

The region in the cache key is not optional. Omit it and a European visitor warms the cache with euro prices that the next American visitor then sees.

Backend Redis cache

For values computed repeatedly on the server:

medusa-config.tsts
modules: [
  {
    resolve: "@medusajs/medusa/cache-redis",
    options: {
      redisUrl: process.env.CACHE_REDIS_URL,
      ttl: 30,
    },
  },
]

Then in your own code:

ts
const cache = container.resolve("cache")
const key = `shipping-options:${regionId}:${countryCode}:${cartTotalBucket}`

let options = await cache.get<ShippingOption[]>(key)
if (!options) {
  options = await computeShippingOptions(regionId, countryCode)
  await cache.set(key, options, 300)
}

Note cartTotalBucket rather than the exact total — bucketing by price band keeps the hit rate usable instead of generating a unique key per cart.

Cache keys

Every key needs the dimensions that change the answer:

DataKey must include
Productid or handle, region, currency
Category listingid, region, page, sort, filters
Shipping optionsregion, country, total bucket
Tax rateregion, product type
Pricevariant, region, currency, customer group

Customer group is the one people forget, and it is how a B2B buyer's contract price ends up cached for retail customers. If a value can differ per customer group, that group belongs in the key — or do not cache it at all.

Never cache

  • Carts. They change constantly and are per-customer.
  • Customer data. Addresses, orders, payment methods.
  • Sessions and auth.
  • Inventory you display as exact. "Only 2 left" must be live. Cache a boolean in-stock flag if you need one.
  • Anything under a customer-specific price list.

The rule: if two customers could correctly see different values, either the difference is in the key or you do not cache it.

Invalidation

Event-driven, not TTL-driven. TTLs are a fallback, not a strategy:

src/subscribers/invalidate-storefront-cache.tsts
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"

export default async function invalidateCache({
  event,
  container,
}: SubscriberArgs<{ id: string }>) {
  const query = container.resolve("query")
  const logger = container.resolve("logger")

  const { data: [product] } = await query.graph({
    entity: "product",
    fields: ["handle"],
    filters: { id: event.data.id },
  })

  const res = await fetch(`${process.env.STOREFRONT_URL}/api/revalidate`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-revalidate-secret": process.env.REVALIDATE_SECRET!,
    },
    body: JSON.stringify({ handle: product?.handle }),
  })

  if (!res.ok) {
    // A failed invalidation is a stale-price incident waiting to happen.
    logger.error(`[cache] revalidate failed: ${res.status}`)
  }
}

export const config: SubscriberConfig = {
  event: ["product.updated", "product.deleted", "price-list.updated"],
}

Log failures loudly. A silently failed invalidation is exactly the bug that shows a sale price for twelve hours after the sale ends.

Stampedes

When a popular key expires, every concurrent request recomputes it at once. Two mitigations:

Stale-while-revalidate. Serve the stale value and refresh in the background. Next.js does this by default for ISR — one of the better reasons to use it.

Jittered TTLs. ttl + random(0, ttl * 0.1), so a thousand keys written together do not expire together.

Measuring

Track hit rate per cache, p95 latency with and without, and invalidation lag — the time from a price change to the storefront reflecting it. That last number is the one merchandisers care about, and it is the one nobody instruments until someone complains.

Below about 80% hit rate, your keys are too specific. Near 100% with complaints about stale data, invalidation is broken.

Cache warming

After a deploy or a full invalidation, the first visitor to each page pays the full cost. On a large catalog that means a period of slow pages and elevated database load exactly when you have just shipped.

Warm the pages that matter:

src/jobs/warm-cache.tsts
export default async function warmCache(container: MedusaContainer) {
  const logger = container.resolve("logger")
  const query = container.resolve("query")

  // Warm what people actually visit, not the whole catalog.
  const { data: products } = await query.graph({
    entity: "product",
    fields: ["handle"],
    filters: { status: "published" },
    pagination: { take: 200, order: { updated_at: "DESC" } },
  })

  for (const product of products) {
    await fetch(`${process.env.STOREFRONT_URL}/products/${product.handle}`, {
      headers: { "user-agent": "cache-warmer" },
    })
  }

  logger.info(`[warm] ${products.length} pages`)
}

export const config = { name: "warm-cache", schedule: "*/30 * * * *" }

Warm your top pages by traffic, not the entire catalog — a 20,000-product warm is a self-inflicted load test.

Debugging a stale page

A checklist for "this price is wrong on the storefront", in the order that finds it fastest:

  1. Is it stale in Medusa too? Check the admin. If the admin is wrong, it is not a cache problem.
  2. Did the invalidation fire? Look for the subscriber's log line and the revalidate endpoint's response.
  3. Did it invalidate the right tag? A handle change means the old tag no longer matches anything.
  4. Is it the CDN, the data cache, or the browser? Request with a cache-busting query string; if that is correct, the page cache is stale.
  5. Is the key missing a dimension? If only some customers see it, the key is missing region, currency or customer group.

Step five is the one that produces the strangest bug reports, because it is intermittent by nature and depends entirely on who warmed the cache first.


Caching problems are quiet until they are expensive. We are happy to review a strategy.

Frequently asked questions

What should I cache in a Medusa store?

Product and category pages at the CDN, API responses in the storefront data cache with tags, and expensive repeated computation such as shipping options and tax rates in the backend Redis cache. Never cache carts, sessions or customer-specific data.

Why do customers see the wrong prices after caching?

Almost always a cache key missing the region, currency or customer group. If two customers could correctly see different values, every dimension that varies must be part of the key.

How do I invalidate the storefront cache when a product changes?

Tag your cached fetches, expose a secret-protected revalidate endpoint on the storefront, and call it from a Medusa subscriber on product and price-list events. Log failures — a silent invalidation failure means stale prices.

Should I use TTLs or event-driven invalidation?

Event-driven, with TTLs as a fallback. Short TTLs waste work and still leave a stale window; event-driven invalidation updates exactly what changed, exactly when it changes.

What is a cache stampede and how do I avoid it?

When a popular key expires and many concurrent requests recompute it simultaneously. Serve stale content while revalidating in the background, and add jitter to TTLs so keys written together do not expire together.

Can I cache inventory levels?

Cache a boolean in-stock flag, not exact counts. Displaying "only 2 left" from a cache leads to overselling and to customers seeing quantities that no longer exist.

[ Keep reading ]