caching a high-volume commerce platform
date: November 19, 2025
Problem: The commerce API remained the source of truth, but serving every product, category, and content request from an upstream system would make peak traffic slower, more expensive, and dependent on someone else’s rate limits.
Solution: We placed caches at the boundaries where data ownership and freshness requirements changed: the edge, frontend content layer, API gateway memory, and a distributed backend cache.
This cache landscape sat inside a wider commerce replatform delivered through phased launches. The platform had to support legacy and new storefronts during migration, then protect a consolidated commerce engine once every market had moved.
Cache content and commerce data differently
Content can usually tolerate short periods of staleness. Price and stock often cannot. Treating both as generic JSON with one time to live would either serve risky commerce data or throw away most of the available cache value.
The frontend used stale-while-revalidate for content. Fresh entries returned immediately. Stale entries also returned immediately, while the runtime refreshed them in the background. A missing entry blocked on the content API once and then repopulated Redis.
This is the production cache path, shortened only to keep the example readable:
if (now < cached.staleAt) {
return cached.data;
}
waitUntil((async () => {
const freshData = await fetchFn();
if (freshData == null) return;
const revalidateNow = Date.now();
await redis.setex(key, ttl, {
data: freshData,
cachedAt: revalidateNow,
staleAt: revalidateNow + swr * 1000,
});
})());
return cached.data;
Stale content was a deliberate availability mode, not an accidental side effect. When Redis was unavailable, the frontend fetched directly from the content API. Pages became slower and upstream traffic increased, but the storefront still worked.
Share cache state across gateway instances
The API gateway used two levels. L1 lived in each process and responded in roughly 1 to 5 milliseconds. L2 used shared Redis and responded in roughly 10 to 20 milliseconds. A source request usually took 200 to 500 milliseconds depending on the query.
That second level matters when autoscaling adds instances. A new task has no local cache, but it can promote entries from Redis instead of sending a cold burst to the commerce API.
The gateway also put strict time limits around distributed cache operations. Redis should reduce dependency load, not become a new reason for requests to hang:
var fusionCacheBuilder = builder.Services.AddFusionCache()
.WithDefaultEntryOptions(options => options
.SetDuration(TimeSpan.FromMinutes(5))
.SetFactoryTimeouts(TimeSpan.FromMilliseconds(1200))
.SetDistributedCacheTimeouts(
softTimeout: TimeSpan.FromMilliseconds(300),
hardTimeout: TimeSpan.FromMilliseconds(2500),
allowBackgroundDistributedCacheOperations: false));
If Redis failed to connect, the gateway continued with memory-only caching. A distributed cache failure reduced efficiency, but did not have to become a storefront outage.
Invalidation follows the owner of the data
Content publication triggered a signed webhook. The frontend mapped the changed content tag to Redis keys, marked them stale or deleted them, and revalidated framework cache tags.
Commerce changes followed a different route. The commerce engine emitted a product update. A function translated that event into a cache invalidation message on a queue. Gateway consumers removed the L2 entry, while short-lived L1 entries expired naturally.
This avoided broadcasting directly to every running instance. It also gave invalidation retries and failure isolation through the queue. The source system announced change, while each cache retained control over how it became fresh.
Time to live still differed by resource. Category structures and reference data could stay for an hour or a day. Product details used minutes. Price and stock bypassed this cache because stale values would change a purchase decision.
Serialization is part of cache design
The first distributed backend test failed because the default JSON serializer could not reconstruct interface-based objects from the commerce SDK. L1 appeared healthy because it stored live objects. L2 exposed the problem only when another instance read the bytes back.
We fixed it by routing SDK types through the SDK serializer and keeping a fallback serializer for ordinary application types. We also made deserialization failure degrade to a miss instead of taking down the request.
That incident changed the test plan. Warm-cache tests had to cross process boundaries, restart instances, and read entries written by another version. A cache hit is not proven until another process can deserialize it.
Choose what may be stale before choosing a cache
For every resource, write down its owner, acceptable staleness, invalidation trigger, fallback path, and worst failure mode. Only then pick the cache layer and time to live.
Result: The frontend and backend both reached cache hit rates above 98 percent during peak preparation. Hot gateway requests avoided hundreds of milliseconds of upstream latency, while explicit fallbacks kept cache failures from becoming full platform failures.
Those cache layers were later tested as part of the platform’s Black Friday and high-season readiness programme, including sudden frontend spikes and sustained backend gateway traffic.
I designed and built this at rb2.