Litestore · Developer docs
Data access

Caching

Cache a new read with the right tags, and recognize the reads that shouldn't be cached at all.

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.

Postgres 15+Your data
RedisCache and rate limits
S3 storageImages
LitestoreOne Next.js app
StorefrontAdmin
ResendEmail and sign-in links
InngestBackground jobs
StripeOptional
A write clears the cache tags that the storefront's cached reads declared, so the next visitor sees fresh data.

Cache tags

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 reviewers reject any other string.

The tag system is the main subject below. The five-minute rotation window that keeps the home feed cacheable and the reads Litestore deliberately never caches are covered after it.

Tag-based invalidation

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

products
orders
settings
facets

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.

modules/markets/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

Always pass a constant from CACHE_TAGS to revalidateTag, invalidateTag or invalidateTags, never a string or template literal. A mistyped tag, such as "admin-refunds" where the read used "refunds", never throws. The page just shows stale data with no error.

CRUD invalidation

Each BaseCRUD module declares its static cacheTags once, and the base class refreshes them, plus the entity tag <model>-<id>, on every write outside a transaction. When a write touches more storefront pages, the module's override calls the matching helper in platform/cache.ts (invalidateProducts, invalidateCollections, invalidateCategories, …) after super.

Event invalidation

invalidateCachesForDomainEvent in platform/cache.ts is a single switch from each event type to the caches it clears. jobs/domain-events.ts runs it.

Some events don't affect any cached data:

cart.merged
checkout.started
wishlist.*

These events fall through to the default branch, which invalidates nothing, and a comment there names them so the choice is visible.

Best-effort invalidation

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. A stale cache recovers on its own, and a 500 error doesn't.

ISR paths

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

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

Write invalidation

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.

So every server-side file that runs a Prisma mutation directly should invalidate the tags it affects, either through the cache helpers or with revalidateTag or revalidatePath from next/cache. BaseCRUD subclasses do this automatically. Activity and analytics writes, seeds and tests don't need to, because nothing caches what they write.

Cache lifetime

FEED_ROTATION_WINDOW_MS in modules/storefront/feed/get-feed.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.

We tried a random seed per request 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 determines when it changes, and visitors have no effect on it.

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.

Uncached data

ReadWhy Litestore doesn't cache it
Anything in the admin that requires sign-inIt differs for each signed-in user.
The storefront cartmodules/checkout/cart-queries.ts reads the cart cookie, so Litestore renders the cart fresh for each request.
Stock at the moment it is reservedadjustInventoryInTransaction in modules/inventory/adjust-inventory.ts runs a guarded UPDATE … WHERE quantity >= n inside the transaction, so the loser of a race for the last unit matches no row and is rejected.

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

Litestore caches the stock count on a product page under CACHE_TAGS.variantStock(id). It doesn't cache the stock check when an order is placed.

We made this difference on purpose. The number on the product page is a hint. The check at checkout is a decision.

Caching new reads

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