How to Instrument Counters and Histograms in JavaScript 2026


If you want to skip the theory and start recording metrics immediately, the metrics quickstart guide will get you from zero to a working dashboard in minutes.

What Is a Counter?

A counter is a cumulative metric that only goes up. Its value increases monotonically and resets to zero when the process restarts. That’s the entire contract.

You use counters to measure things like total HTTP requests served, errors encountered, or background jobs completed. The “only goes up” constraint is what makes counters useful: because the value never decreases during normal operation, monitoring tools can compute accurate rates over time using functions like rate() or increase().

Here’s what a counter looks like in OpenTelemetry’s JavaScript SDK:

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('my-app');
const requestCounter = meter.createCounter('http.requests.total', {
  description: 'Total number of HTTP requests',
});

// In your request handler:
requestCounter.add(1, { method: 'GET', route: '/api/users' });

And the equivalent using prom-client, the most popular Prometheus client library for Node.js:

const client = require('prom-client');

const requestCounter = new client.Counter({
  name: 'http_requests_total',
  help: 'Total number of HTTP requests',
  labelNames: ['method', 'route'],
});

// In your request handler:
requestCounter.inc({ method: 'GET', route: '/api/users' });

Both patterns do the same thing: define a named counter and increment it. The API surface is small by design.

A nuance most guides skip

Practitioner Jason Stitt points out something worth understanding: counters and histograms are both client-side concepts. From the perspective of the Prometheus server, they’re really just ways of organizing gauges. The “only goes up” semantic is enforced by the client library, not the storage layer. This matters because it explains why using the wrong type (say, a gauge for request counts) doesn’t produce an obvious error. It just produces wrong answers when you query source.

For a deeper walkthrough of instrumenting counters on Cloudflare Workers specifically, see instrumenting Workers with counters.

What Is a Histogram?

A histogram measures how values are distributed over time. Instead of telling you “the average response time is 100ms” (which hides important information), a histogram shows you how many observations fell into each of several predefined ranges, called buckets.

Under the hood, a Prometheus-style histogram is actually a collection of counters. It exposes several time series: one counter per bucket (with a _bucket suffix), a _sum counter that accumulates the total of all observed values, and a _count counter that tracks how many observations were recorded source.

Consider a payment service processing transactions. Most complete in 50ms, but some hit fraud detection and take 3 seconds. The average latency is 100ms, which looks fine. But 5% of users wait 3 seconds, and they’re frustrated. A histogram reveals this tail behavior. An average hides it.

Here’s how to instrument a histogram in JavaScript using OpenTelemetry:

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('my-app');
const latencyHistogram = meter.createHistogram('http.server.duration', {
  description: 'Request latency in milliseconds',
  unit: 'ms',
});

// In your request handler:
const start = performance.now();
// ... handle request ...
const duration = performance.now() - start;
latencyHistogram.record(duration, { method: 'GET', route: '/api/users' });

With prom-client:

const client = require('prom-client');

const latencyHistogram = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Request latency in seconds',
  labelNames: ['method', 'route'],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10],
});

// In your request handler:
const end = latencyHistogram.startTimer({ method: 'GET', route: '/api/users' });
// ... handle request ...
end();

The startTimer() pattern in prom-client is convenient: it returns a function that, when called, records the elapsed time automatically.

Counter vs. Histogram: When to Use Which

The choice between counters and histograms isn’t about preference. It’s about what operational question you need to answer. If you need a rate, start with a counter and derive the rate from it. If you need percentiles, tail behavior, or bucket-based SLO math, use a histogram.

Here’s a practical decision table:

Use Case Metric Type Why
Total HTTP requests Counter Only goes up; derive request rate with rate()
Request latency distribution Histogram Need percentiles (p50, p95, p99)
Current memory usage Gauge Fluctuates up and down
Response payload sizes Histogram Distribution matters more than the total
Error count Counter Monotonically increasing; rate reveals spikes
Active WebSocket connections Gauge Goes up and down with connection lifecycle
Queue processing times Histogram Tail latency reveals bottlenecks

The rule of thumb: if you catch yourself wanting to run rate() on it, make it a counter. If you catch yourself wanting p95 or p99 values, make it a histogram. For a broader overview of metrics types and best practices, that guide covers the full picture.

Other Metric Types at a Glance

Gauge

A gauge measures a value that goes up and down. Unlike counters, gauges reflect the current state, not accumulated totals. CPU usage, memory consumption, active connections, and queue depth are all gauges.

The critical mistake to avoid: using a gauge to count events like HTTP requests. Gauges don’t track history, so you lose the ability to compute accurate trends. The rate() function won’t work correctly on a gauge because it assumes monotonic increases with known resets source.

Summary

Summaries calculate quantiles (like p50, p95) on the client side before sending data. This sounds convenient, but it creates a serious problem: you can’t aggregate summaries across multiple instances. If you have three servers each reporting their own p99, averaging those three numbers doesn’t give you the true p99 of the combined traffic.

Histograms keep more flexibility because the central system can recompute percentiles from the raw buckets. If you need percentiles across multiple instances, stick with histograms. They work with PromQL functions like histogram_quantile() and can be summed across labels and services.

JavaScript Libraries for Instrumentation

When learning how to instrument counters and histograms in JavaScript, you have several library options. The right choice depends on your runtime and backend.

OpenTelemetry JS SDK

OpenTelemetry is the vendor-neutral standard for instrumentation. It supports counters, histograms, and gauges through the @opentelemetry/api and @opentelemetry/sdk-metrics packages. The advantage is portability: you write instrumentation once and can export to Prometheus, Jaeger, Datadog, or any OTLP-compatible backend.

The tradeoff is complexity. OpenTelemetry requires setting up a meter provider, an exporter, and often a collector. For a full application with multiple signal types (metrics, traces, logs), this investment pays off. For a small service that just needs a few counters, it can feel heavy.

prom-client for Node.js

If your backend is Prometheus, prom-client is the most direct path. It supports all four Prometheus metric types and exposes a /metrics endpoint that Prometheus scrapes. The API is straightforward and the library is mature.

Default histogram buckets in prom-client follow Prometheus conventions: .005, .01, .025, .05, .075, .1, .25, .5, .75, 1, 2.5, 5, 7.5, 10 (in seconds) source. These defaults work for many HTTP latency scenarios but should be customized for your specific SLOs.

Push-based approaches for serverless

Both OpenTelemetry and prom-client assume a long-running process. This assumption breaks in serverless environments. Push-based metrics services let you send data over HTTP during request handling, which works with any runtime lifecycle.

Distlang Metrics takes this approach. It provides a lightweight JavaScript client that records counters and histograms, then flushes them over HTTP. There are no agents to install and no scrapers to configure.

For a complete walkthrough of the setup, the end-to-end metrics example shows the full workflow from code to a working dashboard.

StatsD

StatsD is a front-end proxy originally written in Node.js by Etsy. You send metrics as UDP packets, which a StatsD server aggregates and forwards to a backend like Graphite. It’s simple and battle-tested, but the UDP-based approach doesn’t work in environments without network socket access, like V8 isolate runtimes source.

Instrumenting Counters and Histograms in Serverless and Edge Runtimes

This is where most instrumentation guides fall apart. Traditional APM agents cannot run in Cloudflare Workers or Vercel Edge Functions because these runtimes are not Node.js. There is no file system, no native modules, and no long-running process to attach to source.

The pull vs. push problem

Prometheus uses a pull model: it scrapes a /metrics endpoint at regular intervals. This works for long-running servers. It fails for serverless functions that might start, handle a request, and terminate before the next scrape interval arrives.

A developer on the Prometheus mailing list articulated this clearly: “The problem I face is the fundamental mismatch of Prometheus’ pull architecture and the serverless runtime which doesn’t even allow me to see individual container instances.” Practitioners on forums and blogs repeatedly confirm this tension. A process that terminates before the next scrape interval will silently lose its metrics source.

Push-based ingestion solves this. Instead of waiting for a scraper, your code sends metrics to an HTTP endpoint before the handler exits.

Flush semantics matter

The key pattern for instrumenting counters and histograms in JavaScript serverless functions is non-blocking flush. On Cloudflare Workers, you use ctx.waitUntil() to keep the isolate alive long enough to send metrics after the response has been returned:

export default {
  async fetch(request, env, ctx) {
    const start = performance.now();
    const response = await handleRequest(request);
    const duration = performance.now() - start;

    // Record metrics
    metrics.counter('http.requests.total', 1, { method: request.method });
    metrics.histogram('http.request.duration', duration);

    // Flush without blocking the response
    ctx.waitUntil(metrics.flush());
    return response;
  }
};

On Vercel, the equivalent is the after() function in Next.js, which runs code after the response has been sent to the client.

For detailed patterns on this topic, the guide on waitUntil flush semantics covers the Cloudflare side, and the Vercel metrics quickstart covers the Next.js side.

Understanding buffering and flush behavior is also important, since misconfigured flush timing is a common cause of missing metrics in short-lived handlers.

Histogram Bucket Design: Practical Tips

Histograms are powerful, but misconfigured buckets quietly cause problems. From bloated storage to misleading percentile calculations, bucket design is where teams get burned.

Start with 8 to 10 buckets

More buckets mean more time series. Each bucket creates a separate time series, and with labels, the count multiplies fast. The formula is straightforward:

Total time series = (buckets + 2) x label_combinations

So a histogram with 10 buckets and 3 label combinations produces 36 time series. Change that to 20 buckets across 50 label combinations, and you’re at 1,100 time series from a single metric source.

Start with 8 to 10 buckets and add more only if you need finer resolution at specific ranges.

Use exponential spacing, not linear

Linear buckets (100ms, 200ms, 300ms, 400ms) rarely match the shape of real latency distributions, which are almost always skewed with a long tail. Exponential spacing (10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s) covers a wider range with fewer buckets and gives you better resolution where it matters.

Align buckets to your SLOs

If your p99 target is 500ms but your nearest bucket boundary is at 1000ms, you cannot accurately measure whether you are meeting your SLO. Place bucket boundaries at or near your SLO thresholds.

Keep label cardinality low

Adding labels like user_id, request_path with wildcards, or session_id to histograms creates unbounded cardinality. Use these identifiers in traces and logs, not metrics. Good histogram labels include things like HTTP method, status code class (2xx, 4xx, 5xx), and route template (not the resolved path).

Common Instrumentation Mistakes

Knowing how to instrument counters and histograms in JavaScript is not just about the API calls. It’s about avoiding patterns that produce misleading data.

Using a gauge where a counter belongs. If you track HTTP request counts with a gauge and inc(), you break reset detection. The rate() and increase() functions assume counter semantics. Your dashboards will render, but the numbers will be wrong.

Ignoring the +Inf bucket. Every Prometheus histogram has a final +Inf bucket that catches all observations. If your largest explicit bucket is 10 seconds, every observation above 10 seconds still gets counted in +Inf. If you’re not checking this bucket, you might miss a population of extreme outliers.

Not flushing metrics before handler exit. In serverless functions, if you record metrics but don’t flush them (or don’t use waitUntil/after to keep the process alive), the data never reaches your backend. This is the single most common source of missing metrics in edge runtimes.

Averaging when you should be bucketing. An average response time of 100ms can mask the fact that 5% of users experience 3-second latency. If you care about user experience at the tails, you need a histogram, not a counter divided by another counter.

High-cardinality labels on any metric type. Adding a label with unbounded values (user IDs, full URLs, trace IDs) creates a new time series for every unique value. This leads to memory growth, slow queries, and higher storage costs. One observability blog notes this creates “a multiplicative effect on histogram cardinality” that makes the problem especially severe for histograms with many buckets source.

Putting It All Together

The process of instrumenting counters and histograms in JavaScript boils down to three decisions:

  1. What operational question am I answering? This determines the metric type.
  2. What runtime am I targeting? This determines the library and flush strategy.
  3. What labels do I need? This determines your cardinality budget.

Get these three right and you’ll have metrics that actually answer the questions your team asks during incidents. Get them wrong and you’ll have dashboards that look busy but mislead you when it counts.

For JavaScript developers working in serverless or edge runtimes, push-based metrics services remove the biggest friction point. If that describes your stack, Distlang Metrics is purpose-built for this scenario, with a free tier that supports 500k rows per month and gets you from code to dashboard in minutes.

FAQ

What is the difference between a counter and a histogram in JavaScript metrics?

A counter tracks a single value that only goes up, like total requests or error counts. A histogram tracks the distribution of values across configurable buckets, like how many requests took 0-100ms, 100-500ms, or 500ms+. Use counters when you need rates. Use histograms when you need percentiles.

Can I use prom-client in Cloudflare Workers or Vercel Edge Functions?

Not directly. prom-client relies on a long-running Node.js process and exposes a /metrics endpoint for Prometheus to scrape. Edge runtimes like Cloudflare Workers run in V8 isolates without Node.js APIs. You need a push-based approach that sends metrics over HTTP before the handler exits.

How many histogram buckets should I use?

Start with 8 to 10 buckets using exponential spacing. Each bucket creates a separate time series, and the total count multiplies with every label combination. More buckets increase storage cost and query time. Add buckets only when you need finer resolution around specific SLO thresholds.

Why shouldn’t I use averages instead of histograms for latency?

Averages hide tail latency. If 95% of requests complete in 50ms but 5% take 3 seconds, the average is roughly 200ms, which sounds acceptable. The histogram reveals that a significant portion of users experience unacceptable latency. SLO calculations and incident detection depend on percentiles, not averages.

What is the waitUntil pattern for metrics in serverless functions?

waitUntil() is a Cloudflare Workers API that keeps the isolate alive after the response has been sent. You pass it a promise (like metrics.flush()) so your metrics data reaches the backend without adding latency to the user’s response. Vercel’s equivalent is the after() function in Next.js.

Should I use OpenTelemetry or prom-client for Node.js metrics?

If you’re committed to Prometheus as your backend and run long-lived Node.js servers, prom-client is simpler and more direct. If you want vendor neutrality, plan to add tracing later, or might switch backends, OpenTelemetry is the better long-term investment. For serverless JavaScript, neither is ideal without additional configuration for push-based export.

What are high-cardinality labels and why do they matter?

A high-cardinality label is one with many unique values, like user IDs or full request URLs. Each unique label combination creates a new time series. On a histogram with 10 buckets, a label with 1,000 unique values creates 12,000 time series from a single metric. This causes memory bloat, slow queries, and higher costs. Keep metric labels to bounded sets like HTTP method, status code class, and route templates.

How do I instrument counters and histograms in JavaScript if my app is serverless?

Use a push-based metrics client that sends data over HTTP. Record your counter increments and histogram observations during request handling, then flush the data using ctx.waitUntil() (Cloudflare) or after() (Vercel) so the export happens without blocking the response. The JavaScript metrics client docs show this pattern step by step.