OpenTelemetry in Next.js 16 — Silent Route Handler Failures Masked a 15% Error Rate
Without OpenTelemetry instrumentation in Next.js 16, server component errors are silently swallowed.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓Node.js 18+
- ✓Next.js 14+ (16 recommended)
- ✓OpenTelemetry Collector or compatible backend (Grafana Tempo, Jaeger, Datadog)
- ✓npm packages: @opentelemetry/sdk-node, @opentelemetry/exporter-trace-otlp-proto, @opentelemetry/instrumentation-http
- Next.js server component errors are swallowed by default — they log to the server console but do NOT trigger error boundaries or monitoring alerts.
- OpenTelemetry instrumentation via
instrumentation.tscaptures every server component, route handler, and server action span for observability. - The
after()API fromnext/serverenables post-response logging and cleanup without blocking the client response. - Missing instrumentation masked a 15% error rate in API route handlers for 3 weeks before we discovered it during a manual log audit.
Imagine a restaurant where the kitchen sends up burned dishes but tells the waitstaff everything is fine. The customers (users) get bad food but no one complains because the kitchen staff doesn’t report failures. That’s Next.js without OpenTelemetry: server errors happen silently, with no alert, no dashboard, and no visibility — until users notice something is wrong.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
Next.js 16’s server components are a black box by default — errors thrown inside them are caught by the framework, logged to the server console, and silently converted to a generic 500 response. Unless you’re actively watching server logs, these errors are invisible. We discovered this the hard way: a 15% error rate in our API route handlers went undetected for three weeks because none of our monitoring tools were instrumented to capture server-side Next.js errors.
The fix was implementing OpenTelemetry (OTEL) instrumentation via Next.js’s instrumentation.ts file, configuring exporters to send traces to our observability platform (Grafana Tempo), and adding custom spans for database queries, external API calls, and render operations. This single change turned a “deployment looks fine” team into one that could see every millisecond of every request.
This guide covers: setting up instrumentation.ts, tracing server components and route handlers, using the API for post-response logging, configuring the OpenTelemetry SDK for Next.js, and building dashboards that show real-time error rates, latency distributions, and dependency health across your entire application.after()
What instrumentation.ts Actually Does — The Node.js Bootstrap Hook
Next.js 16 loads instrumentation.ts from your project root at server startup, before the application code begins executing. This is the critical timing: you must register OpenTelemetry SDK, configure exporters, and load instrumentations BEFORE any application modules are imported. If you register OTEL after a module has been loaded, that module’s operations will not be instrumented.
The file exports a single function that Next.js calls during initialization. Inside this function, you configure the Node.js SDK, set up the span processor and exporter, register auto-instrumentation packages, and optionally initialize custom tracer instances. The file lives at the project root (next to register()next.config.js), not inside the app/ directory.
We initially placed instrumentation.ts inside app/ and wondered why no spans appeared. Next.js only loads this file from the project root. Placement matters because Node.js module resolution determines import timing.
register() function is never called and no instrumentation is initialized.register() function before any application module is imported.register() and checking server startup output.Auto-Instrumentation vs Custom Spans — What Each Captures
Auto-instrumentation packages (@opentelemetry/instrumentation-http, -express, -pg, -grpc, etc.) automatically wrap library functions to create spans without any code changes. They capture: HTTP request duration, status codes, and headers; database query text and duration; gRPC call metadata; and outgoing fetch/axios requests.
But auto-instrumentation cannot capture business-level spans: “user authentication duration,” “payment processing,” “AI response generation.” For these, you need custom spans created via trace.getTracer().startSpan(). The power of OpenTelemetry is combining auto-instrumentation (infrastructure layer) with custom spans (application layer) in a single trace waterfall.
We realized that auto-instrumentation alone was insufficient: it showed us that a route handler returned 500 after 8 seconds but not which sub-operation (payment gateway call) caused the failure. Adding custom spans around the payment gateway call revealed that the Stripe API call was taking 7.5 seconds due to a network routing issue.
The after() API — Post-Response Logging Without Blocking the Response
The function from after()next/server schedules a callback to execute after the response has been sent to the client. This is the perfect mechanism for non-critical post-response work: logging, metrics submission, cache warmup, and cleanup operations. The callback runs in the same request context but does not increase the response latency visible to the user.
Critically, callbacks maintain the OpenTelemetry trace context from the parent request. This means spans created inside after() are correctly parented to the original request trace. You can log errors, submit custom metrics, or perform cleanup without worrying about slowing down the user-facing response.after()
We use for: submitting page-level performance metrics to our analytics pipeline, logging slow database queries (>500ms) for offline analysis, warming caches for related pages after a content update, and flushing OpenTelemetry batch spans to ensure they’re exported before the process terminates.after()
after() runs outside the request-response lifecycle but still within the same async context. This means requestId, traceId, and locale data are available in the callback. The response, however, is already sent — you cannot modify headers or body in after().after() callbacks after discovering that inline metrics submission was adding 200-500ms to p95 response times. After the migration, p95 response times dropped by 35% because the response was no longer waiting for metrics pipeline writes to complete.after() for any non-critical post-response work: logging, analytics, cache warmup.after() for critical operations that must complete before the user sees a response.useReportWebVitals — Client-Side Performance Meets OTEL Tracing
Next.js provides useReportWebVitals for capturing client-side performance metrics (LCP, CLS, INP, FCP, TTFB). These metrics are reported from the browser and are independent of server-side OpenTelemetry. The challenge: correlating client-side metrics with server-side traces.
The solution is to generate a unique trace ID on the server (via OpenTelemetry), pass it to the client as a data attribute, and include it when reporting web vitals. This connects the server-side span (request handling) with the client-side span (page rendering) in a single trace waterfall.
We implemented this by embedding the trace ID in the HTML response as a meta tag, reading it in useReportWebVitals, and including it as a custom dimension in the web vitals report. Our observability platform then shows: server took 200ms to generate HTML, browser took 2.3s to render LCP, and the database query that caused the server delay.
useReportWebVitals to capture client-side metrics but correlate them with server traces via shared trace IDs.The ‘Unhandled’ Error Trap — Why process.on(‘uncaughtException’) Is Dangerous
A common mistake when implementing Node.js error monitoring is adding a process.on(‘uncaughtException’) handler to log errors. This is dangerous because Node.js enters an unknown state after an uncaught exception — event loop ticks may fail, file descriptors may leak, and the process becomes unreliable. The Node.js documentation explicitly recommends against it.
Instead, use process.on(‘unhandledRejection’) for promise rejections, and rely on OpenTelemetry’s auto-instrumentation to capture exceptions within request contexts. For truly unexpected errors, let the process crash (honoring the container orchestrator’s restart policy) rather than trying to recover in an unstable state.
We learned this when a process.on(‘uncaughtException’) handler caught a socket hang-up error, logged it to our monitoring, and continued running. The process then failed to accept new connections for 5 minutes before the health check finally killed it.
process.on(‘uncaughtException’) handler and instead configured PM2 with max_restarts: 10. Now, when a truly unexpected error occurs, the worker crashes, PM2 restarts it within 200ms, and the other workers handle traffic during the restart.OTEL Exporters — Choosing Between Batch, Simple, and Custom Span Processors
OpenTelemetry in Node.js offers several span processors and exporters. The BatchSpanProcessor buffers spans in memory and exports them in batches at configurable intervals (default 5 seconds). This is the most efficient for production: minimal overhead, configurable batch size, and built-in retry logic. The SimpleSpanProcessor exports each span immediately as it ends — higher overhead but lower latency for span visibility.
For Next.js server components, the BatchSpanProcessor is the right choice. Server components may create dozens of spans per request (layout, page, child components, data fetching). Batching reduces the export overhead from O(n) exports to O(1) batch exports per interval.
The critical configuration: set scheduledDelayMillis to a value that balances visibility latency with overhead. We use 2 seconds in production: spans appear in Grafana within 2-3 seconds of the request completing, with an overhead cost of less than 0.1% CPU.
SimpleSpanProcessor for “instant” visibility and saw CPU usage increase by 8% from span export overhead alone. Switching to BatchSpanProcessor with a 2-second interval reduced the CPU overhead to 0.3% while keeping span visibility latency under 3 seconds.BatchSpanProcessor in production — it’s designed for high-throughput scenarios.scheduledDelayMillis to 2000-5000ms for a good balance of visibility latency and overhead.maxExportBatchSize to match your traffic volume (default 512 is fine for most apps).Building a Server Error Dashboard — What Metrics Matter for Next.js
After implementing OTEL, building the right dashboard is critical. The metrics that caught the 15% error rate for us: HTTP error rate by route (not just overall), p50/p95/p99 latency per route, database query duration histogram, span status distribution (OK, ERROR, UNSET), and error count by span name.
For Next.js specifically, add: server component render duration (per component type), middleware execution duration, route handler invocation count, server action execution duration, and cache hit/miss ratio for unstable_cache invocations.
The dashboard that caught our silent errors: we filtered spans with status.code = ERROR grouped by http.route. This showed that /api/search had a 15% error rate while every other route was below 1%. The error was invisible in traditional APM because Next.js returned HTTP 200 for the page that included the search widget, and the widget’s inline error handling never propagated to the response code.
Server Actions and OpenTelemetry — Tracing Beyond HTTP
Server Actions in Next.js 16 are called via POST requests to /_next/... internal endpoints. Auto-instrumentation captures these as HTTP spans, but the span names are meaningless for debugging. You need to add custom attributes to identify which server action created each span.
The approach: create a custom wrapper around Server Actions that starts an active span with the action’s name as the span name. This overwrites the generic HTTP span name with something meaningful like server-action.submitContactForm. Add attributes for the action’s input parameters (sanitized to exclude PII), execution duration, and error details.
REPL Summary — The 3 Commands That Diagnose Any Monitoring Gap
When you suspect silent errors are slipping through your monitoring, these three checks will reveal the gaps:
kubectl logs --since=24h | grep -c ‘Error’— if this returns a non-zero number but your dashboard shows 0 errors, you have a monitoring gap.curl -s http://localhost:3000/api/health && curl -s http://localhost:9464/metrics | grep ‘http_server_duration_count’— compares request count from your app vs metric count.NODE_OPTIONS=’--require @opentelemetry/auto-instrumentations-node/register’ node server.js— runs your server with auto-instrumentation on demand to verify OTEL works before committing changes.
OpenTelemetry Collector — Why You Need It and How to Configure It
The OpenTelemetry Collector is a vendor-agnostic agent that receives, processes, and exports telemetry data. In a Next.js deployment, running a collector as a sidecar has three benefits: it handles batching and retry logic (offloading work from your Node.js process), it provides a local endpoint that stays constant regardless of backend changes, and it transforms data (e.g., redacting PII, adding cluster metadata).
Deploy the collector as a sidecar container in your Kubernetes pod or as a separate container in your Docker Compose setup. The Node.js process sends spans to localhost:4318 (OTLP HTTP), and the collector handles export to your backend (Grafana Tempo, Jaeger, Datadog, Honeycomb).
We initially sent spans directly from Next.js to Grafana Cloud, which created a tight coupling between our application and the backend. Switching to a local collector meant we could change backends without changing application code, and the collector’s batch processing reduced our span export overhead by another 40%.
probabilistic_sampler processor that samples 10% of high-volume spans while keeping 100% of error spans, reducing our OTEL costs by 70%.The Silent 15% Error Rate — 3 Weeks of Invisible Failures
kubectl logs but nowhere in our monitoring dashboards.instrumentation.ts with auto-instrumentation for HTTP, gRPC, and database clients. Added custom error span attributes to mark failed requests. Configured a Grafana alert that fired when the error rate exceeded 2% over a 5-minute window. Fixed the connection pool concurrency bug by using a proper mutex for pool acquisition.- Next.js server errors are invisible by default — never trust APM agents that only monitor HTTP responses without OTEL integration.
- Always implement OpenTelemetry instrumentation before going to production — it’s not optional, it’s the difference between blind and observable deployments.
- Server component errors do not trigger React error boundaries — they are handled by Next.js’s internal error wrapper, not by your custom error UI.
- Logs are not monitoring — if you only see errors in kubectl logs, you don’t have monitoring, you have a time machine for post-mortem analysis.
kubectl logs --since=1h or docker logs --since 1h grepping for ‘Error’. Install OpenTelemetry instrumentation immediately — you’re flying blind.NODE_ENV=development temporarily or configure next.config.js with serverExternalPackages to allow proper error propagation.trace.getTracer(). Compare duration of data fetching vs rendering. Use after() to log without blocking response.curl -s http://localhost:4318/v1/traces 2>&1 || echo 'OTEL collector not reachable'docker logs <opentelemetry-collector> | grep 'export' | tail -20| File | Command / Code | Purpose |
|---|---|---|
| instrumentation.ts | export async function register() { | What instrumentation.ts Actually Does |
| lib | const tracer = trace.getTracer('my-app'); | Auto-Instrumentation vs Custom Spans |
| app | export default async function Page() { | The after() API |
| lib | 'use client'; | useReportWebVitals |
| instrumentation.ts | process.on('unhandledRejection', (reason) => { | The ‘Unhandled’ Error Trap |
| instrumentation.ts | const processor = new BatchSpanProcessor(exporter, { | OTEL Exporters |
| lib | export function recordRouteMetrics( | Building a Server Error Dashboard |
| lib | const tracer = trace.getTracer('server-actions'); | Server Actions and OpenTelemetry |
| check-monitoring-gap.sh | echo "=== Checking Error Monitoring Gap ===" | REPL Summary |
| otel-collector-config.yaml | receivers: | OpenTelemetry Collector |
Key takeaways
Interview Questions on This Topic
Design an observability strategy for a Next.js 16 application that traces server components, route handlers, server actions, and client-side performance in a single cohesive pipeline.
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Next.js. Mark it forged?
5 min read · try the examples if you haven't