---
title: Custom Prometheus Metrics in Node.js + Express (2026)
description: 'Add Prometheus metrics to a Node.js + Express app with prom-client: a /metrics endpoint, custom counters and histograms, and safe low-cardinality labels.'
url: https://nerdleveltech.com/prometheus-metrics-nodejs-express-prom-client-tutorial
language: en
date: '2026-06-29'
category: cloud-devops
tags:
  - prometheus
  - node.js
  - prom-client
  - express
  - observability
  - metrics
  - cloud-devops
markdown_url: https://nerdleveltech.com/prometheus-metrics-nodejs-express-prom-client-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/prometheus-metrics-nodejs-express-prom-client-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Custom Prometheus Metrics in Node.js + Express (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.3**[^npm] 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`) using `startTimer`
- How to wire one Express middleware that records both on every request
- How to expose a `/metrics` endpoint 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.[^npm]
- **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:

```bash
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.[^promclient]

## 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:

```js
// 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()`.[^promclient] That single call gets you, among others:

- `process_cpu_seconds_total`, `process_resident_memory_bytes`, `process_start_time_seconds`
- `nodejs_eventloop_lag_seconds` (plus `_p50`, `_p90`, `_p99` variants)
- `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.[^promclient] 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";[^types] a **histogram** records a distribution, which is what you want for latency:[^types]

```js
// 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.[^naming] These two names — `http_requests_total` and `http_request_duration_seconds` — are in fact the canonical examples the Prometheus naming guide uses.[^naming]

Because no `buckets` array is passed, the histogram uses prom-client's defaults, which are tuned for web request latencies:[^promclient]

```text
[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`:

```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.[^promclient] 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`:

```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.[^promclient] Second, `register.contentType` is the exact header Prometheus expects — `text/plain; version=0.0.4; charset=utf-8`.[^exposition] 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:

```bash
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):

```text
# 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`.[^types] Here is the histogram for `/users/:id`:

```text
# 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`.[^types] 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()`.[^histograms]

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.[^naming]

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.[^promclient]

**`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.[^promclient] 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.[^negotiation] Reading the value from the registry keeps it correct across both.[^exposition]

## 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]))`.[^histograms]

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](https://nerdleveltech.com/opentelemetry-collector-nodejs-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](https://nerdleveltech.com/building-a-modern-monitoring-strategy-that-actually-works) 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](https://nerdleveltech.com/resize-kubernetes-pods-without-restart).

[^promclient]: siimon/prom-client — Prometheus client for Node.js (README, API reference). https://github.com/siimon/prom-client
[^npm]: prom-client — npm package (version 15.1.3, engines, license). https://www.npmjs.com/package/prom-client
[^types]: Prometheus Documentation — "Metric types" (counter, gauge, histogram; `_bucket`/`_sum`/`_count`). https://prometheus.io/docs/concepts/metric_types/
[^naming]: Prometheus Documentation — "Metric and label naming" (base units, `_total` suffix, label cardinality caution). https://prometheus.io/docs/practices/naming/
[^histograms]: Prometheus Documentation — "Histograms and summaries" (`histogram_quantile`, bucket selection). https://prometheus.io/docs/practices/histograms/
[^exposition]: Prometheus Documentation — "Exposition formats" (text format version 0.0.4, content type). https://prometheus.io/docs/instrumenting/exposition_formats/
[^negotiation]: Prometheus Documentation — "Scrape protocol content negotiation" (Content-Type handling, `fallback_scrape_protocol`, PrometheusText0.0.4 default). https://prometheus.io/docs/instrumenting/content_negotiation/
