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.

Cost stages of a handler-scoped invocationEach handler-scoped invocation runs fetch, parse, and resolve, then discards the consumer at exit so the next call repeats every stage.Fetch mapobject storage, slowParse consumerWASM decode, ~200 msResolve then discardnext call repeats it all

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.

  1. 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
  1. 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;
}
  1. Build a consumer from a /tmp copy 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;
}
  1. 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
};
  1. 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);
  }
}

Serverless map lookup pathAn invocation checks the module-scope warm map first, falls back to the tmp disk cache on a warm-miss, and only reaches object storage when both are cold.Warm mapmodule scope, 0 ms/tmp diskparse only, ~200 msObject storagefetch + parse

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.

Handler scope versus module scopeThe left panel loads and destroys the consumer inside the handler on every call; the right panel keeps it in module scope and reuses it across warm invocations.Handler scopeFetch map every callParse map every callWarm reuse impossibleModule scopeFetch once per containerReuse across warm calls/tmp survives recycle

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 /tmp volume 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 swallow ENOSPC from writeFile rather than letting a full disk fail an otherwise-successful symbolication.
  • /tmp persistence is a best-effort optimisation, not a guarantee. A recycled container may or may not keep its disk, so treat a /tmp hit 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.