All posts
Forward Development

Medusa 2.19 cache invalidation can OOM your API container

Why Medusa 2.19.0's caching module runs unbounded invalidation on worker_mode: server processes, how to confirm it, and what to change before a migration

  • Medusa.js
  • Migrations
  • Operations

A catalogue sync kicks off through the Admin API. Roughly 90 seconds after boot, the API container is gone:

<--- Last few GCs --->
[20:0x31b25000]    88305 ms: Scavenge (interleaved) 2023.7 (2075.3) -> 2021.1 (2076.5) MB, ...
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Restart it and the same thing happens on the next boot. Before the process dies, unrelated endpoints degrade from about 10 ms to 7.6 s, which reads like a database or connection-pool problem right up until the container disappears. That is the signature of a caching-module invalidation bug reported against @medusajs/medusa / @medusajs/framework 2.19.0 with @medusajs/caching-redis and @medusajs/event-bus-redis at ^2.19.0, on Node v22.18.0 and PostgreSQL 16.2.

The crash is a fan-out, not a leak

Nothing is being retained across requests. The heap fills because each write event triggers a tag-based cache clear, and in the caching-redis provider a tag clear is an SMEMBERS per tag followed by a getBuffer per member key under Promise.all. With a hot list tag such as Product:list:*, that set holds one member per cached list key. A single clear materialises thousands of buffers at once. N concurrent clears materialise N times that, and nothing limits N.

That distinction matters when you are diagnosing. Heap profiles will show live buffers, not a retained graph you can trace back to a leaking closure. Raising --max-old-space-size buys you a longer sync before the same crash.

Clears are dispatched with void, so nothing applies backpressure

CachingModuleService.performCacheClear loops over providers and calls void provider.clear({ key, tags, options }). performCacheSet does the same with provider.set(...). Because of the void, the await in clear_ resolves before Redis has done any work, and the ongoingRequests coalescing that sits above it can never dedupe — the entry is deleted almost immediately. The one mechanism that would collapse duplicate work never fires, and there is no queue depth anywhere in the path to slow the producer down.

The invalidation handler runs on the process that emits the event

DefaultCacheStrategy.onApplicationStart registers the handler twice:

eventBus.subscribe("*", handleEvent);
eventBus.addInterceptor?.(handleEvent);

subscribe only does work where the BullMQ worker exists — event-bus-redis builds a worker only if (this.isWorkerMode). Interceptors are different: they run wherever an event is emitted. RedisEventBusService.emit() calls this.callInterceptors(...) for each event, releaseGroupedEvents() does the same, and AbstractEventBusModuleService.callInterceptors maps over handlers without awaiting them.

So a process configured with projectConfig.workerMode: "server" — documented as not processing events — runs the full invalidation for every event it emits, meaning every write served by the Admin or Store API, while it is also serving HTTP traffic.

This is the part that misleads people. Splitting server and worker containers is the right architecture and we would recommend it regardless, but it does not dodge this bug. In a split, every event is still queued to BullMQ whenever a matching subscriber exists, and the strategy's own subscribe("*") guarantees a match — so each event gets invalidated twice, once in-process on the server via the interceptor and once on the worker via the queue. Setting workerMode: "server" is not a mitigation here.

Half-finished clears make every later clear more expensive

caching-redis defaults to a 5 s commandTimeout. Under a burst, the clears exceed it. The timeout is caught by isConnectionError and returned as a no-op, so invalidation aborts half done: stale entries stay cached, and their key names remain as orphaned members of the tag set. Every subsequent clear of that tag now reads a larger set, so the problem compounds run over run.

If you are chasing stale product or price data on a staging box that has been bulk-loaded a few times, this is a strong candidate. The correctness symptom and the memory symptom have the same root.

Confirming it is this bug takes one bulk write

Run a bulk write against the Admin API — an import, a price sync, a migration backfill — against a single container with workerMode: "server" and the caching module registered with the Redis provider. The reported reproduction was roughly 10k products updated through the Admin API. Watch for:

  • Heap climbing monotonically for the duration of the burst, with GC lines that barely recover between scavenges.
  • Event-loop starvation showing up on endpoints unrelated to the write path, not just on the writes themselves.
  • Redis traffic dominated by SMEMBERS plus buffer reads against tag sets, rather than by writes.
  • Command timeouts at the 5 s mark, followed by stale reads afterward.
  • Memory tracking event volume rather than row count — grouped events released at the end of a workflow land in one burst.

The decisive test: stop the worker containers entirely and run the burst with only the API container up. If the heap still climbs, invalidation is running in a process the docs say does not process events.

There is no supported off switch in 2.19.0

The caching feature flag only gates whether anything is stored (useCache, @Cached); onApplicationStart wires invalidation unconditionally. A custom strategy would be the natural escape hatch, but it is disabled in loaders/providers.js:

const strategy = strategy_1.DefaultCacheStrategy; // Re enable custom strategy another time

That leaves two options. Not registering the caching module at all silences it and gives up caching. Or patch it. The issue includes a patch-package sketch against 2.19.0: await provider.clear(...) in performCacheClear instead of void; drop the addInterceptor registration as redundant with the * subscriber; accumulate tags from events into a deduplicating Set drained by one flush at a time in batches. Tags across a sync are reported to be nearly all duplicates, so tens of thousands of events collapse into a handful of clears and the sync runs flat.

The report does not name a release that fixes this. Don't plan a version bump as your remediation without first checking the issue for a landed fix and the release it shipped in.

What to change before a cutover

A migration cutover is a bulk write by definition — the backfill is the exact workload that triggers this. If you are moving a Magento 2 catalogue onto Medusa, plan around it rather than discovering it during go-live rehearsal. Our approach on Magento 2 to Medusa migrations is to rehearse the backfill at full production volume on the target infrastructure, because problems like this only appear at real event counts.

Concretely:

  • Split server and worker containers, but treat it as architecture, not as a fix for this.
  • Decide your caching posture for the backfill window explicitly. Running the backfill with the caching module unregistered, then registering it and warming afterward, is a legitimate cutover plan.
  • If you keep the module registered, apply the patch and verify heap stays flat under a rehearsal burst before you trust it.
  • Raise commandTimeout above the 5 s default so clears are less likely to be silently no-op'd into stale-cache-plus-orphaned-tag-members territory.
  • Pin @medusajs/* versions across the stack and re-run the rehearsal after any bump.

If your Medusa API container is dying under imports and you cannot tell whether this is the cause, get in touch — the reproduction above is usually enough to answer it in an afternoon, and the answer changes your cutover plan.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us