Skip to content

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:

  1. Per request: a data loader batches the lookups a request makes and answers a repeated key from memory.
  2. Local tier: for near-static data, an in-process memory in front of the shared cache.
  3. 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 ttl

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 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:// or rediss://: 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.

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.

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.

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.

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.

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.

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]

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 userToken or userData, so the rule does not apply and the CDN does not share their page.
  • The session refresh route at /graphql/auth is 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.

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.

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.

A publish in the CMS reaches the storefront as an event:

  1. 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.
  2. The service publishes a content-modified-event: contentType: "layout" for the header, footer, or navigation, or contentType: "page" with the changed paths.
  3. Your message infrastructure delivers the event to the storefront’s POST /api/events, as plain JSON or in a CloudEvents envelope.
  4. The route calls revalidateTag("layout") for a layout change, and revalidateTag(path) for each changed page path, which matches the tags on the content page fetches.
  5. It passes the page paths to invalidatePaths() in src/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]

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.

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 its documentId. 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-BypassSecret header may send any operation, if it matches TRUSTED_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.