Caching and performance
Evolve caches in the services and in the storefront. The GraphQL gateway between them caches no responses. This page starts in the services; the storefront and the gateway come last.
A service caches at three levels. Each one answers before the next is asked:
- Per request: a data loader batches the lookups a request makes and answers a repeated key from memory.
- Local tier: for near-static data, an in-process memory in front of the shared cache.
- Shared cache: a Redis store that every instance of every service reads and writes, split into namespaces.
Only what misses all three reaches the commerce engine or the CMS.
resolver: context.loaders.productTypeById.load(id) │ ├─ 1. data loader key already loaded in this request? → answer │ (collects every key asked for in this tick, up to 50 per batch) ├─ 2. local tier entry in this process's memory? → answer ├─ 3. shared cache entry in Redis, namespace "catalog:dataloaders"? → answer │ └─ misses: one request to commercetools for all of them, written back to the shared cache with a ttlData loaders: once per request
Section titled “Data loaders: once per request”Resolvers read engine data through context.loaders, not by calling the
engine directly. Two resolvers that need the same category in one request then
share one fetch, and ten products that each need their product type become one
batched request.
A module declares the loaders it needs with
defineDataLoaders, and
Application merges the
declarations of every module in the composition into context.loaders for
each request. The commercetools modules all share one declaration,
commercetoolsDataLoaders,
so composing twelve of them gives one set of loaders. Two different
declarations that claim the same loader name are an error, not a silent
override.
Loaders are built on first read, not when the context is created. By then the shopper’s token is loaded (see Authentication and sessions), which matters: some loaders cache differently for a signed-in shopper.
The shared cache
Section titled “The shared cache”The cache object from
@evolve-framework/core/cache is one
per process. A service configures it once at startup:
import { cache } from "@evolve-framework/core/cache";
await cache.configure({ url: config.REDIS_URL });The URL decides what the cache is:
redis://orrediss://: a Redis client, shared by every instance.memory://: entries live in this process only. Nothing is shared, so an invalidation on one instance is invisible to the others. Use it for tests and for a local run without Redis, not for a deployment.- No URL: caching is off. Stores answer every read with a miss and drop every write, and the service logs a warning. Everything still works, but every lookup goes to the engine.
For a managed Redis that needs a cloud SDK to authenticate, pass a
credentialsProvider, for example from
createEntraIdCredentialsProvider
for Azure Managed Redis. Core itself depends on no cloud SDK.
Namespaces
Section titled “Namespaces”cache.getStore(namespace) returns a Keyv store for one
namespace. The namespace is a key prefix in Redis, and clearing a store clears
only its own namespace, so several services can share one Redis database. The
packages use namespaces per domain, such as catalog:dataloaders,
account:dataloaders, checkout:dataloaders, and cms:dataloaders. Give
your own data a namespace of its own.
For a single value that is not loaded by key, cache.wrapFn({ key, fn, ttl })
returns the cached value or calls fn and stores the result. It uses the
default namespace, and a ttl of 0 skips the cache. It caches only when
Redis is configured: with memory:// it always calls fn.
All ttls are in milliseconds.
When Redis fails
Section titled “When Redis fails”The cache never fails a request. A read that errors is logged and treated as a miss, and a write that errors is logged and dropped. The Redis client gives up connecting after 500 ms and retries a few times, and a background check tries to reconnect every 10 seconds. While Redis is down, the service is slower, not broken.
The local tier
Section titled “The local tier”Some data is read on almost every page, is covered by a handful of keys, and barely changes: product types, the attributes highlighted on them, categories, the store itself. For that data, the trip to Redis is the whole cost of the lookup.
Pass localTtl and the store gets a
LocalTierStore in front of
Redis:
cache.getStore("catalog:dataloaders", { localTtl: 30_000 });The local tier is a small in-process memory (1,000 entries by default, least
recently used out first). It answers from memory for up to localTtl and asks
Redis only for the keys it does not have, so a batch it covers in full never
leaves the process.
Two properties keep it safe:
- It cannot extend a value’s life. It holds the entry as Redis returned it, with its expiry, so a value expires at the same moment as it does in Redis.
- It never remembers a miss. A value another instance writes is visible on the next read.
The cost is staleness. Deleting a key reaches Redis, but not the memory of
other instances, which keep answering for up to localTtl. Use the local
tier only for data where nobody waits on that difference. Not for anything
priced, personal, or edited with the expectation of seeing the result.
The commercetools package uses a local ttl of 30 seconds for product types, highlighted attributes, categories, stores, and associate roles. The Contentful package uses 5 seconds for the header, footer, and navigation.
What the engines cache
Section titled “What the engines cache”The commercetools loaders follow one rule: shared data is cached, personal data is not.
| Data | Shared cache | Local tier |
|---|---|---|
| Product types, categories | 5 minutes | 30 seconds |
| Highlighted attributes | 60 minutes | 30 seconds |
| Stores, associate roles | 5 minutes | 30 seconds |
| Products (by id, slug, SKU) for guests | 5 minutes | no |
| Product slugs, product discounts, discount codes, shipping methods, custom objects | 5 minutes | no |
| Products for signed-in shoppers | no | no |
| Carts, customers, orders, quotes, shopping lists, business units | no | no |
Catalog keys include the store key, locale, and currency, so one store’s answer never serves another. Products for a signed-in shopper skip the shared cache because their prices depend on the shopper’s customer group and business unit. Uncached loaders still batch and remember within the request.
A few more things to know:
- Nothing invalidates these entries. A change in commercetools shows up when the entry expires, up to 5 minutes later, plus the local ttl where one applies.
- Not found is cached too. A lookup that finds nothing is stored like a value, so a product created a minute ago can stay not found until the entry expires.
- A signed-in shopper’s details are cached for 5 minutes, keyed by the store, the customer, and the token. A new token, after a refresh or a business-unit switch, gets a fresh entry.
- The store record is kept for the life of the process. Its channels are read once per store key and not again until the service restarts.
The CMS packages use the same cache, and the Contentful package does
invalidate. Its webhook clears the cms:layout namespace when a layout entry
changes and cms:dataloaders when anything else does, so a publish does not
wait for the ttl. The layout’s local tier on other instances can still answer
for its 5 seconds.
Your own loaders
Section titled “Your own loaders”Declare them with defineDataLoaders and set them as your module’s
dataLoaders. Give the shared cache a namespace and a key that names
everything the answer varies by. The commercetools store context’s
cacheKeyPrefix covers the store key, locale, and currency. The framework’s
own loaders are built with DataLoaderCache from
@labdigital/dataloader-cache-wrapper, a DataLoader that reads and writes a
Keyv store:
import type { StoreContext } from "@evolve-framework/commercetools";import { cache } from "@evolve-framework/core/cache";import { defineDataLoaders } from "@evolve-framework/core/utils";import { DataLoaderCache } from "@labdigital/dataloader-cache-wrapper";
type BrandLoaderContext = { storeContext: StoreContext };
export const brandLoaders = defineDataLoaders<BrandLoaderContext>()({ brandById: (ctx) => new DataLoaderCache<string, Brand | null>(fetchBrandsById(ctx), { maxBatchSize: 50, cache: { ttl: 5 * 60_000, storeFn: () => cache.getStore<Brand | null>("brands:dataloaders"), }, cacheKeyFn: (id) => `brand:${ctx.storeContext.cacheKeyPrefix}:id:${id}`, }),});Leave out the cache option for data that is personal or must be fresh on
every request. The loader then still batches and remembers within one
request.
In the storefront
Section titled “In the storefront”The storefront caches at four levels, from the visitor inward:
| Level | Holds | For how long | Shared |
|---|---|---|---|
| CDN | Whole pages, for visitors without a session | 15 minutes, then stale for up to 30 days while it refetches | By every visitor |
| Next.js data cache | The answer of each server-side GraphQL fetch | Per fetch, 5 to 60 minutes | By every instance, through Redis |
React cache() |
The fetches of one server render | One render | No |
| React Query | The browser’s fetches | Fresh for 60 seconds | No, per browser tab |
The values in this section are the reference sites’. They live in your
project’s code, so change them there. The @evolve-storefront/* packages
cache nothing on the server. Their data hooks fetch through your app’s GraphQL
fetcher and React Query client.
flowchart TB shopper((Shopper)) --> cdn[CDN<br/>hit for a guest] cdn -- miss, or a session cookie --> render[Next.js render] render --> rcache["React cache()<br/>once per render"] rcache --> data[(Data cache in Redis<br/>tags: product_detail_page, slug)] data -- miss --> gateway[Gateway] gateway --> service[Subgraph] service --> sredis[(Service cache in Redis)] sredis -- miss --> vendor[Commerce engine / CMS]
Pages for visitors without a session
Section titled “Pages for visitors without a session”next.config.ts gives every response a shared-cache header, unless the request
carries a session cookie:
async headers() { return [ { source: "/:path*", missing: [ { type: "cookie", key: "userToken" }, { type: "cookie", key: "userData" }, ], headers: [ { key: "Cache-Control", value: "s-maxage=900, stale-while-revalidate=2592000", }, ], }, { source: "/graphql/:path*", headers: [{ key: "Cache-Control", value: "no-store" }], }, ];},- A visitor without a session gets a page a CDN may keep for 15 minutes, and serve stale for up to 30 days while it fetches a fresh one.
- A signed-in visitor sends
userTokenoruserData, so the rule does not apply and the CDN does not share their page. - The session refresh route at
/graphql/authis never cached. A visitor is sent there exactly when the session cookies are missing, so the first rule matches it too. The second rule comes later and wins. Without it, a shared cache could hand one visitor’s renewed session to another.
Separate the directives with a comma. A shared cache does not read
s-maxage from a value that uses a semicolon.
The reference sites copy .next/static to a static file store when the
container starts, with Cache-Control: public, max-age=31536000, immutable.
The build ID is SERVICE_VERSION, so a new release gets new asset URLs.
Server-side fetches
Section titled “Server-side fetches”Each server-side GraphQL fetch states whether Next.js may cache its answer, for how long, and under which tags:
return serverClient( GetProductDetailPage, { slug, selectedSku: sku }, { cache: authHeaders ? "no-cache" : "force-cache", next: { revalidate: 900, tags: ["product_detail_page", slug, sku, locale].filter( (item): item is string => Boolean(item), ), }, },);The rule is the same as in the services: shared data is cached, personal data
is not. A fetch with the session headers, or in draft mode for a CMS preview,
uses no-cache, because its answer depends on who asks. The fetches in the
reference sites:
| Data | Revalidates after | Tags |
|---|---|---|
| Header, footer, navigation | 60 minutes | layout, and siteHeader, siteFooter, or site |
| Content page | 15 minutes | content-page, the path, and <locale>/<path> |
| Product page | 15 minutes | product_detail_page, the slug, the SKU, the locale |
| Product listing on a category page | 15 minutes | product_category_page, the filters |
| Search results | 5 minutes | product_search, the filters |
| Content snippet | 15 minutes | content_snippet, the key, the locale |
Each of these functions is wrapped in React’s cache(), so a page and its
generateMetadata share one fetch per render.
Where the data cache lives
Section titled “Where the data cache lives”next.config.ts points cacheHandler at cache-handler.mjs. With REDIS_URL
set, it stores the data cache in Redis under the nextjs: prefix, through
@trieb.work/nextjs-turbo-redis-cache, and traces each read and write. Without
REDIS_URL, it falls back to Next.js’ own cache on the file system, and logs a
warning at startup.
Run more than one instance only with Redis. On the file system, each instance has its own cache, and a revalidation reaches only the instance that received it.
When content changes
Section titled “When content changes”A publish in the CMS reaches the storefront as an event:
- The CMS calls the webhook of your CMS service. The Contentful and Storyblok packages drop their own cached entries first (see What the engines cache), so nothing that reacts to the event reads the old content.
- The service publishes a
content-modified-event:contentType: "layout"for the header, footer, or navigation, orcontentType: "page"with the changed paths. - Your message infrastructure delivers the event to the storefront’s
POST /api/events, as plain JSON or in a CloudEvents envelope. - The route calls
revalidateTag("layout")for a layout change, andrevalidateTag(path)for each changed page path, which matches the tags on the content page fetches. - It passes the page paths to
invalidatePaths()insrc/lib/cdn-invalidation.ts, to purge the CDN.
invalidatePaths() is empty in the reference sites. Until you implement it
for your CDN, a page the CDN holds updates when its 15 minutes run out.
The route answers 200 even when handling the event fails, so the sender does not retry. Check its logs when content does not update.
flowchart TB publish[CMS publish] --> webhook[CMS service webhook<br/>clears its Redis keys] webhook --> bus[(Message bus)] bus --> events[Storefront<br/>POST /api/events] events --> revalidate["revalidateTag()"] revalidate --> next[Next request refetches<br/>through the gateway] revalidate -. not implemented yet .-> purge[CDN purge]
When the catalog changes
Section titled “When the catalog changes”Nothing revalidates a product page early. The events route accepts a
product-modified-event and does nothing with it yet. A change to a product
or price shows up after the service’s cache expires (up to 5 minutes), then
the data cache (15 minutes), then the CDN (15 minutes, and longer while it
serves stale). A signed-in shopper skips all three, because products for a
signed-in shopper are not cached in the service either.
To show catalog changes sooner, revalidate the product’s tags from that event,
or lower the revalidate values.
In the gateway
Section titled “In the gateway”The gateway keeps no response cache: every operation reaches the subgraphs. What makes repeated requests cheap is the caching in the storefront and the services above.
The gateway can restrict which operations it runs to a published list of persisted documents. This is about trust and request size, not about caching answers:
- With
PERSISTED_DOCUMENTS_ENABLED=true, it accepts only operations that are registered in the Hive registry, and finds each by itsdocumentId. It is off by default. - The storefront’s codegen writes
src/generated/persisted-documents.json, and the deploy publishes it to Hive. - The storefront sends
<app name>~<app version>~<hash>as the document id. The feature packages’ documents carry no id, so the app computes one with@evolve-storefront/persisted-documents. - A request with the
X-TrustedDocument-BypassSecretheader may send any operation, if it matchesTRUSTED_DOCUMENT_BYPASS_SECRET. Without that variable, nothing can bypass the list.
The gateway compresses responses of 1 KiB and larger, when the client accepts it.

