Skip to main content

prom-client vs @opentelemetry/api vs clinic.js: Node.js Performance Monitoring (2026)

·PkgPulse Team

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 (/metrics endpoint), 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

Featureprom-client@opentelemetry/apiclinic.js
Metrics
Distributed traces
CPU profiling
Memory profiling
Event loop monitoring✅ (collectDefaultMetrics)
Auto-instrumentationN/A
Pull model✅ (/metrics endpoint)configurableN/A
Push model✅ (OTLP push)N/A
Cloud backendsGrafanaDatadog/Honeycomb/JaegerLocal 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.

Compare monitoring and observability packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.