Litestore · Developer guide
Data access

Caching

See how Litestore caches reads with tags, clears them after every write, and which reads it never caches.

Litestore caches storefront and admin reads so pages load fast, and it clears the right cache entries whenever data changes. Read this page before you add a cached read or a new kind of write.

Each cached read gets Next.js tags from one fixed list. The CRUD layer then invalidates those tags on every write.

The fixed list is the whole point of the design. A cache tag is a string that a read and a write share without ever seeing each other. If the two strings don't match, nothing raises an error, and the page simply shows stale data. Litestore therefore keeps every tag as a constant, and the build fails on any other string.

This page covers that tag system. It also explains the five-minute rotation window that keeps the home feed cacheable, and the reads Litestore deliberately never caches.

Tag-based invalidation

CACHE_TAGS in lib/cache.ts defines every tag Litestore uses.

Some tags are static, such as these:

products
orders
settings
facets

Others are functions that build a tag for one entity:

CACHE_TAGS.product(id)
CACHE_TAGS.productBySlug(slug)
CACHE_TAGS.variantStock(id)

A cached read declares the tags it depends on, and a write invalidates the same constants. The read and the write can't fall out of step.

lib/channel.ts
const resolveSoleChannelCached = unstable_cache(
  async (defaultCurrency: string): Promise<ChannelInfo | null> => {
    const channels = await db.channel.findMany({
      where: { isActive: true, deletedAt: null },
      select: channelCandidateSelect,
      take: 2,
    })
    if (channels.length !== 1) return null

    const only = channels[0]
    return toChannelInfo({ ...only, currency: only.currency || defaultCurrency })
  },
  ["sole-active-channel"],
  { revalidate: 300, tags: [CACHE_TAGS.channels] },
)

Never invalidate with a raw tag string

scripts/check-cache-tags.ts fails the build when you pass a string or template literal to revalidateTag, invalidateTag or invalidateTags. It also fails a BaseCRUD subclass that declares protected cacheTag = "...". A mistyped tag, such as "admin-refunds" where the read used "refunds", never throws. The page just shows stale data with no error.

Writes invalidate through the CRUD layer

BaseCRUD.getRelatedCacheTags maps each entity to the tags a write to it affects.

The base class calls it on every mutation, so a mutation can't forget a page that depends on the data. Each module lists its tags once instead of repeating them at every place that writes.

Domain events invalidate through a map

invalidateCachesForDomainEvent in lib/cache.ts is a single static map from each event type to the caches it clears. functions/domain-events.ts runs it.

Some events don't affect any cached data:

cart.merged
checkout.started
social.*
wishlist.*

These events invalidate nothing, and the map lists them explicitly with an empty invalidation. An event listed that way is a decision. An event missing from the map is an oversight. If both looked the same, you couldn't tell them apart.

Invalidation is best-effort on purpose

safeRevalidateTag catches and logs failures instead of throwing them to the caller.

By the time it runs, the data is already written. If a flush is missed, the cache is stale for a while. If invalidation threw during render, the visitor would get a 500 error. You can recover from the first, but not from the second.

Time-based ISR pages also need the path

Some storefront pages use time-based ISR instead of relying only on tags.

For that reason, invalidateProducts, invalidateCollections and invalidateCategories also call revalidatePath on the entity's URL. Without it, an edit would not show until the ISR window ran out.

Why every write invalidates

When a mutation writes data without invalidating the cache, the bug is silent, comes and goes, and is easy to miss in review. The admin table shows stale data until the cache expires, and by then nobody links the two.

scripts/check-cache-invalidation.ts scans every server-side file that runs a Prisma mutation directly. Each such file must import from ~/lib/cache, or import revalidateTag or revalidatePath from next/cache.

Files that are allowed to skip this rule are listed in an explicit ALLOWLIST in that script, each with its reason:

  • BaseCRUD subclasses
  • Activity and analytics writes
  • Seeds
  • Tests

Because every entry states its reason, someone has to argue for an exemption. It can't slip in by accident.

The five-minute window

FEED_ROTATION_WINDOW_MS in server/web/feed/index.ts is five minutes.

currentFeedSeed() picks the rotation bucket from the clock instead of choosing a random seed for each request. It cycles through all FEED_SEED_BUCKETS orderings in about eighty minutes.

Everyone who loads the page within one window sees the same order. That keeps exactly one cache variant in use at a time.

A random seed per request was tried first. It split getFeedCached into sixteen variants:

per-request random seed
    ↓
16 cache variants
    ↓
most storefront views miss
    ↓
full uncached feed build, per request

The feed still rotates. The clock decides when it changes, not the visitor.

The caller generates the seed and passes it through pagination. Page 2 therefore uses the same order as page 1, even if a new window starts in between.

What is never cached

Litestore never caches these reads:

  • Anything in the admin that requires sign-in.
  • The storefront cart. server/web/cart/queries.ts reads the cart cookie, so the cart is rendered fresh for each request.
  • Stock at the moment it is reserved. lib/inventory.ts takes a SELECT … FOR UPDATE row lock inside the transaction.

Stock is the interesting case, because it appears on both sides.

The stock count on a product page is cached under CACHE_TAGS.variantStock(id). The stock check when an order is placed is not cached.

This difference is deliberate. The number on the product page is a hint. The check at checkout is a decision.

Deciding how to cache a new read

If the value comes from stored data and a write can name the pages it affects, cache the read with a tag from CACHE_TAGS. This is the usual case, and the CRUD layer already handles it.

If the read is expensive and slightly stale data is acceptable, also add a revalidate window. resolveSoleChannelCached does this with 300 seconds.

If the page uses time-based ISR, also call revalidatePath. Otherwise the edit waits for the ISR window.

Don't cache the read if it naturally differs per request, like the cart cookie or anything behind admin sign-in. Also don't cache it if the code uses the value to commit to something. Being correct at the moment of a write is not something caching can solve.

If you add a new input that a cached read varies on, count the resulting variants first. Inputs that look cheap and change per request are how one busy cache entry turns into sixteen cold ones.

For any cached read, don't ask how stale it can be. Ask this:

Is this value a hint, or a decision?

On this page