Caching Source Maps in Redis for Fast Symbolication
An in-process consumer cache dies with the process: every new pod, every cold start, and every deploy re-fetches and re-parses the same popular .map file from scratch, and a fleet of ten replicas parses the newest release ten times over. This how-to moves the expensive work into a shared Redis tier keyed by release and file, so the first worker to see a release pays the parse cost once and every other worker — now and after the next restart — reads a pre-decoded index straight out of memory. It extends Server-Side Symbolication at Scale with a cross-process caching layer, sits inside the broader Source Map Generation & Stack Trace Debugging pipeline, and complements the single-process technique in Caching Parsed Source Maps for Faster Symbolication; if you are still assembling the resolution loop itself, start from Symbolicating Stack Traces in a Node CLI Script.
Symptom / Trigger
The tell is that symbolication latency and object-store egress both spike in lockstep every time you ship, and they spike on every replica at once rather than settling after the first request. A raw event arrives carrying a minified V8 stack that must be resolved:
TypeError: Cannot read properties of undefined (reading 'id')
at t (https://cdn.example.com/app.4f9a1b2c.js:1:48312)
at https://cdn.example.com/app.4f9a1b2c.js:1:22107
Look at the per-worker log and you see the same release being fetched and decoded independently across the fleet, with hundreds of milliseconds of parse time repeated pod by pod:
pod-a [symbolicate] MISS sm:9f3c1a:app.4f9a1b2c.js fetch 41ms decode 380ms
pod-b [symbolicate] MISS sm:9f3c1a:app.4f9a1b2c.js fetch 39ms decode 372ms
pod-c [symbolicate] MISS sm:9f3c1a:app.4f9a1b2c.js fetch 44ms decode 391ms
pod-a [symbolicate] MISS sm:9f3c1a:app.4f9a1b2c.js fetch 40ms decode 377ms ← same pod, post-restart
Every line marked MISS for a release that shipped an hour ago is wasted work: the map is immutable, yet the fleet keeps rebuilding the exact same lookup table from the exact same bytes. The pattern is easy to miss in a single-process benchmark because one warm process eventually stops missing; it only becomes obvious once you aggregate the log across every replica and notice that the miss count never converges toward zero the way it should for a release that stopped changing the moment it shipped.
Root Cause Explanation
The decode cost lives entirely in process-local memory, so it cannot be amortised across the machines that actually share the load. A SourceMapConsumer holds its decoded mappings inside a WebAssembly heap that is not serialisable and cannot cross a process — let alone a network — boundary, which means the naive server keeps the only thing worth sharing in the one place nothing else can reach it.
// WRONG — each process/replica re-fetches and re-decodes the same immutable map
async function symbolicate(event, frame) {
const raw = await fetchRawMap(event.release, frame.file); // network round trip
const consumer = await new SourceMapConsumer(raw); // full VLQ decode, WASM heap
const pos = consumer.originalPositionFor(frame); // the only cheap line here
consumer.destroy();
return pos; // consumer is thrown away; the next event and the next pod repeat it all
}
The originalPositionFor call is a binary search over already-decoded data and costs microseconds; the fetchRawMap and new SourceMapConsumer steps cost tens to hundreds of milliseconds and are identical for every event that shares a release and file. The fix is to run the fetch-and-decode exactly once per (release, file) across the whole system, store the decoded result somewhere every worker can read cheaply, and reduce the per-event work to a lookup. Redis is the natural home for that shared result: it is already in most production stacks, it survives process restarts, and a single key holds the pre-decoded index that all replicas read.
Step-by-Step Fix
The plan is to store not the raw map and not the WASM consumer, but a compact, sorted array of mappings that serialises to JSON and supports a direct binary search — so a cache hit skips both the network fetch and the VLQ decode. Each step below is independently useful, but the payoff only lands when all six are in place: the key scheme makes entries shareable, the flattening step makes them serialisable, and the tiered read collapses the common case to almost nothing.
- Install a Redis client and the source-map library.
# ioredis is the async client; source-map 0.7.x gives the async WASM consumer we decode with
npm install ioredis [email protected]
- Connect to Redis and derive a stable key from release plus file.
// cache.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL); // e.g. redis://cache:6379
// One entry per immutable (release, file) pair — a deploy mints a fresh key
function mapCacheKey(release, file) {
return `sm:${release}:${file}`; // sm:9f3c1a:app.4f9a1b2c.js
}
- Decode a raw map once into a flat, sorted tuple array, then free the WASM heap.
const { SourceMapConsumer } = require('source-map');
// Flatten the parsed map into rows sorted by generated position, then discard the consumer
async function buildIndex(rawMap) {
const consumer = await new SourceMapConsumer(rawMap); // await is mandatory in 0.7.x
const rows = [];
consumer.eachMapping((m) => {
// [genLine, genCol, source, origLine, origCol, name] — everything a lookup needs
rows.push([m.generatedLine, m.generatedColumn, m.source, m.originalLine, m.originalColumn, m.name || null]);
}, null, SourceMapConsumer.GENERATED_ORDER); // iterate in generated order so rows stay sorted
consumer.destroy(); // release the WASM heap immediately — the rows are all we keep
return rows;
}
- Read the index from Redis, or build and store it with a TTL on a miss.
// getIndex populates Redis exactly once per release+file across the whole fleet
async function getIndex(release, file, fetchRawMap) {
const key = mapCacheKey(release, file);
const cached = await redis.get(key);
if (cached) return JSON.parse(cached); // HIT: no object-store fetch, no VLQ decode
const rawMap = await fetchRawMap(release, file); // MISS: pull the .map exactly once
const rows = await buildIndex(rawMap);
await redis.set(key, JSON.stringify(rows), 'EX', 86400); // 24h TTL; a release is immutable
return rows;
}
- Resolve a generated position against the sorted index with a binary search.
// rows are sorted by (generatedLine, generatedColumn); find the last mapping at or before the query
function originalPositionFor(rows, line, column) {
let lo = 0, hi = rows.length - 1, best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const [gl, gc] = rows[mid];
if (gl < line || (gl === line && gc <= column)) { best = mid; lo = mid + 1; }
else hi = mid - 1;
}
if (best === -1) return null; // position precedes the first mapping — leave the frame raw
const [, , source, origLine, origCol, name] = rows[best];
return { source, line: origLine, column: origCol, name };
}
- Add a tiny in-process hot cache so repeat hits skip even the JSON parse.
const hot = new Map(); // release+file -> already-parsed rows for this process
// L1 (process memory) in front of L2 (Redis) — the JSON.parse cost is paid once per pod
async function getIndexCached(release, file, fetchRawMap) {
const key = mapCacheKey(release, file);
if (hot.has(key)) return hot.get(key); // L1 hit: zero I/O, zero parse
const rows = await getIndex(release, file, fetchRawMap); // L2 hit or cold build
hot.set(key, rows);
return rows;
}
The three tiers form a strict ladder: the process-local hot map answers the common case with no work at all, Redis answers a cold process with a single round trip and one JSON parse, and the object store is touched exactly once for the entire lifetime of a release. Only the deepest miss pays the VLQ decode, and only one worker in the fleet ever reaches it.
Verification
Prove two things: that a hit resolves the known coordinate correctly, and that the hit path never touches the object store. The trick is to pass a fetchRawMap that throws on the second call — if it runs, the assertion fails loudly.
// verify.js — run with: node verify.js
const assert = require('node:assert/strict');
const { getIndex, getIndexCached, originalPositionFor } = require('./cache');
async function main() {
let fetches = 0;
const fetchRawMap = async () => { fetches++; return require('fs').readFileSync('fixtures/app.js.map', 'utf-8'); };
// Cold build populates Redis and resolves the known fixture coordinate
const rows = await getIndexCached('9f3c1a', 'app.4f9a1b2c.js', fetchRawMap);
const pos = originalPositionFor(rows, 1, 48312);
assert.ok(pos.source.endsWith('Button.tsx'), 'col 48312 must map to Button.tsx');
// A fresh call with a throwing fetcher must be served from Redis, never the store
const guard = () => { throw new Error('fetchRawMap ran on a hit!'); };
await getIndex('9f3c1a', 'app.4f9a1b2c.js', guard);
assert.equal(fetches, 1, 'expected exactly one object-store fetch for the release');
console.log('OK — resolved from cache with a single upstream fetch');
}
main().catch((e) => { console.error(e); process.exit(1); });
A passing run prints the OK line and exits zero. If you instead see fetchRawMap ran on a hit!, the Redis write in step 4 was skipped or the key differs between calls — check that release and file are byte-for-byte identical on both paths, because a trailing query string or a leading slash on the filename silently forks the key and turns every event back into a miss.
Edge Cases & Gotchas
- A giant map can exceed a comfortable value size. A five-megabyte
mappingsstring flattens into an index that may run tens of megabytes as JSON. Keep individual values well under Redis’s 512 MB hard limit, and if a single map is unusually large, store it compressed (gzip the JSON beforeSET, gunzip afterGET) or shard the index by generated line range across several keys. - Never reuse a key across releases. The key must include the full release identifier, not just the filename. If two deploys ship the same bundle name with different content and you key on the filename alone, a stale index resolves frames to the wrong source line — a failure that looks like success. A content hash in the filename plus the release SHA in the key eliminates the collision.
- Set a TTL and let it lag the deploy cadence. A 24-hour expiry reclaims memory for retired releases automatically, but if you deploy hourly you will accumulate many live keys; either shorten the TTL or evict a release’s keys from your deploy pipeline so Redis memory tracks the number of active releases, not the number you have ever shipped.
- JSON parse is cheap but not free. On a cold process a multi-megabyte index still costs a
JSON.parse; that is why step 6 exists. Without the in-processhotmap, a busy pod re-parses the same index on every event and hands most of the win back.
FAQ
Why cache a flattened index instead of the raw .map file in Redis?
Storing the raw map skips only the network fetch; each worker still runs the full VLQ decode through the WASM consumer on every cold start, which is the expensive half. Storing the pre-decoded, sorted tuple array skips both the fetch and the decode — a hit reduces to a GET, a JSON.parse, and a binary search. The trade is a larger value in Redis, since the flattened index is bigger than the compact mappings string, in exchange for eliminating the CPU that actually dominated your latency graph.
Can I store the SourceMapConsumer itself in Redis to avoid rebuilding it?
No. A consumer’s decoded state lives in a WebAssembly heap that is not serialisable and is bound to the process that created it, so it cannot be written to Redis or shared across workers. That constraint is exactly why the flattened index exists: it captures the same lookup information in a portable, JSON-friendly shape. The in-process consumer approach is the right tool within a single long-lived process and is covered in the local caching how-to linked below; Redis is the tool for sharing across processes and surviving restarts.
How do I stop a thundering herd when a new release lands?
Right after a deploy, many workers can miss the same cold key at once and each start a fetch-and-decode. Guard the build with a short-lived Redis lock: SET sm:lock:{release}:{file} 1 NX EX 30 before building, and if the NX set fails, wait briefly and re-read the value key. The lock holder populates the index while the others poll for it, so the object store sees one fetch and the fleet does one decode even under a synchronised burst.
Related
- Server-Side Symbolication at Scale — the backend symbolication service this Redis tier plugs into
- Caching Parsed Source Maps for Faster Symbolication — the in-process consumer cache that pairs with this shared layer
- Symbolicating Stack Traces in a Node CLI Script — the single-map resolution loop this builds on
- Source Map Generation & Stack Trace Debugging — parent reference for the full source map pipeline