How to Track Usage Metrics in a Multi-Tenant SaaS Application (Node.js, Redis & Postgres)
Sooner or later every SaaS team gets the same three questions: "How much is tenant X actually using?", "Can we enforce plan limits?", and "Why did this customer's bill change?"
If you have no usage metering by then, the answers come from log greps and guesswork. If you built it hastily, they come from numbers nobody trusts.
This is especially painful in automation products, where usage is bursty. One tenant's workflow can trigger 50,000 runs overnight while another triggers 12 all month.
In this tutorial you'll build a per-tenant usage tracking pipeline that is:
- Accurate: every event is attributed to exactly one tenant
- Cheap on the hot path: it adds microseconds to each request, not milliseconds
- Queryable: you can power dashboards, quotas, and usage-based billing from the same data
Table of Contents
- Prerequisites
- Step 1: Define what you meter
- Step 2: Design a tenant-safe event schema
- Step 3: Capture usage with API middleware
- Step 4: Count in Redis, persist in Postgres
- Step 5: Expose usage through an API
- Step 6: Enforce quotas
- Common pitfalls
- Conclusion
Prerequisites
You'll get the most out of this guide if you have:
- [ ] Node.js 18+ and basic Express knowledge
- [ ] PostgreSQL 14+ (any managed instance works)
- [ ] Redis 6+
- [ ] A multi-tenant app where every request can be resolved to a
tenant_id(via API key, JWT claim, or subdomain)
Note: The patterns here are language-agnostic. Node.js is used for the examples, but the same design works in Go, Python, or Java.
Step 1: Define what you meter
The most common mistake is metering everything. Start with a short list of billable or limit-relevant units. For an automation product, that might be:
| Metric key | What it counts | Typical use |
|---|---|---|
workflow.run |
Completed workflow executions | Plan limits, billing |
message.sent |
Outbound messages across channels | Billing, abuse detection |
api.request |
Authenticated API calls | Rate limits, analytics |
ai.tokens |
LLM tokens consumed | Cost pass-through |
seat.active |
Active users in the last 30 days | Seat-based pricing |
Two rules keep this list healthy:
- Every metric has an owner and a purpose. If nobody uses it for billing, limits, or product decisions, don't collect it.
- Metric keys are an API. Once customers see them on invoices or dashboards, renaming them is a breaking change.
Step 2: Design a tenant-safe event schema
Every usage event should be a small, immutable record. The schema below works for almost any product:
CREATE TABLE usage_events (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
metric TEXT NOT NULL,
quantity BIGINT NOT NULL DEFAULT 1,
idempotency_key TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (tenant_id, idempotency_key)
);
CREATE INDEX idx_usage_events_tenant_metric_time
ON usage_events (tenant_id, metric, occurred_at DESC);
Why it looks this way:
-
tenant_idisNOT NULLand leads every index. Every query is tenant-scoped by default. -
idempotency_key+ a unique constraint gives you safe retries. Queues redeliver messages, and without this you will double-count. -
quantityis a number, not an implied1. Tokens, bytes, and batch sends all need it. -
metadatais JSONB for non-billing context such as channel, workflow ID, and region.
For row-level isolation, add Postgres Row-Level Security so a bug in one query can't leak another tenant's data:
ALTER TABLE usage_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON usage_events
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Then set app.current_tenant at the start of each request or transaction.
Step 3: Capture usage with API middleware
The goal is to record usage without slowing down the request. Resolve the tenant once, attach it to the request, and emit the event after the response is sent.
// middleware/usage.js
import { randomUUID } from "node:crypto";
import { recordUsage } from "../usage/recorder.js";
export function trackApiUsage(req, res, next) {
// tenant is resolved earlier by your auth middleware
const tenantId = req.tenant?.id;
if (!tenantId) return next();
res.on("finish", () => {
// Only count successful or client-billable requests
if (res.statusCode >= 500) return;
recordUsage({
tenantId,
metric: "api.request",
quantity: 1,
idempotencyKey: req.headers["x-request-id"] ?? randomUUID(),
metadata: { route: req.route?.path, method: req.method },
});
});
next();
}
For non-HTTP work like background jobs and workflow runs, call the same recordUsage function from the worker:
// workers/runWorkflow.js
await executeWorkflow(job.data);
await recordUsage({
tenantId: job.data.tenantId,
metric: "workflow.run",
quantity: 1,
// job.id is stable across retries, which makes it a perfect idempotency key
idempotencyKey: `workflow.run:${job.id}`,
metadata: { workflowId: job.data.workflowId },
});
Tip: Derive idempotency keys from something that is stable across retries (a job ID, a message ID), never from a timestamp or a fresh random value.
Step 4: Count in Redis, persist in Postgres
Writing one Postgres row per event works until it doesn't. A better pattern is a hot counter in Redis plus a durable event log:
- Redis answers "how much has this tenant used this month?" in under a millisecond, which is what quota checks need.
- Postgres is the source of truth for audits, invoices, and recomputation.
// usage/recorder.js
import Redis from "ioredis";
import { usageQueue } from "./queue.js";
const redis = new Redis(process.env.REDIS_URL);
const periodKey = (tenantId, metric, date = new Date()) => {
const period = date.toISOString().slice(0, 7); // e.g. "2026-10"
return `usage:${tenantId}:${metric}:${period}`;
};
export async function recordUsage(event) {
const { tenantId, metric, quantity = 1 } = event;
// 1. Hot path: atomic increment (microseconds)
const key = periodKey(tenantId, metric);
await redis.incrby(key, quantity);
await redis.expire(key, 60 * 60 * 24 * 40); // keep ~40 days
// 2. Durable path: hand off to a queue for batched persistence
await usageQueue.add("persist", event, {
attempts: 5,
backoff: { type: "exponential", delay: 1000 },
removeOnComplete: true,
});
}
The consumer batches inserts and relies on the unique constraint for deduplication:
// usage/persist.js
import { pool } from "../db.js";
export async function persistBatch(events) {
const values = [];
const placeholders = events.map((e, i) => {
const o = i * 6;
values.push(
e.tenantId, e.metric, e.quantity,
e.idempotencyKey, e.occurredAt ?? new Date(), JSON.stringify(e.metadata ?? {})
);
return `($${o + 1}, $${o + 2}, $${o + 3}, $${o + 4}, $${o + 5}, $${o + 6})`;
});
await pool.query(
`INSERT INTO usage_events
(tenant_id, metric, quantity, idempotency_key, occurred_at, metadata)
VALUES ${placeholders.join(",")}
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING`,
values
);
}
Keep Redis and Postgres honest
Redis counters can drift if a process crashes between the INCRBY and the queue write. Run a nightly reconciliation job that recomputes totals from Postgres and overwrites the Redis counters:
SELECT tenant_id, metric, SUM(quantity) AS total
FROM usage_events
WHERE occurred_at >= date_trunc('month', now())
GROUP BY tenant_id, metric;
Postgres wins every disagreement. Redis is a fast cache, not a ledger.
Step 5: Expose usage through an API
Customers (and your own dashboard) need to read usage. Pre-aggregate into daily rollups so charts stay fast as the event table grows:
CREATE TABLE usage_daily (
tenant_id UUID NOT NULL,
metric TEXT NOT NULL,
day DATE NOT NULL,
total BIGINT NOT NULL,
PRIMARY KEY (tenant_id, metric, day)
);
-- Run hourly; safe to re-run
INSERT INTO usage_daily (tenant_id, metric, day, total)
SELECT tenant_id, metric, occurred_at::date, SUM(quantity)
FROM usage_events
WHERE occurred_at >= now() - interval '2 days'
GROUP BY 1, 2, 3
ON CONFLICT (tenant_id, metric, day)
DO UPDATE SET total = EXCLUDED.total;
Then a tenant-scoped endpoint:
// routes/usage.js
router.get("/v1/usage", async (req, res) => {
const { metric, from, to } = req.query;
const { rows } = await pool.query(
`SELECT day, total
FROM usage_daily
WHERE tenant_id = $1 AND metric = $2 AND day BETWEEN $3 AND $4
ORDER BY day`,
[req.tenant.id, metric, from, to]
);
res.json({ metric, data: rows });
});
Notice that tenant_id comes from req.tenant, never from a query parameter. This is the single most important rule in multi-tenant APIs.
Step 6: Enforce quotas
With a fast counter you can enforce plan limits at the edge of your API:
// middleware/quota.js
const PLAN_LIMITS = {
free: { "workflow.run": 1_000 },
growth: { "workflow.run": 50_000 },
};
export function enforceQuota(metric) {
return async (req, res, next) => {
const limit = PLAN_LIMITS[req.tenant.plan]?.[metric];
if (limit === undefined) return next(); // unlimited
const used = Number(await redis.get(periodKey(req.tenant.id, metric))) || 0;
res.set("X-Usage-Limit", String(limit));
res.set("X-Usage-Remaining", String(Math.max(limit - used, 0)));
if (used >= limit) {
return res.status(429).json({
error: "quota_exceeded",
message: `Monthly limit for ${metric} reached.`,
});
}
next();
};
}
Two design choices worth making deliberately:
- Soft vs. hard limits. Many products warn at 80%, notify at 100%, and block only well past the limit. Hard blocking an automation tenant mid-run can be worse than the overage.
- Expose remaining quota in headers. Developers integrating with your API will thank you.
Common pitfalls
- Time zones. Store everything in UTC and decide up front what "month" means for billing. Per-tenant billing anchors cause real pain later.
- Double counting on retries. If your idempotency key isn't stable, your invoices will be wrong.
- Noisy-neighbor writes. One tenant's spike shouldn't delay everyone's metering. Use per-tenant rate limits and queue partitioning if needed.
- Unbounded metadata cardinality. Don't put user IDs or full URLs in labels you plan to group by.
- No backfill story. Keep raw events long enough to recompute totals when you fix a bug.
- Metering in the request path synchronously. If Postgres hiccups, your API shouldn't go down with it.
Conclusion
A reliable usage metering system doesn't need a heavyweight platform. The core ideas are:
- Meter a small, intentional set of metrics
- Make every event tenant-scoped and idempotent
- Count fast in Redis, store durably in Postgres, and reconcile regularly
- Serve dashboards from daily rollups
- Enforce quotas with clear headers and graceful limits
We went through this exact process while building multi-channel automation at Botsailor, where per-tenant message and workflow volumes vary wildly. Getting the event schema right early saved us from a painful migration later.
Your turn: how do you handle usage metering in your SaaS: build it in-house, or use a billing platform? Share your approach in the comments.
Top comments (0)