Caching Parsed Source Maps for Faster Symbolication

SourceMapConsumer construction is expensive — it reads a .map file from disk, parses the JSON, and compiles a WASM binary on first use — so constructing a new consumer for every inbound error event crushes throughput in any log enrichment daemon. This page explains how to cache and reuse consumers per release, bound memory with an LRU eviction policy, and call destroy() correctly on eviction, building on Local Symbolication with Mozilla source-map Library and fitting into the Source Map Generation & Stack Trace Debugging pipeline.

LRU cache lifecycle for SourceMapConsumer instances Diagram: an incoming error event checks the LRU cache by map key. On a hit, the consumer is returned directly. On a miss, a new consumer is constructed from disk, inserted into the cache (possibly evicting the LRU entry which calls destroy), and then returned. Error Event mapKey = hash cache hit? yes Return consumer promote to MRU no Construct readFile + JSON parse await new Consumer() Insert LRU if at capacity: evict LRU entry evicted.destroy() free WASM heap LRU capacity = N releases · each consumer ≈ 20–100 MB WASM heap

Symptom / Trigger

A Node.js log enrichment worker symbolicates inbound error events and starts exhibiting these symptoms under sustained load:

# heap usage grows monotonically over time
Heap used: 320 MB  (after 1 000 events)
Heap used: 640 MB  (after 2 000 events)
Heap used: 980 MB  (after 3 000 events)
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Alternatively, the p95 symbolication latency is unacceptable even though lookups themselves are fast:

event 1:  symbolicate duration 420 ms  ← construction cost paid every time
event 2:  symbolicate duration 415 ms
event 3:  symbolicate duration 418 ms

Both patterns indicate that a new SourceMapConsumer is being constructed for every event.

Root Cause Explanation

The broken pattern allocates a fresh consumer per error and never releases WASM memory:

// WRONG — constructs and leaks a consumer for every single error event
async function handleError(event) {
  const raw = await fs.readFile(`maps/${event.buildHash}.js.map`, 'utf-8');
  const consumer = await new sourceMap.SourceMapConsumer(raw); // 20–100 MB WASM allocation
  event.frames = event.frames.map(f =>
    consumer.originalPositionFor({ line: f.line, column: f.column })
  );
  // consumer.destroy() never called → WASM heap never released
}

Each SourceMapConsumer holds a block of WASM linear memory. The size depends on the number of mappings — a typical production bundle with 50,000 mappings occupies roughly 20–60 MB. At 100 events per second without destruction or reuse, the process allocates multiple gigabytes within seconds.

Step-by-Step Fix

1. Install an LRU cache package

A simple Map grows unboundedly. Use an LRU implementation that evicts the least-recently-used entry when capacity is reached and fires a callback where you can call destroy().

npm install lru-cache   # provides LRUCache class with dispose callback

The lru-cache package (v10+) accepts a dispose option that fires synchronously whenever an entry is evicted, giving you a reliable hook to call consumer.destroy().

2. Create the cache module

// lib/consumerCache.js
'use strict';

const sourceMap = require('source-map');
const fs = require('fs/promises');
const path = require('path');
const { LRUCache } = require('lru-cache');

// Register WASM once — safe to call multiple times, subsequent calls are no-ops
sourceMap.SourceMapConsumer.initialize({
  'lib/mappings.wasm': path.join(
    __dirname,
    '../node_modules/source-map/lib/mappings.wasm'
  )
});

// LRU bounded to 5 concurrent consumers — tune based on your release cadence
const cache = new LRUCache({
  max: 5,
  dispose: (consumer, key) => {
    consumer.destroy(); // free WASM heap on eviction — this is the critical line
    console.debug(`[symbolication] evicted consumer for ${key}`);
  }
});

/**
 * Returns a cached SourceMapConsumer for the given .map file path.
 * Constructs a new consumer on cache miss, evicting LRU entry if at capacity.
 */
async function getConsumer(mapFilePath) {
  const key = path.resolve(mapFilePath); // normalise to absolute path as cache key

  if (cache.has(key)) {
    return cache.get(key); // cache hit — O(1), no disk I/O
  }

  // Cache miss — read file and construct consumer (~20–100 ms)
  const raw = await fs.readFile(key, 'utf-8');
  const consumer = await new sourceMap.SourceMapConsumer(raw); // must await

  cache.set(key, consumer); // set triggers eviction if cache.size === max
  return consumer;
}

module.exports = { getConsumer };

3. Use the cache in the error enrichment handler

// lib/enrich.js
'use strict';

const { getConsumer } = require('./consumerCache');

async function enrichFrames(frames, mapFilePath) {
  const consumer = await getConsumer(mapFilePath); // fast on cache hit

  return frames.map(frame => {
    const pos = consumer.originalPositionFor({
      line: frame.line,      // 1-based from Error.stack
      column: frame.column   // 0-based from Error.stack
    });

    if (pos.source === null) {
      return { resolved: false, ...frame };
    }

    return {
      resolved: true,
      source: pos.source,
      line: pos.line,
      column: pos.column,
      name: pos.name ?? null
    };
  });
}

module.exports = { enrichFrames };

Notice that enrichFrames does not call consumer.destroy() — that is now the cache’s responsibility via the dispose callback. Calling destroy() manually while the consumer is still cached would corrupt subsequent lookups.

4. Tune the LRU capacity for your release cadence

The right max value depends on how many releases are simultaneously live in your error stream. A blue-green deployment with two active releases needs max: 2. A canary rollout strategy with rolling releases over 24 hours might need max: 10.

// Derive max from the number of concurrent release slots your deployment uses
const CONCURRENT_RELEASES = parseInt(process.env.SYMBOLICATION_CACHE_SIZE || '5', 10);

const cache = new LRUCache({
  max: CONCURRENT_RELEASES,
  dispose: (consumer, _key) => consumer.destroy()
});

Each cached consumer holds roughly 20–100 MB of WASM memory depending on bundle size. With max: 5 and 60 MB average consumers, peak WASM memory is capped at 300 MB.

5. Add a manual flush for graceful shutdown

When the process receives SIGTERM, evict all consumers before exit to release WASM memory cleanly:

// Call this in your SIGTERM / SIGINT handler
function flushConsumerCache() {
  cache.clear(); // triggers dispose() for every remaining entry → all destroy() called
}

process.on('SIGTERM', () => {
  flushConsumerCache();
  process.exit(0);
});

LRUCache.clear() fires the dispose callback for each entry synchronously, so all WASM allocations are freed before the process exits.

Verification

Measure heap behaviour before and after applying the cache. This script processes 200 synthetic events against the same .map file and prints heap usage before and after:

// bench/heapCheck.js
const { enrichFrames } = require('../lib/enrich');

const MAP_PATH = 'dist/app.js.map';
const FRAME = { line: 1, column: 48312 };
const EVENT_COUNT = 200;

async function run() {
  const before = process.memoryUsage().heapUsed;

  for (let i = 0; i < EVENT_COUNT; i++) {
    await enrichFrames([FRAME], MAP_PATH); // should reuse cached consumer
  }

  const after = process.memoryUsage().heapUsed;
  const deltaMB = ((after - before) / 1024 / 1024).toFixed(1);

  // With caching: delta should be < 5 MB (no new WASM allocations)
  // Without caching: delta would be > 4 000 MB (200 × ~20 MB each)
  console.log(`Heap delta after ${EVENT_COUNT} events: ${deltaMB} MB`);
  console.assert(parseFloat(deltaMB) < 50, 'Heap growth too large — cache may not be working');
}

run().catch(console.error);

Run with node bench/heapCheck.js. A heap delta under 50 MB for 200 events confirms the cache is serving hits. A delta in the gigabyte range indicates the cache is not being reached — check that the mapFilePath argument is consistent across calls (an inconsistent absolute/relative mix produces a cache miss every time).

Deduplicating Concurrent Cold-Cache Requests

When two error events arrive simultaneously for the same map file that is not yet in the cache, both coroutines call getConsumer, both find a cache miss, and both start reading the file and constructing a consumer. The first to finish inserts into the cache; the second then inserts again, immediately evicting the first entry and calling destroy() on it — after which the first coroutine may still hold a reference and attempt to use the now-destroyed consumer.

Guard against this with a pending-promise map:

// lib/consumerCache.js — add pending Promise deduplication
const cache = new LRUCache({ max: 5, dispose: (c) => c.destroy() });
const pending = new Map(); // key → Promise<SourceMapConsumer>

async function getConsumer(mapFilePath) {
  const key = path.resolve(mapFilePath);

  if (cache.has(key)) return cache.get(key); // fast path: cache hit

  if (pending.has(key)) return pending.get(key); // second caller awaits the first

  // First caller: construct and register the pending promise before any await
  const promise = (async () => {
    const raw = await fs.readFile(key, 'utf-8');
    const consumer = await new sourceMap.SourceMapConsumer(raw);
    cache.set(key, consumer); // insert into LRU; may evict+destroy an older entry
    pending.delete(key);      // clear pending entry so future cache hits are direct
    return consumer;
  })();

  pending.set(key, promise); // register before first await — no race window
  return promise;
}

The key invariant is that pending.set(key, promise) executes synchronously before the first await inside the async IIFE. JavaScript’s single-threaded event loop guarantees that no other coroutine can observe the cache-miss state between the pending.set and the first suspension point. All subsequent callers for the same key find the pending entry and await the same promise.

Measuring Cache Effectiveness

Add instrumentation to distinguish hits from misses so you can tune max based on observed eviction rates in production:

// Instrumented version of getConsumer
let hits = 0, misses = 0, evictions = 0;

const cache = new LRUCache({
  max: CONCURRENT_RELEASES,
  dispose: (consumer, key) => {
    evictions++;
    console.debug(`[cache] evict ${key} (total evictions: ${evictions})`);
    consumer.destroy();
  }
});

async function getConsumer(mapFilePath) {
  const key = path.resolve(mapFilePath);
  if (cache.has(key)) {
    hits++;
    return cache.get(key);
  }
  misses++;
  // ... rest of construction logic
}

// Expose metrics for your observability platform
function cacheMetrics() {
  return {
    size: cache.size,
    hits,
    misses,
    evictions,
    hitRatio: hits / Math.max(1, hits + misses)
  };
}

A hitRatio above 0.95 indicates the cache is working correctly for your release cadence. An evictions / misses ratio above 1.0 means entries are being evicted before they have a chance to be reused — increase max. If max cannot be increased due to memory constraints, consider keying on release version string (e.g., a git SHA from a release field in the event payload) rather than full filesystem path, which consolidates cache entries when the same release is served from different CDN paths.

Edge Cases & Gotchas

  • Relative vs. absolute path keys cause spurious misses. ./dist/app.js.map and /home/ci/project/dist/app.js.map both point to the same file but are different strings. Always normalise to absolute paths with path.resolve() before using as a cache key.
  • Do not call destroy() on a consumer that is still in the cache. The dispose callback is the only correct place to call destroy(). Calling it manually while the entry is live poisons the cache — subsequent hits return a destroyed consumer whose originalPositionFor throws.
  • LRUCache from lru-cache v7 and v10 have incompatible APIs. v7 uses new LRU({ max }), v10 uses new LRUCache({ max }). Check your installed version. The dispose callback signature also differs: v7 is dispose(key, value), v10 is dispose(value, key).
  • Multiple concurrent requests for the same cold key can construct duplicate consumers. If two events for the same missing key arrive simultaneously, both will miss the cache and both will start constructing a consumer. Guard with a Promise stored in a pending map if you need strict deduplication — the implementation is shown in the section above.

FAQ

How do I pick the LRU max value in practice?

Count the number of bundle versions that produce errors simultaneously in your production error stream. For most teams this is 2–3: the current release, the previous release (some users haven’t refreshed), and occasionally a canary. Start at max: 5 and monitor the eviction rate. If the eviction rate is high relative to your request rate, increase max. If WASM heap memory is the constraint, decrease it.

What happens to the LRU entry when a deployment retires a release?

Nothing happens automatically — the cache holds the consumer until it is naturally evicted by a newer entry or the process restarts. That is fine: the WASM heap for a retired release is a bounded cost that disappears with the next eviction cycle. If you need proactive cleanup, expose a cache.delete(key) call from a deployment webhook handler.

Can I share one LRUCache instance across worker threads?

No. SourceMapConsumer instances hold WASM memory that is not transferable across threads. Each worker thread must maintain its own cache. Worker threads do not share the main thread’s Map or LRUCache by default anyway.