How I Found a Slow Next.js Route With OpenTelemetry Tracing and Custom Spans
How I wired OpenTelemetry into Next.js with instrumentation.ts, added custom spans, and used the trace waterfall to find a hidden bottleneck.
Jason
6 min read·
A few weeks ago one of my Next.js pages started taking almost two seconds to respond in production. My logs told me that it was slow, but not where the time went. Was it the database? A third-party API? Rendering? I was guessing, and guessing is an expensive way to debug.
What fixed it was distributed tracing with OpenTelemetry. Next.js already ships with OpenTelemetry support built in, so wiring it up took one file and a handful of lines. In this post I'll walk through how I set it up, how I added my own spans around the code I actually cared about, and how I read the resulting trace to find the bottleneck.
Traces, spans and why logs weren't enough
A trace is the full story of one request. It's made of spans: named, timed operations that nest inside each other. The root span might be GET /dashboard, with child spans for rendering the route, each fetch call, and each database query underneath.
The key difference from logs is structure. A log line says "query took 900ms". A trace shows that the 900ms query ran sequentially after a 600ms API call that didn't depend on it, which is the kind of thing you only notice when you see a waterfall.
Step 1: the instrumentation file
Next.js looks for a file called instrumentation.ts in the project root (or inside src/ if you use that folder, but never inside app/ or pages/). It calls the exported register() function once when a new server instance starts. On Next.js 15 and later this file is picked up automatically; older versions needed the experimental.instrumentationHook flag.
The quickest route is Vercel's @vercel/otel package, which configures the OpenTelemetry SDK for you and works outside Vercel too:
npm install @vercel/otel @opentelemetry/api
// instrumentation.ts
import { registerOTel } from '@vercel/otel';
export function register() {
registerOTel({ serviceName: 'portfolio-web' });
}
That's genuinely enough to start producing spans. Next.js wraps incoming requests, route rendering, route handlers, generateMetadata and server-side fetch calls in spans automatically. If you want more detail, setting NEXT_OTEL_VERBOSE=1 makes Next.js emit additional internal spans. I leave it off by default because it's noisy, and switch it on only when I'm digging into framework behaviour.
Step 2: send the spans somewhere you can see them
Spans are useless if they go nowhere. @vercel/otel exports over OTLP and respects the standard OpenTelemetry environment variables, so I don't hard-code an endpoint. Locally I run Jaeger in Docker, which accepts OTLP and gives me a UI for browsing traces:
docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latest
Then I point the app at it in .env.local:
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
Port 4318 is the standard OTLP/HTTP port and 16686 is Jaeger's UI. After npm run dev and a couple of page loads, my service shows up at http://localhost:16686. In production I set the same variables to my tracing vendor's OTLP endpoint (plus an auth header via OTEL_EXPORTER_OTLP_HEADERS), and the code doesn't change at all. That portability is the main reason I went with OpenTelemetry instead of a vendor-specific SDK.
Step 3: wrap the code you care about in custom spans
The automatic spans told me the route render took about 1.9 seconds, but the render span was one big opaque bar. To see inside it, I added my own spans around my data-loading functions using @opentelemetry/api.
Rather than repeating try/catch/finally in every function, I wrote a small helper:
// lib/tracing.ts
import { trace, SpanStatusCode, type Attributes } from '@opentelemetry/api';
const tracer = trace.getTracer('portfolio-web');
export async function withSpan<T>(
name: string,
attributes: Attributes,
fn: () => Promise<T>,
): Promise<T> {
return tracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn();
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
Three details matter here:
- startActiveSpan makes the new span the active one for everything inside the callback, so any nested spans (including the automatic fetch spans) become its children instead of floating at the top level.
- span.end() in finally is essential. A span that is never ended is never exported, and you'll stare at an empty trace wondering what went wrong.
- Recording the exception and setting an error status makes failed operations show up red in the trace view, which is far easier to spot than searching logs.
Using it in a Server Component looks like this:
// app/dashboard/page.tsx
import { withSpan } from '@/lib/tracing';
import { prisma } from '@/lib/prisma';
import { getGithubStats } from '@/lib/github';
async function loadDashboard(userId: string) {
const projects = await withSpan('db.projects.list', { 'app.user_id': userId }, () =>
prisma.project.findMany({ where: { ownerId: userId }, take: 20 }),
);
const stats = await withSpan('github.stats', { 'app.user_id': userId }, () =>
getGithubStats(userId),
);
return { projects, stats };
}
export default async function DashboardPage() {
const userId = 'user_123'; // from your session in a real app
const { projects, stats } = await loadDashboard(userId);
return <Dashboard projects={projects} stats={stats} />;
}
I name spans after what they do (db.projects.list), not where they live in the code, and I keep high-cardinality values like user IDs in attributes rather than in the span name. Span names should group nicely in the UI; attributes are for filtering.
Step 4: read the waterfall
With custom spans in place, the trace for GET /dashboard finally made the problem obvious. Inside the render span I saw:
- db.projects.list: about 40ms.
- github.stats: about 1.6 seconds, starting only after the database span ended.
Two lessons jumped out. First, the GitHub call was the real cost, not the database I'd been suspecting. Second, the two calls were independent but ran one after the other because I'd written two sequential awaits. The fix was a one-liner:
const [projects, stats] = await Promise.all([
withSpan('db.projects.list', { 'app.user_id': userId }, () =>
prisma.project.findMany({ where: { ownerId: userId }, take: 20 }),
),
withSpan('github.stats', { 'app.user_id': userId }, () => getGithubStats(userId)),
]);
In the next trace the two spans sat side by side instead of stacked, and I then cached the GitHub stats so most requests skipped that span entirely. Without the waterfall I would probably have spent an afternoon adding database indexes that wouldn't have helped.
Gotchas I hit along the way
Edge runtime vs Node.js runtime
register() runs in every runtime your app uses, including Edge middleware. If you set up the OpenTelemetry Node SDK manually instead of using @vercel/otel, import it only when process.env.NEXT_RUNTIME === 'nodejs', otherwise the Edge bundle will try to load Node-only modules and fail. With @vercel/otel, automatic spans work in both runtimes, but custom spans are only supported on the Node.js runtime.
// instrumentation.ts (manual SDK setup)
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./instrumentation.node');
}
}
Database spans
My withSpan wrapper tells me how long a Prisma call took, but not which SQL ran. Prisma publishes an official @prisma/instrumentation package that emits spans for queries and connection handling; it plugs into the instrumentations option of registerOTel. Check Prisma's tracing docs for the setup that matches your Prisma version, since older versions required a preview feature flag.
Sampling and cost
Tracing every request is fine locally, but in production it can get expensive with paid backends. The standard OTEL_TRACES_SAMPLER and OTEL_TRACES_SAMPLER_ARG environment variables let you sample a fraction of traces, for example parentbased_traceidratio with 0.1 to keep about 10%. I start low and raise it while actively investigating something.
Don't put secrets in attributes
Span attributes get shipped to a third party. I treat them like logs: IDs and counts are fine, tokens, emails and request bodies are not.
Key takeaways
- Next.js has OpenTelemetry built in: one instrumentation.ts with registerOTel() gets you request, render and fetch spans.
- Configure exporters with standard OTEL_* environment variables so the same code works against Jaeger locally and any vendor in production.
- Wrap important work in custom spans with startActiveSpan, always end them in finally, and record exceptions.
- Read the waterfall: sequential spans that don't depend on each other are free wins with Promise.all.
- Guard Node-only setup with NEXT_RUNTIME, sample in production, and keep sensitive data out of attributes.
Written by Jason
Published November 9, 2026 · Updated Nov 13, 2026