Reporting uncaught exceptions before a Node process dies

Wire uncaughtException and unhandledRejection to an HTTP error tracker on Node 22+, give the report a hard flush budget, then exit — with the hooks that actually work.

Register process.on('uncaughtException') and process.on('unhandledRejection'), do exactly one awaited HTTP POST inside each with a hard timeout of a second or two, then call process.exit(1) yourself. That’s the whole shape. With Infrai it’s a single POST /v1/errors/capture and no SDK, which matters here because the handler runs in a process you no longer trust — the fewer moving parts between the throw and the socket write, the better your odds of the report landing.

The part people get wrong isn’t the handler. It’s the flush: once you’ve attached a listener, Node stops crashing on your behalf, and if you call process.exit() before the request completes you’ve built a crash reporter that reports nothing. Sentry’s SDK solves this with an internal transport queue and a close(timeout) call; without an SDK you solve it with await and an AbortSignal.

The hooks, and which one you actually want

HookFires onSuppresses the default crash?Can you await inside?
uncaughtExceptiona synchronous throw nothing caughtYes — the exit is now your jobYes, on borrowed time
uncaughtExceptionMonitorthe same throw, just before the crashNoNo — the process dies right after
unhandledRejectiona rejected promise with no handlerYes (it overrides the default throw mode)Yes
beforeExitthe event loop drained normallyn/aYes, but not after process.exit()
SIGTERMyour supervisor asking you to stopn/aYes — and this one isn’t an error

uncaughtExceptionMonitor looks like the tidy choice because it preserves Node’s own exit behaviour. It isn’t, for reporting: the process is gone microseconds later and an in-flight POST goes with it. Use the plain uncaughtException handler, accept that you now own the exit, and keep the handler small.

Two events, not one. A rejected promise on Node 22+ would terminate the process anyway under the default --unhandled-rejections=throw mode, but attaching a listener overrides that — so if you attach one and forget to exit, you’ve quietly converted crashes into zombies.

The reporter

One file, no dependencies, safe to import first thing in your entrypoint.

// crash-reporter.mjs — Node 22+, ESM, no dependencies.
const ENDPOINT = "https://api.infrai.cc/v1/errors/capture";
const FLUSH_MS = 2000;

async function reportFatal(kind, err) {
  const key = process.env.INFRAI_API_KEY;
  if (!key) return;                       // never let the reporter itself throw
  const error = err instanceof Error ? err : new Error(String(err));
  try {
    const res = await fetch(ENDPOINT, {
      method: "POST",
      headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
      body: JSON.stringify({
        message: (error.stack ?? error.message).slice(0, 8000),
        exception: error.name,
        fingerprint: `${kind}:${process.env.SERVICE_NAME ?? "api"}:${error.name}`,
        environment: process.env.NODE_ENV ?? "development",
        release: process.env.APP_RELEASE ?? "dev",
      }),
      signal: AbortSignal.timeout(FLUSH_MS),
    });
    if (!res.ok) console.error("[fatal] capture rejected", res.status, await res.text());
  } catch (e) {
    console.error("[fatal] capture failed", e.message);   // stderr is the fallback sink
  }
}

let exiting = false;
async function die(kind, err, code) {
  if (exiting) return;                    // a second fatal during shutdown must not re-enter
  exiting = true;
  console.error(`[fatal] ${kind}:`, err);
  await reportFatal(kind, err);
  process.exit(code);
}

process.on("uncaughtException", (err) => { void die("uncaughtException", err, 1); });
process.on("unhandledRejection", (reason) => { void die("unhandledRejection", reason, 1); });

Three details in there are load-bearing. The exiting guard stops a second fatal — very common, because the first one often breaks something that then breaks again — from starting a second POST and racing the first. AbortSignal.timeout(2000) bounds how long a dying process waits on a network that may be the reason it’s dying. And every failure path inside the reporter is swallowed to stderr, because a crash reporter that throws inside uncaughtException produces an infinite loop.

Wiring and proving it

// index.mjs — the import must come before anything that can throw.
import "./crash-reporter.mjs";
import http from "node:http";

const server = http.createServer((req, res) => {
  if (req.url === "/boom") { setTimeout(() => { throw new Error("boom from a timer"); }, 5); }
  res.writeHead(200, { "content-type": "text/plain" });
  res.end("ok\n");
});

process.on("SIGTERM", () => server.close(() => process.exit(0)));
server.listen(3000, () => console.log("listening on :3000"));
export INFRAI_API_KEY="your_infrai_api_key"
export NODE_ENV=production
export APP_RELEASE="2026.07.22"

node index.mjs &
sleep 1
curl -sS "http://localhost:3000/boom" || true
wait $!
echo "exit code: $?"

A throw from inside a setTimeout callback is the case that catches people out: no request-scoped try/catch can see it, and Express-style error middleware never runs. That’s precisely why the process-level hook exists.

What the handler puts on the wire

Here’s the same payload as a curl, so you can check the account is wired up before you trust it to a crash path:

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "ReferenceError: sessionCache is not defined\n    at Timeout._onTimeout (/app/src/jobs/sweep.js:31:5)",
    "exception": "ReferenceError",
    "fingerprint": "uncaughtException:jobs/sweep:ReferenceError",
    "environment": "production",
    "release": "2026.07.22"
  }'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_qo4RQLJysCv4hPenTj62qeBG",
    "fingerprint": "9628ff938b227877f3e5258fde56ecfbaf8e25ec5f378c201049671b61b4e645",
    "error_group_id": "errgrp_NYDnJiTHHxQpzhJCjXMud1fD",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_qo4RQLJysCv4hPenTj62qeBG"
  }
}

is_new_group: true on a crash is the signal worth alerting on — a fatal fingerprint nobody has seen since the last deploy. Reading it back costs nothing:

curl -sS "https://api.infrai.cc/v1/errors/search?q=sessionCache&limit=2" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      {
        "event_id": "evt_err_qo4RQLJysCv4hPenTj62qeBG",
        "error_group_id": "errgrp_NYDnJiTHHxQpzhJCjXMud1fD",
        "timestamp": "2026-07-26T01:27:23.225546Z",
        "level": "error",
        "title": "ReferenceError: sessionCache is not defined",
        "environment": "production",
        "release": "2026.07.22"
      }
    ],
    "next_cursor": null,
    "total": 1
  }
}

Search needs a non-empty q (an empty one is a 400) and it ignores environment and level — those filters work on GET /v1/errors/list instead. Mildly annoying, and easy to trip over when you build the crash dashboard.

Restart loops, and what they cost you

A container that crashes on boot restarts and crashes again, so the fatal handler fires on every restart. Ten restarts a minute for an hour is 36,000 captured events, which is a bill and a very noisy group. Two defences: keep the exit code honest so your supervisor applies backoff (Kubernetes CrashLoopBackOff triggers on non-zero exits, so don’t “helpfully” exit 0), and check spend rather than guessing.

curl -sS "https://api.infrai.cc/v1/account/usage" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '{period, total_cost, errors: [.breakdown[] | select(.key | startswith("errors."))]}'

Captured events are billable per call — $0.00005 each as verified on 2026-07-26, with $2 of free credit on a new account, so roughly 39,999 captures before you’re charged — while every read route in the namespace is free. Rates here trend downward and campaigns run, so today’s figure may be lower than the one printed above; the structure is the durable bit. Reads free, writes metered, and a crash loop is the one pattern that can make a cheap unit price add up.

Where you’d want something else

The stored event has no frames. Whatever you pass as exception is normalised away — the event comes back as {"type": "Message", "value": "…", "stacktrace": []} — so the stack survives only as the text you put in message, and there’s no support for source maps, minified frame resolution or in-app markers. If you’re debugging a bundled TypeScript service and you want clickable frames, stick with Sentry; its Node SDK also handles worker threads and domain-style context you’d otherwise write yourself. Rollbar is the reasonable middle option if you want that plus a per-event ownership workflow.

The trade-off you’re making is deliberate: one HTTP call you can read, no instrumented require chain, free reads, and a key that already covers queues, cron, storage and email — so the follow-up work after a crash (page someone, re-enqueue the job) doesn’t need another vendor.

References

Browse more errors developer guides