docs

Performance and safety

What the Unhandled SDKs do while your code runs, how much they keep in memory, and what your app sees when Unhandled is slow, rejecting requests or down.

Monitoring must never be the reason an app is slow or down. This page lists the work the Unhandled SDKs do inside your requests, every buffer they keep and its limit, and what happens when the ingest misbehaves. It applies to @unhandled/node and every package built on it (Express, Fastify, Hono, NestJS, Next.js, AdonisJS), and to the browser SDK.

What the SDKs promise

  • No network on the request path. Spans, logs and errors go into an in-memory queue. A background timer sends them. No SDK code waits for an export, a DNS lookup or a file read while your handler runs.
  • Every queue has a limit. When a queue is full, new records are dropped and counted. Your code never waits for room.
  • Export failures stay inside the SDK. A timeout, a refused connection, a 429 or a 500 from the ingest is retried in the background, then dropped. flush() and shutdown() never reject.
  • Shutdown has a deadline. flush() and shutdown() return within shutdownTimeoutMillis, 5 seconds by default, whatever state the ingest is in. A final batch the ingest has not accepted by then is dropped.
  • SDK failures are not your failures. If code inside the SDK throws, including a callback you passed to it (a user resolver, a sampling rule, a body transform, a URL sanitize), the SDK skips that piece of telemetry and your request carries on. Each failing spot is logged once through the OpenTelemetry diagnostic logger. Set OTEL_LOG_LEVEL=warn to see those messages.

Work on the request path

These are the only things the Node SDKs do while a request is in flight. Everything else happens on a timer between requests.

WorkWhenWhat bounds it
Start and end spansEach instrumented operationConstant per span. Ending a span appends it to the export queue.
Name the route, record the userOnce per requestConstant.
Capture a bodyEach side you turned on in http.captureBodiesValues the framework already parsed are redacted and copied only as far as maxBodySize reaches. Raw text is parsed only up to 64 KiB, or up to maxBodySize when that is larger. Longer text is not parsed and the span gets unhandled.http.request.body_omitted (or response) set to too_large. A side you did not turn on costs nothing, because the framework packages check before they read or serialize it.
Report an exceptionEach reported errorParse the stack and build the record. Source lines come from a cache. A file the SDK has not read yet is read asynchronously, and the error report is sent once it is in.
Hold the spans of an unsampled jobEach span of a job that keepErrors() is watchingAppend to the job's buffer, which stops at maxSpans.

With Hono, the request body is recorded only when your handler read it with c.req.json() or c.req.text(). The middleware never reads a request body itself, so it never waits on a client's upload. A response body that is already in memory is copied. A streamed response is not waited for and its body is not recorded.

In the browser, console.* calls and logger messages are cut at 8,192 characters, and objects passed to the console are serialized only as far as that limit reaches.

Queues and their limits

Node.js

BufferLimitWhen it is full
Spans waiting for exportbatch.maxQueueSize, 2,048 per destinationNew spans are dropped. The diagnostic logger reports how many.
Log records waiting for export (logs, errors, analytics events)batch.maxQueueSize, 2,048 per destinationNew records are dropped. The diagnostic logger reports how many.
Export requests in flight30 per signal and destinationThe batch counts as failed and is dropped.
Spans of one job held by keepErrors()maxSpans, by default one less than batch.maxQueueSizeLater spans are counted on the root as unhandled.sampling.dropped_spans.
Source files cached for stack frames256 files and 8 MiB. Files over 1 MiB are never read.The least recently used file is evicted.
Error reports waiting for a source file64Further reports are sent at once, without source lines.
Bytes read from an outgoing call's body64 KiB, or maxBodySize when largerThe body is marked too_large and not parsed.

Browser

BufferLimitWhen it is full
Records waiting to be sent or to be retried1,000, with 100 reserved each for spans, analytics events and other logsNew records are dropped, and the console shows one warning.
Requests in flight4, each with a 10 second timeoutRecords wait in the queue.
Console records100 in a burst, then one every 5 secondsExtra console calls are still printed, just not sent.
Error reports10 of the same error in a burst, then one every 10 seconds, and 100 of all errors in a burst, then one every 5 secondsExtra errors are not sent.
Data sent when the page is hidden or closed60 KB with keepaliveThe rest goes out as ordinary requests, which finish if the page stays open, as a hidden tab does.

The browser SDK sends a batch once 50 records or about 30 KB are queued, so little is left to send when the page closes.

When Unhandled is slow or down

The SDK sends in the background, so your requests do not notice whether the ingest answers in 1 millisecond, 5 seconds or never. What changes is what the SDK keeps and how long it takes to stop:

  • Slow ingest. Exports wait for their answer while new records queue behind them, up to the limits above.
  • Ingest returning 429 or 502 to 504. Each batch is retried with backoff for up to batch.exportTimeoutMillis, then dropped. A 429 with Retry-After is honored.
  • Ingest returning another error, such as 500. The batch is dropped without a retry.
  • Ingest unreachable. Connections fail fast and are retried with backoff. The queues fill and stay at their limits.

Those rules are the Node.js exporters'. The browser SDK retries a 429, any 5xx, a timeout and a network error alike, up to 4 attempts with jittered backoff. It waits as long as Retry-After asks, up to 60 seconds, and drops the batch when the server asks for longer. Any other status drops the batch at once. A batch sent by flush(), or while the page is hidden or closing, is not retried. The console shows one warning the first time a batch is dropped.

The numbers below come from the benchmark in packages/bench on a 20-core machine: an Express app at 400 requests per second, with body capture on both sides, five spans per request and one request in twenty failing with a reported error. Each figure is the median of five runs.

IngestRequest p50Request p99Event loop delay p99Heap peak during loadshutdown()
No SDK2.9 ms16.8 ms2.6 ms10 MB4 ms
Healthy3.1 ms5.9 ms2.6 ms46 MB62 ms
Answers after 5 s3.2 ms8.7 ms1.7 ms49 MB5.0 s
Refuses connections2.9 ms4.5 ms1.2 ms49 MB5.0 s
Always 4293.0 ms6.1 ms1.2 ms49 MB5.0 s
Always 5003.1 ms6.9 ms2.4 ms44 MB36 ms

Latency is measured from when each request was due to leave, so a stalled event loop shows up as latency rather than as fewer requests. The p99 column is noisy on a shared machine: the "no SDK" median includes a run where the whole machine stalled. The p50 column is the stable one, and it moves by a few tenths of a millisecond between the rows. In this app, Unhandled's own code spends about 0.2 ms of CPU per request, most of it on body capture, and OpenTelemetry's span handling comes on top. Both grow with the number of spans and the size of the bodies you capture. The heap grows because spans and log records wait in the export queue. After a garbage collection, the heap is back where it started in every row, including the ones where nothing gets through.

Stopping

shutdown() returns within shutdownTimeoutMillis. Batches that could not be sent by then are dropped. An export request that is still waiting for the ingest, or a retry that is scheduled, can keep Node's event loop alive until batch.exportTimeoutMillis runs out. If your process relies on the event loop draining in order to exit, call process.exit() after shutdown(), or lower batch.exportTimeoutMillis.

The process handlers flush for at most 2 seconds before they exit after an uncaught exception.

Tuning

GoalChange
Use less memory when the ingest is downLower batch.maxQueueSize. Each queued span is roughly 1 KB, more with large attributes or captured bodies.
Use less memory on large failing jobsPass keepErrors({ maxSpans }) with a smaller number.
Stop fasterLower shutdownTimeoutMillis, and batch.exportTimeoutMillis if the process must exit on its own.
Spend less CPU on body captureTurn on only the sides you need and keep maxBodySize small.
Spend less CPU on busy servicesLower samplingRatio. Spans that are not sampled skip body capture too.
See dropped data and SDK errorsSet OTEL_LOG_LEVEL=warn.

Rerun the benchmark

Terminal
cd packages/bench
node src/run.ts --after ../.. --before ../../../unhandled-main --reps 5
node src/micro.ts --root ../..

--before and --after each point at a checkout of this repository, so you can compare two versions in one interleaved run. Leave one out to measure a single checkout. micro.ts times single operations (body capture, an exception report, a large failing job) for one checkout. --modes picks from none, healthy, slow, down, 429 and 500. The app listens on port 3420 and the fake ingest on 3421, which --app-port and --ingest-port change.