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
| Hook | Fires on | Suppresses the default crash? | Can you await inside? |
|---|---|---|---|
uncaughtException | a synchronous throw nothing caught | Yes — the exit is now your job | Yes, on borrowed time |
uncaughtExceptionMonitor | the same throw, just before the crash | No | No — the process dies right after |
unhandledRejection | a rejected promise with no handler | Yes (it overrides the default throw mode) | Yes |
beforeExit | the event loop drained normally | n/a | Yes, but not after process.exit() |
SIGTERM | your supervisor asking you to stop | n/a | Yes — 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.