performance-probe
v1.4.0
Published
Lightweight, zero-dependency Express middleware for server latency profiling — p50/p95/p99, slow-request capture, event-loop lag, GC tracking.
Maintainers
Readme
performance-probe
Lightweight, zero-dependency Express middleware for server latency profiling.
What it gives you
| Feature | Description |
|---|---|
| Per-route percentiles | p50 / p95 / p99 / max / avg, sorted by p95 desc |
| HTML dashboard | Visual report with color-coded health, progress bars, search/filter controls, customizable columns, and a diagnosis guide |
| Search & Filtering | Instant client-side search by route paths and filtering by HTTP method or traffic status (Active, Inactive, Slow Only) |
| Custom Column Views | Keeps the dashboard simple and clean by hiding percentiles (p50, p95, p99) by default. Toggle them with checkboxes anytime! |
| 🧹 Reset Stats | Clear accumulated metrics directly from the dashboard UI with one click without restarting the server |
| Built-in auth | API key required to access dashboard — auto-generated or via env var |
| Slow-request capture | Full context: concurrent reqs, event-loop lag, memory, correlation ID |
| Queue-time detection | Measures nginx/LB → app delay from X-Request-Start header |
| Server diagnostics | CPU load, memory pressure, heap %, uptime — with auto-verdict |
| Event-loop lag | monitorEventLoopDelay histogram, reset per slow event |
| GC pause tracking | PerformanceObserver on gc entries, correlated to slow requests |
| Downstream isolation | Wrap DB/API calls to see if lag is yours or a dependency |
| Route pre-scanning | probe.scan(app) discovers all routes before they're hit |
| Correlation ID | Auto-generates/propagates x-request-id |
| Zero dependencies | Single file, no node_modules needed |
Install
npm i performance-probeUsage
CommonJS (require)
const createProbe = require('performance-probe');ES Modules (import)
import createProbe from 'performance-probe';
// or
import { createProbe } from 'performance-probe';Both formats work out of the box — no config needed.
Quick Start
const express = require('express');
const createProbe = require('performance-probe');
const probe = createProbe({ slowThresholdMs: 300 });
const app = express();
// Mount FIRST — before body parsers, auth, etc.
app.use(probe.middleware);
// Dashboard endpoint (protected by API key automatically)
app.get('/latency-report', probe.reportRoute);
// ... your existing routes ...
// After all routes are defined:
probe.scan(app);
app.listen(3000);On startup you'll see:
[probe] Dashboard key: a1b2c3d4e5f6a1b2c3d4e5f6
[probe] Access: /latency-report?key=a1b2c3d4e5f6a1b2c3d4e5f6Authentication
The dashboard is protected by default. No one can access it without the key.
How to access
# Browser
https://yourapi.com/latency-report?key=YOUR_KEY
# API / curl
curl -H "x-probe-key: YOUR_KEY" https://yourapi.com/latency-report?format=jsonSet a fixed key (recommended for production)
Set the PROBE_KEY environment variable:
PROBE_KEY=my-secret-probe-key-123 node server.jsOr pass it directly:
const probe = createProbe({
slowThresholdMs: 300,
reportKey: 'my-secret-probe-key-123'
});If you don't set one, a random key is auto-generated on each restart and printed to console.
Performance Overhead
The probe adds < 0.05ms per request. Here's why:
| What it does per request | Cost |
|---|---|
| process.hrtime.bigint() — start timer | ~1 nanosecond |
| ++inFlight — increment a counter | ~1 nanosecond |
| res.on('finish', fn) — register callback | negligible |
| next() — your route runs normally | 0 |
| Subtraction + division on finish | ~1 nanosecond |
| ring.push() — write to pre-allocated typed array | O(1), no allocation |
What it does NOT do in the hot path:
- ❌ No
JSON.stringify - ❌ No file I/O
- ❌ No network calls
- ❌ No array growth or reallocation
- ❌ No sorting (only on dashboard load)
- ❌ No logging (only for slow requests, via
setImmediate)
The probe's overhead is 6,000x smaller than a typical 300ms API response.
Nginx Setup (for queue-time detection)
Add this to your nginx location block:
proxy_set_header X-Request-Start "t=${msec}";The probe auto-detects this header and tracks how long requests sat in nginx before reaching your app. Optional — everything else works without it.
API
createProbe(options?)
| Option | Default | Description |
|---|---|---|
| slowThresholdMs | 500 | Requests above this are flagged slow |
| ringSize | 500 | Max samples per route (ring buffer) |
| reportKey | auto-generated | API key for dashboard access (or set PROBE_KEY env var) |
| reportAuth | null | Legacy: fn(req) → bool gate for report endpoint |
| enableGC | true | Track GC pause durations |
| enableCorrelationId | true | Auto x-request-id |
| enableQueueTime | true | Detect nginx/LB queue time |
| enableServerInfo | true | Include server diagnostics in full report |
| instanceId | $HOSTNAME | Tag for multi-instance deploys |
| onSlow | console.warn | Custom handler fn(slowCtx) |
Instance methods
| Method | Description |
|---|---|
| probe.middleware | Express middleware — mount first |
| probe.report() | Route stats array (sorted by p95 desc) |
| probe.summary() | Quick health-check object |
| probe.serverInfo() | Full server diagnostics + verdict |
| probe.queueReport() | Nginx/LB queue time percentiles |
| probe.reportRoute | Express route handler for the dashboard |
| probe.scan(app) | Pre-register all Express routes (call after routes are defined) |
| probe.wrapCall(label, fn) | Returns wrapped async fn with latency tracking |
| probe.downstreamReport() | Stats for wrapped downstream calls |
| probe.gcReport() | Recent GC pause entries |
| probe.reset() | Clear all stats |
Glossary
| Term | Meaning | |---|---| | p50 | Median — half your requests are faster than this | | p95 | 95% are faster — what your frustrated users experience | | p99 | 99% are faster — worst-case outliers | | Avg | Simple average — hides spikes, use p95 instead | | Max | Single slowest request ever recorded | | Slow Count | Requests above your threshold — each logged with full context | | In-Flight | Requests currently being processed | | Event Loop Lag | How long callbacks wait to run. High = blocking code | | Queue Time | Nginx/LB → app delay. High = nginx is the bottleneck | | RSS | Total memory your process uses. Climbing = leak | | Heap % | V8 heap fullness. >85% = GC thrashing = lag spikes | | GC Pause | Garbage collection freeze. Long pauses = memory pressure | | Load/Core | CPU load ÷ cores. >1.0 = saturated | | Verdict | Auto-diagnosis flag: HEALTHY, CPU_HIGH, CPU_SATURATED, etc. |
Diagnosis Guide
| Symptom | Check | Likely Cause |
|---|---|---|
| High queue time, low duration | Queue Time section | Nginx/LB overloaded — not your app |
| High duration, high downstream p95 | Downstream report | DB or external API is slow |
| High duration, low downstream p95 | Event loop lag | Your code is blocking |
| Slow requests cluster after GC | GC report timestamps | Memory pressure / leak |
| verdict: CPU_SATURATED | Server info | Server overloaded — scale up/out |
| verdict: MEMORY_PRESSURE | Server info | Swapping — reduce usage or scale |
| verdict: HEAP_PRESSURE | Server info | GC thrashing — memory leak likely |
Downstream Call Wrapping
const findOrder = probe.wrapCall('db:findOrder', async (id) => {
return db.orders.findById(id);
});
// Use normally — latency tracked automatically
const order = await findOrder('order-123');Caveats
- In-memory only — resets on restart. Persist
probe.report()on an interval for history. - Per-instance — in multi-pod setups, use
instanceIdand aggregate externally. - Queue time requires header — add
proxy_set_header X-Request-Start "t=${msec}";to nginx.
