Building a Batch Symbolication Service in Node.js

A batch symbolicator that lives inside one worker process is useful, but the moment a second team needs readable stack traces you want symbolication behind an HTTP boundary — a single endpoint that takes a raw stack string and returns resolved frames. This how-to builds that service in Node.js: an HTTP handler that validates an incoming trace, groups its frames by bundle, resolves each against a cached SourceMapConsumer, and answers with structured JSON. It applies the throughput architecture from Server-Side Symbolication at Scale to a request/response surface, sits inside the broader Source Map Generation & Stack Trace Debugging reference, and reuses the consumer-lifecycle discipline from Caching Parsed Source Maps for Faster Symbolication; if you only need a one-off command instead of a service, Symbolicating Stack Traces in a Node CLI Script covers the non-service path.

Symptom / Trigger

Several backend services and a mobile client all ingest browser errors, and every one of them shows minified frames in its dashboard because none of them owns symbolication logic. A developer opens an incident and sees this — an unreadable location that points at a bundle, not at source:

POST /symbolicate  →  they send the raw trace as-is
{
  "release": "9f3c2ab",
  "stack": "TypeError: Cannot read properties of undefined (reading 'id')\n    at t (https://cdn.example.com/app.4f9a1b2c.js:1:28471)\n    at https://cdn.example.com/app.4f9a1b2c.js:1:19022"
}

The natural fix is one shared endpoint that every client can POST a stack to. But the first draft of that endpoint tends to crash on the inputs real clients send — a missing release, an anonymous frame with no function name, or a batch of a hundred stacks in a single request that opens a hundred concurrent map fetches. The trigger for this page is the moment you decide to expose symbolication over HTTP and need the request contract and the batching to be tight enough to survive production traffic.

What makes an HTTP surface different from the in-process batcher is that the input is now attacker-shaped and out of your control. Inside a worker you trust the events your own pipeline hands you; behind an endpoint you receive whatever a client serialises, including truncated bodies, wrong content types, and stacks in formats you did not anticipate. The service therefore has two jobs that the library version never had: defend the process against malformed input, and answer with a status code the caller can branch on. Everything below is organised around those two obligations.

Root Cause Explanation

The broken endpoint treats every request as if it carries exactly one well-formed frame and constructs a consumer inline, with no validation and no grouping:

// WRONG — no input validation, one consumer built per request, frames not grouped
const http = require('http');
const { SourceMapConsumer } = require('source-map');

http.createServer(async (req, res) => {
  const body = JSON.parse(await readBody(req));           // throws on bad JSON → 500
  const raw = await fetchMap(body.release);               // fetch on every request
  const consumer = await new SourceMapConsumer(raw);      // rebuilt, never destroyed
  const pos = consumer.originalPositionFor({ line: 1, column: body.column });
  res.end(JSON.stringify(pos));                           // leaks WASM, ignores batch
}).listen(3000);

Three things are wrong at once, and each maps to a class of production failure. There is no request contract, so a malformed body throws inside JSON.parse and returns an opaque 500 instead of a 400 the client can act on. A fresh consumer is built and abandoned on every request, leaking WASM heap exactly as described in the scale guide. And the handler resolves a single hard-coded frame, ignoring that a real stack has many frames — and that a batch endpoint receives many stacks at once. The remedy is to make the request shape explicit, resolve every frame of every stack against a shared cached consumer, and bound how much work one request may trigger.

Symbolication request pipelineAn HTTP POST carrying a raw stack flows through body validation, then frame resolution against a cached consumer, and out as a structured JSON response.POST /symbolicateraw stack + releaseValidate + resolvecached consumerJSON responseresolved frames

Step-by-Step Fix

1. Define the request contract and reject bad input with a 400

// contract.js — validate the body before any expensive work happens
function parseRequest(body) {
  if (typeof body.release !== 'string' || !body.release.trim())
    throw { status: 400, message: 'release is required' }; // client error, not 500
  const stacks = Array.isArray(body.stacks) ? body.stacks : [body.stack];
  if (stacks.length === 0 || stacks.some((s) => typeof s !== 'string'))
    throw { status: 400, message: 'stacks must be non-empty strings' };
  if (stacks.length > 100)
    throw { status: 400, message: 'batch capped at 100 stacks' }; // bound the work
  return { release: body.release, stacks };
}

Validation happens before any I/O, which matters for both cost and safety. A rejected request should never have touched object storage or built a consumer, so the cheapest possible check — is the body even the right shape — runs first. Normalising a lone stack field into a one-element array here means the rest of the service only ever handles the batch case, which removes an entire branch from the resolver and the tests.

2. Parse each stack into frames and group them by bundle filename

// frames.js — turn "at fn (url:LINE:COL)" lines into structured frames
function parseStack(stack) {
  return stack.split('\n').slice(1).map((line) => {
    const m = line.match(/\(([^)]+):(\d+):(\d+)\)$/) ||   // named frame
              line.match(/at ([^\s]+):(\d+):(\d+)$/);      // anonymous frame
    if (!m) return null;
    const filename = m[1].split('/').pop();                // app.4f9a1b2c.js
    return { filename, line: +m[2], column: +m[3] };       // line 1-based, col 0-based
  }).filter(Boolean);
}

The regex handles the two V8 frame shapes a browser emits: a named frame wrapped in parentheses and an anonymous frame with the URL inline. Reducing each URL to its bare filename at parse time is deliberate — it is the piece that combines with the release to form a storage key, and doing it once here keeps the resolver from re-deriving it per lookup. Lines that match neither shape are dropped rather than passed through, so a stray banner or a Caused by: line never becomes a phantom frame in the response.

3. Resolve every frame of every stack against a shared cached consumer

// symbolicate.js — getConsumer is the LRU-cached, destroy-on-evict resolver
const { getConsumer } = require('./consumerCache');

async function symbolicate(release, stacks) {
  return Promise.all(stacks.map(async (stack) => {
    const frames = parseStack(stack);
    return Promise.all(frames.map(async (f) => {
      const consumer = await getConsumer(`maps/${release}/${f.filename}.map`);
      const pos = consumer.originalPositionFor({ line: f.line, column: f.column });
      if (pos.source === null) return { ...f, resolved: false }; // gap in the map
      return { resolved: true, source: pos.source, line: pos.line, name: pos.name };
    }));
  }));
}

4. Wire the handler so errors degrade to a status code instead of a crash

// server.js — one endpoint, explicit status codes, never an unhandled throw
const http = require('http');
const { parseRequest } = require('./contract');
const { symbolicate } = require('./symbolicate');

http.createServer(async (req, res) => {
  try {
    if (req.method !== 'POST' || req.url !== '/symbolicate')
      return send(res, 404, { error: 'not found' });
    const { release, stacks } = parseRequest(JSON.parse(await readBody(req)));
    const results = await symbolicate(release, stacks); // batch resolved together
    send(res, 200, { release, results });
  } catch (err) {
    send(res, err.status || 500, { error: err.message || 'symbolication failed' });
  }
}).listen(3000);

const send = (res, status, obj) => {
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(JSON.stringify(obj)); // always a JSON body, even on error paths
};

Handler request flowInside one request the handler reads the body, validates it against the contract, groups the frames by bundle, then resolves them and returns JSON.Read + parse bodyJSON.parse in try blockValidate + groupcontract, then by bundleResolve + respond200 with resolved frames

The four steps compose into a service whose expensive work — fetching and parsing maps — is shared across every frame and every stack in a request, while the cheap per-frame lookups run in parallel. Reading the request body is a small helper worth writing once so the handler stays readable:

// readBody.js — collect the request stream into a single UTF-8 string, with a cap
function readBody(req, limit = 1_000_000) {
  return new Promise((resolve, reject) => {
    let data = '';
    req.on('data', (chunk) => {
      data += chunk;
      if (data.length > limit) reject({ status: 413, message: 'body too large' });
    });
    req.on('end', () => resolve(data)); // resolve only after the full body arrives
  });
}

Verification

Start the server and POST a batch containing one good stack and one malformed one. A correct implementation resolves the good frames and marks the rest, and never returns a 500 for a client-side mistake:

# Send a two-stack batch and assert the shape of the response
curl -s localhost:3000/symbolicate \
  -H 'content-type: application/json' \
  -d '{"release":"9f3c2ab","stacks":[
        "Error\n    at t (https://cdn.example.com/app.4f9a1b2c.js:1:28471)",
        "Error\n    at unparseable line"]}' | jq '.results | length'
# expected: 2   → one resolved frame array, one empty array (no parseable frames)

The curl check proves the happy path and the degrade path in one call: the good stack yields a resolved frame array and the malformed one yields an empty array, and the overall response is still a 200 because neither stack is a client contract violation. That distinction — a per-frame failure is data, a per-request failure is a status — is the behaviour most worth locking down, because it is the behaviour a careless refactor is most likely to break.

A committed automated test pins the contract so a refactor cannot silently loosen it. Node’s built-in runner is enough:

// test/service.test.js — run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseRequest } from '../contract.js';

test('missing release is a 400, not a 500', () => {
  assert.throws(() => parseRequest({ stack: 'Error\n at x:1:1' }),
    (e) => e.status === 400); // client error surfaces as a status, never a crash
});

test('a batch over the cap is rejected', () => {
  const stacks = Array.from({ length: 101 }, () => 'Error\n at x:1:1');
  assert.throws(() => parseRequest({ release: 'r', stacks }),
    (e) => e.status === 400); // the 100-stack ceiling holds
});

Loose endpoint versus contract-first endpointThe left panel shows an endpoint that crashes on bad input and rebuilds consumers; the right shows a validated, batched, cache-backed endpoint.Loose endpointBad input returns 500One frame per requestConsumer leaks per callContract firstBad input returns 400Whole batch resolvedCached, bounded heap

Edge Cases & Gotchas

  • Anonymous frames have no function name. A frame like at https://cdn/app.js:1:19022 matches the second regex branch and resolves fine, but pos.name will be null; surface the resolved source location anyway rather than dropping the frame.
  • A batch that mixes releases must not share one consumer. Group by the storage key derived from release plus filename, not by filename alone — two releases of app.js are different maps, and keying on filename returns the wrong source line.
  • JSON.parse throws before your validation runs. Keep the parse inside the same try block as parseRequest so a malformed body becomes a 400, not an unhandled rejection that returns a bare 500.
  • An oversized body can exhaust memory before you reject it. Enforce the byte cap while streaming in readBody, not after the full string is assembled, so a hostile client cannot buffer a gigabyte into the process.

FAQ

Should the endpoint accept a single stack or a batch of stacks?

Accept both by normalising to an array. The contract shown treats a lone stack field as a one-element batch and a stacks array as a multi-element batch, so simple clients stay simple while high-volume producers coalesce many traces into one request. Batching is where a service earns its keep: fetching and parsing a map once and resolving fifty stacks against it is far cheaper than fifty separate requests each paying the same setup cost. Cap the batch size so one request cannot monopolise the process, and return a 400 when the cap is exceeded rather than silently truncating.

How does this service avoid the WASM memory leak the scale guide warns about?

It never constructs a SourceMapConsumer inline in the handler. Every consumer comes from getConsumer, the LRU-backed helper whose dispose hook calls destroy() on eviction, so the WASM heap is bounded by the cache size regardless of request rate. The handler only ever holds a reference for the duration of a lookup; ownership of the lifecycle lives in the cache module. That single discipline — resolve, never construct, inside request code — is what keeps a long-lived HTTP service from climbing toward an out-of-memory kill.

What status code should a missing source map return?

Distinguish a client error from a server condition. A malformed request body, a missing release, or an over-cap batch is the caller’s fault and returns 400. A map that simply is not in storage yet — usually a race between a deploy and the upload step — is not the caller’s fault, so return 200 with the frame marked resolved: false and its raw location preserved. That lets the client show the minified frame now and reprocess later once the map lands, instead of treating a temporary gap as a hard failure.