ZUA.
Home/Blog/Prometheus & Grafana
Cloud / DevOpsJune 18, 2026 · 10 min read

Monitoring Node.js Microservices with Prometheus and Grafana

Deploying a service is only half the job. Once it's running you need to know when it's slow, when it's failing, and when it's about to run out of memory — before your users tell you. Here's the full observability stack I use in production.

PrometheusGrafanaNode.jsAlertmanagerDockerMicroservices

Why Default Logging Isn't Enough

Console logs and basic uptime checks tell you a service is alive. They don't tell you it's slow, leaking memory, or that one specific endpoint has a 20% error rate. That gap is where Prometheus and Grafana come in.

  • Logs are text — hard to query trends, percentiles, or rates across services
  • Uptime checks only fire when a service is completely dead, not when it's degraded
  • Without histograms you can't see p95/p99 latency — averages hide tail latency problems
  • Cross-service correlation is impossible without a shared metrics store

Prometheus gives you a time-series metrics store. Grafana gives you dashboards and alerts on top of it. Together they answer: what is my service doing right now, and when did it start going wrong?

Architecture Overview

The full stack is three components alongside your services:

  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐
  │  Service A   │   │  Service B   │   │  Service C   │
  │ :3000/metrics│   │ :3000/metrics│   │ :3000/metrics│
  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘
         │                  │                   │
         └──────────────────┼───────────────────┘
                            ▼
                  ┌──────────────────┐
                  │    Prometheus    │  scrapes /metrics every 15s
                  │    :9090         │  stores time-series data
                  └────────┬─────────┘
                           │
               ┌───────────┴───────────┐
               ▼                       ▼
        ┌────────────┐       ┌──────────────────┐
        │   Grafana  │       │  Alertmanager    │
        │   :3000    │       │  :9093           │
        └────────────┘       └────────┬─────────┘
                                      │
                               Slack / PagerDuty

Each Node.js service exposes a /metrics endpoint in Prometheus text format. Prometheus scrapes all services on a regular interval and stores the data. Grafana queries Prometheus for dashboards. Alertmanager handles routing — warnings go to Slack, critical alerts page on-call.

Step 1 — Instrument Your Node.js Service

Install the official Prometheus client:

npm install prom-client

Create a metrics module. The default metrics give you Node.js process stats for free — CPU usage, heap, event loop lag, GC pauses. Add custom counters and histograms for your HTTP layer on top:

// metrics.ts
import client from "prom-client";

const register = new client.Registry();

// built-in: CPU, memory, event loop lag, GC
client.collectDefaultMetrics({ register, prefix: "node_" });

// custom: HTTP request counter
export const httpRequestsTotal = new client.Counter({
  name: "http_requests_total",
  help: "Total number of HTTP requests",
  labelNames: ["method", "route", "status_code"],
  registers: [register],
});

// custom: request latency histogram
export const httpRequestDuration = new client.Histogram({
  name: "http_request_duration_seconds",
  help: "HTTP request latency in seconds",
  labelNames: ["method", "route", "status_code"],
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5],
  registers: [register],
});

export { register };

Wire it into your Express app with a middleware and expose the scrape endpoint:

// app.ts
import express from "express";
import { register, httpRequestsTotal, httpRequestDuration } from "./metrics";

const app = express();

app.use((req, res, next) => {
  const end = httpRequestDuration.startTimer();
  res.on("finish", () => {
    const labels = {
      method: req.method,
      route: req.route?.path ?? req.path,
      status_code: String(res.statusCode),
    };
    httpRequestsTotal.inc(labels);
    end(labels);
  });
  next();
});

// Prometheus scrape endpoint
app.get("/metrics", async (_req, res) => {
  res.set("Content-Type", register.contentType);
  res.send(await register.metrics());
});

app.listen(3000);
SECURITY

The /metrics endpoint should not be publicly reachable. Keep it on an internal network and ensure only the Prometheus server can reach it — not your public load balancer.

Step 2 — Configure Prometheus

Prometheus is configured with a single YAML file that tells it what to scrape and how often. The simplest setup uses static targets — you list each service by hostname or IP:

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

alerting:
  alertmanagers:
    - static_configs:
        - targets: ["alertmanager:9093"]

rule_files:
  - "alerts.yml"

scrape_configs:
  - job_name: "service-a"
    static_configs:
      - targets: ["service-a:3000"]
    metrics_path: /metrics

  - job_name: "service-b"
    static_configs:
      - targets: ["service-b:3000"]
    metrics_path: /metrics

Run Prometheus with Docker alongside your services:

# docker-compose.yml (relevant excerpt)
services:
  prometheus:
    image: prom/prometheus:v2.51.0
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - ./alerts.yml:/etc/prometheus/alerts.yml
      - prometheus-data:/prometheus
    command:
      - "--config.file=/etc/prometheus/prometheus.yml"
      - "--storage.tsdb.path=/prometheus"
      - "--storage.tsdb.retention.time=15d"

volumes:
  prometheus-data:
TIP

For dynamic environments where services come and go (Kubernetes, Nomad, Consul), replace static_configs with service discovery — Prometheus has native integrations for all of them.

Step 3 — Define Alert Rules

Alert rules live in alerts.yml. These are the four I add to every Node.js service from day one:

# alerts.yml
groups:
  - name: nodejs-service
    rules:

      # Service is completely unreachable
      - alert: ServiceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.job }} is down"
          description: "Prometheus cannot scrape {{ $labels.job }}. Check if the process is running."

      # p95 latency above 500ms sustained for 5 minutes
      - alert: HighLatency
        expr: |
          histogram_quantile(0.95,
            rate(http_request_duration_seconds_bucket[5m])
          ) > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High p95 latency on {{ $labels.job }}"
          description: "p95 latency is {{ $value | humanizeDuration }} — threshold is 500ms"

      # More than 5% of requests returning 5xx
      - alert: HighErrorRate
        expr: |
          rate(http_requests_total{status_code=~"5.."}[5m])
          /
          rate(http_requests_total[5m]) > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "High error rate on {{ $labels.job }}"
          description: "{{ $value | humanizePercentage }} of requests are 5xx errors"

      # Heap memory above 80% for 10 minutes — heading toward OOM
      - alert: HighHeapUsage
        expr: |
          node_nodejs_heap_size_used_bytes
          /
          node_nodejs_heap_size_total_bytes > 0.8
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "High heap on {{ $labels.job }}"
          description: "Heap is at {{ $value | humanizePercentage }} — investigate memory leak or scale out"

Step 4 — Configure Alertmanager

Alertmanager receives firing alerts from Prometheus and routes them. Warnings go to Slack, critical alerts go to PagerDuty:

# alertmanager.yml
global:
  resolve_timeout: 5m
  slack_api_url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

route:
  group_by: ["alertname", "job"]
  group_wait: 30s       # wait 30s to batch related alerts
  group_interval: 5m
  repeat_interval: 4h
  receiver: slack-warnings
  routes:
    - match:
        severity: critical
      receiver: pagerduty-critical

receivers:
  - name: slack-warnings
    slack_configs:
      - channel: "#alerts-infra"
        title: "⚠️ {{ .GroupLabels.alertname }}"
        text: "{{ range .Alerts }}{{ .Annotations.description }}
{{ end }}"
        send_resolved: true

  - name: pagerduty-critical
    pagerduty_configs:
      - routing_key: "YOUR_PAGERDUTY_INTEGRATION_KEY"
        description: "{{ .GroupLabels.alertname }} — {{ .CommonAnnotations.summary }}"
        send_resolved: true

Run Alertmanager alongside Prometheus:

# docker-compose.yml (excerpt)
  alertmanager:
    image: prom/alertmanager:v0.27.0
    ports:
      - "9093:9093"
    volumes:
      - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml

Step 5 — Set Up Grafana

Run Grafana and provision Prometheus as a data source via config file so it persists across container restarts:

# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    isDefault: true
    editable: false
# docker-compose.yml (excerpt)
  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3001:3000"
    volumes:
      - ./grafana/provisioning:/etc/grafana/provisioning
      - grafana-data:/var/lib/grafana
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=changeme

The three dashboards I provision for every Node.js deployment:

  • Service Health Overview — up/down per service, request rate, error rate, p50/p95/p99 latency
  • Node.js Runtime — heap used vs total, event loop lag, GC pause duration, active handles
  • Infrastructure — CPU and memory per host or container
NOTE

Import dashboard ID 11159 (Node.js Application Dashboard) from grafana.com as a starting point — it covers all default prom-client metrics without any extra config.

Key PromQL Queries to Bookmark

These are the queries I reach for first when investigating an incident:

# Requests per second
rate(http_requests_total[1m])

# Error rate as a percentage
rate(http_requests_total{status_code=~"5.."}[5m])
/ rate(http_requests_total[5m]) * 100

# p95 latency
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

# Event loop lag — high value means the thread is blocked
node_nodejs_eventloop_lag_seconds

# Heap usage percentage
node_nodejs_heap_size_used_bytes / node_nodejs_heap_size_total_bytes * 100

# Active HTTP connections
node_http_server_connections_total

Production Tips

  • Use a named Docker volume for Prometheus data — anonymous volumes are wiped when the container is removed
  • Set scrape_interval to 15s for most services; drop to 5s only for critical paths (payments, auth) where faster alerting is worth the extra storage
  • Add a silence in Alertmanager before planned deployments — prevents alert fatigue from expected restarts
  • Store Slack webhook URLs and PagerDuty keys in environment variables or a secrets manager, not hardcoded in alertmanager.yml
  • Enable Prometheus remote_write to a long-term store (Thanos, VictoriaMetrics) if you need more than 15 days of history for capacity planning

Wrapping Up

The full stack — prom-client in each service, Prometheus scraping them, Alertmanager routing to Slack and PagerDuty, Grafana for dashboards — runs entirely in Docker and takes half a day to set up the first time. After that, onboarding a new service means adding one job to prometheus.yml and dropping the metrics middleware into the new app.

The payoff: instead of finding out a service is broken when a user reports it, you get a Slack message the moment error rate crosses 5% or heap is climbing toward OOM — with a direct link to the Grafana panel showing exactly when and why it started.

Need production monitoring set up for your services?

I build and ship production systems — happy to discuss your requirements.

Book a Call