Symbolicating Source Maps in a Serverless Function
A serverless function is a hostile place to symbolicate stack traces: the container that holds your parsed maps can vanish between requests, every cold start pays the full fetch-and-parse tax, and a naive handler re-downloads the same .map from object storage on every single invocation. This how-to shows how to make symbolication survive that environment by loading maps in a cold-start-aware way and reusing them across warm invocations. It narrows the throughput architecture from Server-Side Symbolication at Scale down to the specific constraints of Lambda-style functions, and it assumes the source map fundamentals covered in Source Map Generation & Stack Trace Debugging. If your reuse strategy needs an eviction policy, pair this with Caching Parsed Source Maps for Faster Symbolication.
Symptom / Trigger
The function works in a unit test and falls over the moment it faces real traffic. Every invocation is slow, the fetch to object storage dominates the duration, and under a burst the function times out entirely before it finishes downloading the map:
REPORT RequestId: 7c1f Duration: 2841.55 ms Billed: 2900 ms Memory: 512 MB
[symbolicate] cold start — module init 190 ms
[symbolicate] GET s3://maps/9f2a/app.4f9a1b2c.js.map — 2410 ms (5.8 MB)
[symbolicate] new SourceMapConsumer — 220 ms
REPORT RequestId: 8d2a Duration: 2795.12 ms Billed: 2800 ms Memory: 512 MB
[symbolicate] GET s3://maps/9f2a/app.4f9a1b2c.js.map — 2402 ms (5.8 MB) ← same map, again
Task timed out after 3.00 seconds (RequestId: a91c)
The tell is the second REPORT line: the container is warm — no cold-start init logged — yet it fetches the identical map a second time. The download cost is being paid per request instead of once per container lifetime.
Two numbers make the diagnosis unambiguous. First, the billed duration is dominated by the GET, not by the parse or the frame resolution, so the function is effectively a slow proxy to object storage. Second, that GET line reappears with an identical byte count across invocations of the same container, which means nothing is being reused between calls. When you see fetch cost repeat on a container that already served a request, the problem is always lifetime scope, never symbolication speed.
Root Cause Explanation
The map is being loaded inside the handler, so its lifetime is scoped to a single invocation instead of to the container. A serverless platform keeps a container warm and reuses it for subsequent requests, but only module-scope state survives that reuse; anything created inside the handler body is rebuilt every call.
// WRONG — the consumer is born and dies inside the handler, so warm reuse is impossible
exports.handler = async (event) => {
const raw = await fetchMapFromS3(mapKey(event)); // full download EVERY invocation
const consumer = await new SourceMapConsumer(raw); // full parse EVERY invocation
const frames = symbolicate(consumer, event);
consumer.destroy();
return frames; // container stays warm, cache does not
};
Because consumer is a local, the warm container gains nothing: the next request re-enters handler, the local is gone, and the multi-megabyte fetch runs again. On top of that, a cold start under concurrent load spins up several containers at once, and each one independently stampedes object storage for the same popular map — the download tail latency, not the symbolication, is what pushes you into timeouts.
Step-by-Step Fix
The fix layers three caches, each cheaper than the one beneath it: an in-memory map that a warm container answers from instantly, a /tmp disk copy that a recycled container can parse without touching the network, and object storage as the authoritative fallback. Move all reusable state to module scope, load maps through a promise-cached loader that dedupes concurrent misses, and spill parsed maps to the function’s writable /tmp so even a fresh container can skip the network on its second map. The steps below build those layers from the outside in.
- Initialise the WASM module and declare the cache in module scope, outside the handler.
// symbolicate.mjs — module scope runs ONCE per container, then survives warm reuse
import { SourceMapConsumer } from 'source-map';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const wasm = path.join(path.dirname(fileURLToPath(import.meta.url)),
'node_modules/source-map/lib/mappings.wasm');
await SourceMapConsumer.initialize({ 'lib/mappings.wasm': wasm }); // top-level await, once
const warmConsumers = new Map(); // mapKey -> SourceMapConsumer, lives across warm calls
- Wrap map loading in a promise cache so concurrent cold-start invocations fetch each map at most once.
const inflight = new Map(); // mapKey -> Promise<SourceMapConsumer>, collapses the stampede
function loadConsumer(mapKey) {
if (warmConsumers.has(mapKey)) return warmConsumers.get(mapKey); // warm hit, no await
if (inflight.has(mapKey)) return inflight.get(mapKey); // coalesce parallel misses
const build = buildConsumer(mapKey).finally(() => inflight.delete(mapKey));
inflight.set(mapKey, build); // registered BEFORE any await — no race window on the event loop
return build;
}
- Build a consumer from a
/tmpcopy first, falling back to object storage only when the local disk is cold.
import { readFile, writeFile } from 'node:fs/promises';
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
const s3 = new S3Client({}); // region resolved from the function's own environment
const tmpPath = (mapKey) => path.join('/tmp', mapKey.replace(/\//g, '_')); // flat /tmp filename
async function buildConsumer(mapKey) {
let raw;
try {
raw = await readFile(tmpPath(mapKey), 'utf-8'); // /tmp persists across warm calls
} catch {
const res = await s3.send(new GetObjectCommand({ Bucket: process.env.MAP_BUCKET, Key: mapKey }));
raw = await res.Body.transformToString('utf-8'); // network only on a truly cold disk
await writeFile(tmpPath(mapKey), raw).catch(() => {}); // best-effort spill, ignore ENOSPC
}
const consumer = await new SourceMapConsumer(raw); // await is mandatory in 0.7+
warmConsumers.set(mapKey, consumer); // promote into the warm map
return consumer;
}
- Keep the handler thin — it derives the key, resolves frames against the shared consumer, and never owns lifecycle.
export const handler = async (event) => {
const body = typeof event.body === 'string' ? JSON.parse(event.body) : event; // API GW or direct
const mapKey = `maps/${body.release}/${body.stack.match(/([\w.]+\.js)/)[1]}.map`;
const consumer = await loadConsumer(mapKey); // warm hit ≈ 0 ms, cold disk ≈ one fetch
const frames = resolveFrames(consumer, body.stack); // per-event binary searches, cheap
return { statusCode: 200, body: JSON.stringify({ frames }) }; // consumer stays cached, no destroy
};
- Bound the warm map count so a container handling many releases cannot grow its WASM heap without limit.
const MAX_WARM = Number(process.env.MAX_WARM_MAPS ?? 12); // sized to memory, not request rate
function promote(mapKey, consumer) {
warmConsumers.set(mapKey, consumer);
if (warmConsumers.size > MAX_WARM) {
const oldest = warmConsumers.keys().next().value; // Map preserves insertion order = FIFO
warmConsumers.get(oldest).destroy(); // free WASM heap before dropping the ref
warmConsumers.delete(oldest);
}
}
The promote helper keeps that warm map from becoming its own leak. A single container that lives for hours and sees traffic for a dozen releases would otherwise accumulate a consumer per release and slowly grow its heap until the platform kills it mid-request. Bounding the map to MAX_WARM entries and calling destroy() on the evicted consumer keeps the container’s WASM footprint flat regardless of how long it survives or how many releases pass through it.
The ordering is the whole trick. A warm container answers from warmConsumers with zero I/O. A container that was recycled but reused its /tmp volume — which many platforms preserve for a short window — skips the network and pays only the parse. Only a genuinely cold container on a cold disk reaches object storage, and even then the in-flight map guarantees a concurrent burst collapses to one fetch per distinct map rather than one per request.
Verification
Instrument the loader to tag each resolution with the tier it hit, then drive two invocations at the same warm container and assert the second one never touches the network.
// test/warm-reuse.test.mjs — Node's built-in runner, run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { handler } from '../symbolicate.mjs';
const event = {
release: '9f2a',
stack: 'TypeError: x\n at e (https://cdn.example.com/app.4f9a1b2c.js:1:4832)',
};
test('second invocation reuses the warm consumer', async () => {
const cold = await handler({ body: JSON.stringify(event) }); // builds + caches the consumer
assert.equal(cold.statusCode, 200);
let fetched = false;
global.__onS3Get = () => { fetched = true; }; // test hook the loader calls on a real fetch
const warm = await handler({ body: JSON.stringify(event) });
assert.equal(warm.statusCode, 200);
assert.equal(fetched, false, 'warm invocation must not re-fetch the map from object storage');
});
A passing test proves the container-scoped cache is doing its job: the second call resolves the identical map without a single byte crossing the network. In production the equivalent signal is a structured log field like mapTier: "warm" | "tmp" | "storage" — a healthy function shows warm on the overwhelming majority of invocations, with storage appearing only right after a deploy or a scale-out.
For a stronger check, run the same two-invocation sequence against a deployed function and inspect the platform’s own duration metric. The cold invocation should report the fetch-plus-parse cost; every warm invocation after it should collapse to single-digit milliseconds of symbolication with no network segment. If the warm duration still carries the download, the state you meant to keep is somehow handler-scoped — the most common cause is declaring the cache inside a factory that the handler calls, rather than at true module top level.
Edge Cases & Gotchas
- The writable
/tmpvolume is small — typically 512 MB and shared with everything else the function writes — so a function that serves many large maps can fill it. Cap what you spill, and swallowENOSPCfromwriteFilerather than letting a full disk fail an otherwise-successful symbolication. /tmppersistence is a best-effort optimisation, not a guarantee. A recycled container may or may not keep its disk, so treat a/tmphit as a bonus and never as a source of truth; the object store remains the authoritative copy.- Provisioned or pre-warmed concurrency changes the maths. If you keep containers warm deliberately, prefetch the two or three hottest release maps during init so the very first real request is already a warm hit instead of a cold fetch.
- Watch the timeout budget against the map size. A five-megabyte map over a cold connection can eat most of a three-second limit; either raise the timeout, place the bucket in the same region as the function, or gzip the maps at rest and decompress after download.
FAQ
Why does my warm container still download the map on every request?
The reusable state is not actually at module scope. Only variables declared at the top level of the module file — outside exports.handler and outside any function the handler invokes per request — survive between warm invocations. If you build the cache inside a helper that runs on each call, or inside the handler body itself, the platform faithfully throws it away every time. Move the SourceMapConsumer.initialize() call and the warmConsumers map to the top of the file, confirm they run exactly once by logging from module scope, and the second invocation will resolve from memory.
Is the /tmp disk cache worth the complexity over a plain in-memory map?
It pays off precisely when containers churn faster than they warm up. The in-memory map only helps a container that stays alive; a scale-out event, a deploy, or spiky traffic constantly spawns fresh containers that would each re-fetch from object storage. Because some platforms preserve /tmp across a recycle, a new container often finds yesterday’s popular map already on disk and skips the network, paying only the parse. If your traffic is steady and containers are long-lived, the in-memory map alone is enough and you can drop the disk tier.
How do I stop a cold-start burst from stampeding object storage?
The in-flight promise map is what prevents it. When many invocations land on freshly cold containers at once, each still fetches independently — separate processes cannot share memory — but within any single container the inflight map collapses concurrent misses for the same key into one fetch. To cut cross-container fan-out too, put a shared cache or CDN in front of the bucket so the redundant fetches from separate containers resolve at the edge rather than hitting origin storage every time.
Related
- Server-Side Symbolication at Scale — the parent guide this how-to specialises for serverless
- Caching Parsed Source Maps for Faster Symbolication — the LRU eviction policy behind warm reuse
- Symbolicating Stack Traces in a Node CLI Script — the single-map mechanics without the runtime constraints
- Source Map Generation & Stack Trace Debugging — the overarching source map reference