Custom Prometheus Metrics in Node.js + Express (2026)
June 29, 2026
You expose Prometheus metrics in Node.js by mounting a /metrics endpoint that returns the text output of a prom-client registry. This tutorial builds one on Express: default process metrics, a custom request counter, a duration histogram, and low-cardinality labels Prometheus can scrape safely — every line run and verified before publishing.
TL;DR
Wiring Prometheus metrics in Node.js takes one registry, two custom metrics, and a single middleware. You will instrument an Express app with prom-client 15.1.31 and Express 5.2.1, exposing a /metrics endpoint that reports Node's default process metrics plus a http_requests_total counter and a http_request_duration_seconds histogram. The whole thing is two small files and about 15 minutes. The mistake that quietly wrecks naive setups — labelling by raw URL instead of the route template, which explodes your time-series count — gets fixed in Step 4, with the output reproduced to prove it.
What you'll learn
- How to create a prom-client registry and collect Node.js default metrics with
collectDefaultMetrics - How to define a custom counter (
http_requests_total) with labels - How to record request latency as a histogram (
http_request_duration_seconds) usingstartTimer - How to wire one Express middleware that records both on every request
- How to expose a
/metricsendpoint with the content type Prometheus expects - How to keep label cardinality low by using the route template, not the raw URL
- How to read and verify the Prometheus exposition format
- How to fix the common errors everyone hits with prom-client
Prerequisites
- Node.js 18+ (prom-client requires
^16 || ^18 || >=20; Express 5 requires>= 18). A current LTS — Node 20 or 22 — is recommended.1 - prom-client 15.1.3 and Express 5.2.1, pinned so your build is reproducible.
- Basic familiarity with Express routes and
async/await.
Nothing else is required: prom-client doesn't need a running Prometheus server to render metrics, so you can build and test the whole endpoint locally with curl. A real Prometheus server only matters once you want to scrape and graph what you have exposed.
Step 1 — Scaffold a Node.js + Express project
Create a project, enable ES modules, and install the two pinned packages:
mkdir prom-demo && cd prom-demo
npm init -y
npm pkg set type=module
npm install prom-client@15.1.3 express@5.2.1
Setting type=module lets you use import syntax. prom-client ships as CommonJS, but Node lets you use named imports from it, so import { Counter } from 'prom-client' works without any interop shim.2
Step 2 — Create a prom-client registry and collect default metrics
A registry is the object that holds your metrics and renders them in Prometheus's text format. Create metrics.js and start with a registry plus Node's built-in process metrics:
// metrics.js
import { Counter, Histogram, Registry, collectDefaultMetrics } from 'prom-client';
// One registry for this process. Every metric registers itself here.
export const register = new Registry();
// Optional: a label attached to every series (handy when many instances scrape).
register.setDefaultLabels({ app: 'demo-api' });
// Node/process metrics (CPU, memory, event-loop lag, GC...) — collected on scrape.
collectDefaultMetrics({ register });
collectDefaultMetrics registers a batch of recommended runtime metrics and is collected on scrape, not on a timer — prom-client reads the current values each time you call register.metrics().2 That single call gets you, among others:
process_cpu_seconds_total,process_resident_memory_bytes,process_start_time_secondsnodejs_eventloop_lag_seconds(plus_p50,_p90,_p99variants)nodejs_heap_size_total_bytes,nodejs_gc_duration_seconds,nodejs_version_info
A few process_* file-descriptor and memory metrics are Linux-only, which the prom-client docs call out explicitly.2 Call collectDefaultMetrics once per registry — calling it twice throws (more on that in Troubleshooting).
Step 3 — Define a custom counter and histogram
Default metrics tell you about the process; custom metrics tell you about your application. Add two to metrics.js. A counter only goes up, which is exactly right for "how many requests have I served";3 a histogram records a distribution, which is what you want for latency:3
// metrics.js (continued)
// A counter: total HTTP requests, split by method, route, and status code.
export const httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [register],
});
// A histogram: request duration in seconds (default buckets suit web latencies).
export const httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
registers: [register],
});
Two naming choices here are deliberate and follow Prometheus's own conventions: counters end in _total, and durations use the base unit seconds, not milliseconds.4 These two names — http_requests_total and http_request_duration_seconds — are in fact the canonical examples the Prometheus naming guide uses.4
Because no buckets array is passed, the histogram uses prom-client's defaults, which are tuned for web request latencies:2
[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] // seconds
If your latencies cluster somewhere else (say, sub-millisecond cache hits or multi-second exports), override them with buckets: [...]. Keep the list short: every bucket is its own time series, so wide bucket lists multiply storage the same way labels do.
Step 4 — Record every request with one Express middleware
Now connect the metrics to traffic. The cleanest pattern is a single middleware that starts a timer when the request arrives and records both metrics when the response finishes. Add it to metrics.js:
// metrics.js (continued)
// Express middleware: time every request, record both metrics when it finishes.
export function metricsMiddleware(req, res, next) {
const endTimer = httpRequestDuration.startTimer();
res.on('finish', () => {
// Use the ROUTE TEMPLATE (/users/:id), never req.url (/users/123) — or every
// id becomes its own time series and your cardinality explodes.
const route = req.route?.path ?? 'unmatched';
const labels = { method: req.method, route, status_code: res.statusCode };
endTimer(labels);
httpRequestsTotal.inc(labels);
});
next();
}
startTimer() returns a function; calling it later observes the elapsed seconds into the histogram and lets you attach labels you only know at the end, like the status code.2 Recording on the finish event means you capture the real status code and the full response time, including serialization.
The single most important line is req.route?.path. By the time finish fires, Express has populated req.route with the template that matched — /users/:id, not the concrete /users/123. That is what keeps the route label bounded. Requests that match no route (a 404) have no req.route, so the ?? 'unmatched' fallback collapses them into one series instead of letting every probed path become its own metric.
Step 5 — Expose the /metrics endpoint
Prometheus scrapes a plain HTTP endpoint that returns metrics in its text exposition format. Create server.js:
// server.js
import express from 'express';
import { register, metricsMiddleware } from './metrics.js';
const app = express();
app.use(metricsMiddleware);
app.get('/', (req, res) => res.send('Hello'));
app.get('/users/:id', (req, res) => res.json({ id: req.params.id }));
// Prometheus scrapes this endpoint on an interval.
app.get('/metrics', async (req, res) => {
try {
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch (err) {
res.status(500).end(err.message);
}
});
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`);
});
Two details matter. First, register.metrics() returns a promise, so it must be awaited.2 Second, register.contentType is the exact header Prometheus expects — text/plain; version=0.0.4; charset=utf-8.5 Hard-coding bare text/plain skips the version token and can trip stricter scrapers; reading it from the registry always emits the right value, including if you switch the registry to OpenMetrics format. The try/catch returns a 500 rather than a hung request if rendering ever fails.
Step 6 — Run it and scrape the metrics
Start the server and send a little traffic:
node server.js
# in another terminal:
curl -s localhost:3000/
curl -s localhost:3000/users/123
curl -s localhost:3000/users/456
curl -s localhost:3000/metrics
The /metrics response begins with the default process metrics, then your custom ones. Here is the counter section after those four requests (one of them a 404 from an unmatched path):
# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/",status_code="200",app="demo-api"} 1
http_requests_total{method="GET",route="/users/:id",status_code="200",app="demo-api"} 2
http_requests_total{method="GET",route="unmatched",status_code="404",app="demo-api"} 1
Notice the second line: /users/123 and /users/456 produced one series with a value of 2, because both matched the /users/:id template. That is the cardinality fix from Step 4, working.
Verify the Prometheus exposition format
Each metric is rendered as # HELP and # TYPE comment lines followed by samples. A histogram expands into several series per label combination: one cumulative _bucket per boundary, a _sum, and a _count.3 Here is the histogram for /users/:id:
# HELP http_request_duration_seconds Duration of HTTP requests in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{le="0.005",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.01",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.025",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.05",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.1",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.25",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="0.5",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="1",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="2.5",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="5",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="10",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_bucket{le="+Inf",app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
http_request_duration_seconds_sum{app="demo-api",method="GET",route="/users/:id",status_code="200"} 0.0011
http_request_duration_seconds_count{app="demo-api",method="GET",route="/users/:id",status_code="200"} 2
Buckets are cumulative: each _bucket{le="..."} counts every observation less than or equal to that boundary, so they only ever climb, and _bucket{le="+Inf"} always equals _count.3 Both requests landed under 5 ms, so every bucket reads 2. The _sum is wall-clock time and will differ on your machine. To turn these into a latency graph, Prometheus computes percentiles from the buckets with histogram_quantile().6
The app="demo-api" label appears on every series because of setDefaultLabels in Step 2 — a clean way to tag which service produced the data without repeating yourself on each metric.
Keep label cardinality under control
This is the section that separates a metrics setup that survives production from one that takes down your Prometheus server. Every unique combination of label values is a separate time series. The Prometheus docs are blunt about it:
Remember that every unique combination of key-value label pairs represents a new time series, which can dramatically increase the amount of data stored. Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values.4
The classic Node.js footgun is labelling requests by req.url. With req.url, /users/123, /users/124, and /users/125 become three series — and an app with a million users and a few endpoints can generate millions of series, each one multiplied again by every histogram bucket. Use the route template (req.route?.path) and the count stays equal to the number of routes you actually defined, which Step 6's output proved: two distinct user IDs, one series.
Two rules of thumb keep you safe. Keep label values bounded to a known, small set — methods, route templates, status codes, all fine; IDs, emails, and raw paths, never. And remember that each histogram emits one series per bucket plus a +Inf bucket, a _sum, and a _count — that is 14 series per label combination for the 11-bucket default — so a fat bucket list times a couple of labels multiplies faster than people expect.
Troubleshooting prom-client errors
A metric with the name http_requests_total has already been registered. You constructed the same metric name twice on the same registry. This usually means a module that defines metrics got imported twice, or a dev-server hot reload re-ran the definitions. Define each metric exactly once in a dedicated module (like metrics.js here) and import the instances elsewhere — never redefine them.2
A metric with the name process_cpu_user_seconds_total has already been registered. Same cause, but triggered by calling collectDefaultMetrics({ register }) twice against the same registry. Call it once at startup. If a hot reloader keeps re-running your entry file, guard the call or restart cleanly.
/metrics is missing your custom metrics. A labelled metric emits its # HELP and # TYPE header but no sample lines until it has been observed at least once, because prom-client cannot know the label values in advance.2 Send a request through your routes first, then scrape — or call histogram.zero({ ... }) to pre-register known label combinations. If only the default metrics have values, confirm your custom metrics passed registers: [register] (or were added to the same registry you render).
Prometheus rejects the scrape even though /metrics looks right in a browser. The body must be valid exposition text, not JSON — returning res.json(...) here will never parse. Set the header explicitly with res.set('Content-Type', register.contentType): Prometheus 3.0+ is strict and can fail a scrape whose Content-Type is missing or invalid unless you configure fallback_scrape_protocol, whereas Prometheus 2.x quietly falls back to the text parser.7 Reading the value from the registry keeps it correct across both.5
Next steps
You now have production-ready Prometheus metrics in Node.js: a registry, default process metrics, a request counter, a latency histogram, and one middleware recording all of it with bounded cardinality. Point a Prometheus server at http://localhost:3000/metrics with a 15-second scrape interval and you can start graphing request rate with rate(http_requests_total[5m]) and p95 latency with histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])).6
From here, two directions are worth taking. For request-level traces to sit alongside these aggregate metrics, add distributed tracing as shown in the OpenTelemetry Collector and Node.js tracing tutorial — metrics tell you that latency rose, traces tell you where. And to decide which metrics and alerts actually earn their keep, the principles in building a modern monitoring strategy will keep your dashboards honest. If you scrape these metrics from pods, the same /metrics endpoint is what a Prometheus ServiceMonitor targets once you run the app on Kubernetes.
Footnotes
-
prom-client — npm package (version 15.1.3, engines, license). https://www.npmjs.com/package/prom-client ↩ ↩2
-
siimon/prom-client — Prometheus client for Node.js (README, API reference). https://github.com/siimon/prom-client ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Prometheus Documentation — "Metric types" (counter, gauge, histogram;
_bucket/_sum/_count). https://prometheus.io/docs/concepts/metric_types/ ↩ ↩2 ↩3 ↩4 -
Prometheus Documentation — "Metric and label naming" (base units,
_totalsuffix, label cardinality caution). https://prometheus.io/docs/practices/naming/ ↩ ↩2 ↩3 -
Prometheus Documentation — "Exposition formats" (text format version 0.0.4, content type). https://prometheus.io/docs/instrumenting/exposition_formats/ ↩ ↩2
-
Prometheus Documentation — "Histograms and summaries" (
histogram_quantile, bucket selection). https://prometheus.io/docs/practices/histograms/ ↩ ↩2 -
Prometheus Documentation — "Scrape protocol content negotiation" (Content-Type handling,
fallback_scrape_protocol, PrometheusText0.0.4 default). https://prometheus.io/docs/instrumenting/content_negotiation/ ↩