prom-client vs @opentelemetry/api vs clinic.js: Node.js Performance Monitoring (2026)
TL;DR
prom-client exposes Prometheus metrics from your Node.js app — request rates, latencies, error rates, custom business metrics. @opentelemetry/api is the vendor-neutral observability standard — instruments traces, metrics, and logs that export to Jaeger, Datadog, Honeycomb, or any OTLP backend. clinic.js is a profiling toolkit — diagnoses CPU bottlenecks, memory leaks, and event loop delays by recording flame graphs and heap snapshots. In 2026: use OpenTelemetry for production observability, prom-client if you're already on a Prometheus stack, and clinic.js for diagnosing performance issues locally.
Key Takeaways
- prom-client: ~3M weekly downloads — Prometheus metrics, pull model (
/metricsendpoint), counters/gauges/histograms - @opentelemetry/api: ~15M weekly downloads — CNCF standard, traces + metrics + logs, zero-code auto-instrumentation
- clinic.js: ~100K weekly downloads — local profiling, flame graphs, heap snapshots, bubble diagrams
- prom-client is best for existing Prometheus/Grafana stacks — simple, battle-tested
- OpenTelemetry auto-instrumentation instruments HTTP, DB, and Redis calls with zero code changes
- clinic.js is a development tool — find WHERE your performance problem is, then fix it
The Monitoring Stack
Observability has three pillars:
Metrics → Aggregated numbers over time
"What is the error rate?" "How many req/s?"
Tools: prom-client, OpenTelemetry metrics
Traces → Request lifecycle across services
"Where did this request spend 800ms?"
Tools: OpenTelemetry traces, Jaeger, Zipkin
Logs → Discrete events
"What happened at 14:32:15?"
Tools: pino, winston → shipped to Loki, Datadog
Profiling → Code execution analysis
"Which function is consuming 90% of CPU?"
Tools: clinic.js, 0x, Node.js --prof
→ Production monitoring: OpenTelemetry (all three pillars)
→ Prometheus stack: prom-client + Grafana
→ Local diagnosis: clinic.js
prom-client
prom-client — Prometheus metrics for Node.js:
Setup
import { Registry, Counter, Histogram, Gauge, collectDefaultMetrics } from "prom-client"
const register = new Registry()
// Auto-collect Node.js process metrics (CPU, memory, event loop lag):
collectDefaultMetrics({ register })
// Custom metrics:
const httpRequestsTotal = new Counter({
name: "http_requests_total",
help: "Total number of HTTP requests",
labelNames: ["method", "route", "status"],
registers: [register],
})
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route", "status"],
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
registers: [register],
})
const activeConnections = new Gauge({
name: "active_connections",
help: "Number of active connections",
registers: [register],
})
Express middleware
import express from "express"
const app = express()
// Instrument all routes:
app.use((req, res, next) => {
const timer = httpRequestDuration.startTimer()
res.on("finish", () => {
const route = req.route?.path ?? req.path
timer({
method: req.method,
route,
status: res.statusCode.toString(),
})
httpRequestsTotal.inc({
method: req.method,
route,
status: res.statusCode.toString(),
})
})
next()
})
// Expose /metrics endpoint for Prometheus to scrape:
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType)
res.end(await register.metrics())
})
// Business metrics:
app.get("/api/packages/:name", async (req, res) => {
const pkg = await PackageService.get(req.params.name)
if (!pkg) {
return res.status(404).json({ error: "Not found" })
}
// Track business event:
packageLookupsTotal.inc({ packageName: req.params.name })
res.json(pkg)
})
Grafana dashboard queries
# Request rate (per second, 5-minute window):
rate(http_requests_total[5m])
# p95 latency:
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
# Error rate:
rate(http_requests_total{status=~"5.."}[5m]) /
rate(http_requests_total[5m])
# Memory usage:
nodejs_heap_size_used_bytes / nodejs_heap_size_total_bytes
@opentelemetry/api
OpenTelemetry — vendor-neutral observability:
Auto-instrumentation (zero code)
npm install @opentelemetry/auto-instrumentations-node \
@opentelemetry/sdk-node \
@opentelemetry/exporter-trace-otlp-http
// instrumentation.ts — import BEFORE your app code:
import { NodeSDK } from "@opentelemetry/sdk-node"
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"
const sdk = new NodeSDK({
serviceName: "pkgpulse-api",
traceExporter: new OTLPTraceExporter({
url: "http://jaeger:4318/v1/traces",
}),
metricReader: new PeriodicExportingMetricReader({
exporter: new OTLPMetricExporter({
url: "http://prometheus-otel-collector:4318/v1/metrics",
}),
}),
// Auto-instruments: http, express, pg, mongodb, redis, grpc, etc.:
instrumentations: [getNodeAutoInstrumentations()],
})
sdk.start()
# Start with instrumentation:
node --require ./instrumentation.js dist/index.js
Manual tracing
import { trace, SpanStatusCode, context, propagation } from "@opentelemetry/api"
const tracer = trace.getTracer("pkgpulse-api", "1.0.0")
async function getPackageWithHealthScore(name: string) {
// Create a span for this operation:
return tracer.startActiveSpan(`getPackage:${name}`, async (span) => {
span.setAttribute("package.name", name)
try {
// Child spans are created automatically for DB and HTTP calls:
const pkg = await db.packages.findFirst({ where: { name } })
const npmData = await npmClient.getStats(name) // Traced automatically
const score = calculateScore(pkg, npmData)
span.setAttribute("package.health_score", score)
return { ...pkg, healthScore: score }
} catch (error) {
span.recordException(error as Error)
span.setStatus({ code: SpanStatusCode.ERROR })
throw error
} finally {
span.end()
}
})
}
Custom metrics
import { metrics } from "@opentelemetry/api"
const meter = metrics.getMeter("pkgpulse-api")
const requestCounter = meter.createCounter("http.requests", {
description: "Total HTTP requests",
})
const requestDuration = meter.createHistogram("http.request.duration", {
description: "HTTP request duration",
unit: "ms",
})
// Usage:
requestCounter.add(1, { method: "GET", route: "/api/packages/:name" })
requestDuration.record(150, { method: "GET", status: "200" })
clinic.js
clinic.js — local performance profiling:
Doctor (all-in-one diagnosis)
npm install -g clinic
# Run Doctor — detects: CPU, memory, event loop issues:
clinic doctor -- node dist/index.js
# Under load (use autocannon):
clinic doctor -- node dist/index.js &
npx autocannon -d 30 -c 50 http://localhost:3000/api/packages/react
# Opens flame graph in browser automatically
Flame (CPU profiling)
# Collect CPU flame graph:
clinic flame -- node dist/index.js
# Apply load:
npx autocannon -d 15 -c 20 http://localhost:3000/api/packages/react
# Opens flame graph — thick bars = hot code paths
Heap (memory analysis)
# Detect memory leaks:
clinic heapprofiler -- node dist/index.js
# Or with autocannon running
# Shows heap allocation over time
Bubble diagram interpretation
clinic doctor output:
⬤ CPU circle: large = CPU-bound (optimize sync code, avoid blocking)
⬤ Memory circle: large = heap growth (memory leak, cache unbounded)
⬤ Delay circle: large = event loop blocking (sync I/O, JSON.parse on large data)
⬤ Handles circle: large = too many open handles (connections not closed)
Common findings:
"I/O Issue" → Event loop blocked waiting for I/O (use async, use worker threads)
"CPU Issue" → Hot synchronous code (profile with flame, optimize the fat bars)
"Memory Issue" → Heap growing → find leak with heapprofiler
Feature Comparison
| Feature | prom-client | @opentelemetry/api | clinic.js |
|---|---|---|---|
| Metrics | ✅ | ✅ | ❌ |
| Distributed traces | ❌ | ✅ | ❌ |
| CPU profiling | ❌ | ❌ | ✅ |
| Memory profiling | ❌ | ❌ | ✅ |
| Event loop monitoring | ✅ (collectDefaultMetrics) | ✅ | ✅ |
| Auto-instrumentation | ❌ | ✅ | N/A |
| Pull model | ✅ (/metrics endpoint) | configurable | N/A |
| Push model | ❌ | ✅ (OTLP push) | N/A |
| Cloud backends | Grafana | Datadog/Honeycomb/Jaeger | Local only |
| Weekly downloads | ~3M | ~15M | ~100K |
When to Use Each
Choose prom-client if:
- Already running Prometheus + Grafana (most common ops stack)
- Need simple counter/gauge/histogram metrics with low overhead
- Team is familiar with PromQL and Grafana dashboards
Choose @opentelemetry/api if:
- Building microservices — distributed tracing is essential
- Want vendor flexibility (can switch from Jaeger to Honeycomb to Datadog)
- Need auto-instrumentation of HTTP, database, Redis with zero code
- Greenfield project — OpenTelemetry is the 2026 standard
Choose clinic.js if:
- Diagnosing a specific performance issue (high CPU, memory leak, latency spike)
- Need flame graphs to identify hot code paths
- Want to understand Node.js event loop behavior under load
- Development and debugging (not for production monitoring)
Use all three together:
clinic.js → Identify the problem (dev)
OpenTelemetry → Monitor in production (observability)
prom-client → If you need specific Prometheus metrics
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on prom-client v15.x, @opentelemetry/api v1.x, and clinic.js v13.x.