Package and version
@prisma/client: 5.18.0
What happened?
nabling the metrics preview feature causes steady, unbounded native memory growth (RSS), linear with query throughput, that is never reclaimed. It is invisible to V8 heap snapshots because the growth is in the query engine's native (Rust) heap, not the JS heap.
In our staging environment, a high-throughput service running ~33,000 queries/min leaked RSS at a constant ~1 MB/min (~31.5 bytes per query) until it hit the container memory limit and was OOM-killed (~30h cycle). Removing "metrics" from previewFeatures stopped the growth completely (dropped to ~0.22 bytes/query, RSS flat) at comparable throughput.
Notably, the leak occurs even when the metrics are never read — we never call $metrics.json() / $metrics.prometheus(). Simply enabling the feature and running queries is enough.
How to reproduce
Set previewFeatures = ["metrics"] in the schema.
Instantiate PrismaClient and run a query in a tight loop (any simple query works).
Do not call $metrics at all.
Watch process RSS (not V8 heap — e.g. process.memoryUsage().rss, or OS-level brk/RSS). It grows linearly and monotonically, roughly proportional to the number of queries executed, and never comes back down (GC and --max-old-space-size have no effect, confirming it is outside V8).
The growth rate scales directly with query volume, which is why it's most visible on high-throughput services.
Our root-cause analysis
We traced this to how histogram metrics are stored in the engine's metrics crate.
Every Prisma query records two histograms (prisma_client_queries_duration_histogram_ms and prisma_datasource_queries_duration_histogram_ms). In libs/metrics/src/registry.rs (query-engine/metrics/src/registry.rs in 5.x), histograms are stored in a Registry<Key, GenerationalAtomicStorage> and updated on every record:

GenerationalAtomicStorage backs histograms with metrics_util's AtomicBucket, which is an append-only structure — every record() pushes another raw sample and it is only reclaimed when the bucket is consumed/cleared.
However, both read paths use the non-consuming data_with(...), which reads the samples without clearing them:
So histogram samples accumulate for the entire lifetime of the process:
If $metrics is never read (our case), nothing ever touches the bucket → it grows forever.
Even if $metrics is scraped periodically, data_with doesn't clear it → it still grows forever.
Counters and gauges are unaffected (they use a single fixed-size atomic cell), which is consistent with the small, constant per-query cost we measured (~two f64 samples + AtomicBucket block/atomic overhead ≈ 31.5 bytes/query).
The MetricRegistry map itself is bounded (a fixed set of metric keys), so this is easy to overlook — the leak is in the per-histogram sample buffer behind each key, not in the number of keys.
Affected versions
We compared the metrics implementation and the leaking code path is identical in:
@prisma/client / engines 5.18.0 (query-engine/metrics/src/registry.rs)
6.5.0 (libs/metrics/src/registry.rs)
get_or_create_histogram(...).record(...) into AtomicBucket + non-draining data_with() reads are unchanged across these releases, so upgrading does not fix it. (6.x did stop routing each update through a trace!/serde_json event, which reduces transient allocation churn, but not the fundamental unbounded histogram retention.)
Environment
@prisma/client: 5.18.0 (also verified in engine source at 6.5.0)
Query engine: library engine (default; native .node, runs in-process)
Provider: PostgreSQL
Runtime: Node.js on Linux (containerized)
previewFeatures: ["metrics", ...]
What did you expect to happen?
Histogram metrics should use bounded memory regardless of query volume or how often (or whether) $metrics is scraped — e.g. by using a cumulative bucketed histogram (fixed bucket counters, like counters/gauges) instead of retaining every raw sample, or by draining the AtomicBucket on read (clear_with/consuming data) so accumulated samples are reclaimed.
Minimal reproduction
Fully self-contained: SQLite, no dataset, no models needed. The query is a bare SELECT 1, which still triggers the per-query histogram recording. RSS climbs linearly while the V8 heap stays flat; disabling the metrics preview feature makes RSS flat.
{
"name": "prisma-metrics-leak-repro",
"type": "module",
"scripts": {
"start": "prisma generate && tsx index.ts"
},
"dependencies": {
"@prisma/client": "5.18.0"
},
"devDependencies": {
"prisma": "5.18.0",
"tsx": "^4.19.1"
}
}
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["metrics"] // <-- comment out this line for the control run
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
// A model is only needed so prisma generate has something to emit;
// the repro never touches it (it runs a bare SELECT 1).
model Ping {
id Int @id @default(autoincrement())
}
index.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const TOTAL = 2_000_000; // queries
const SAMPLE_EVERY = 50_000;
function mb(bytes: number) {
return (bytes / 1024 / 1024).toFixed(1);
}
async function main() {
// We deliberately NEVER call prisma.$metrics.* — the leak happens purely
// from recording, regardless of whether metrics are ever read.
for (let i = 0; i < TOTAL; i++) {
await prisma.$queryRawSELECT 1;
if (i % SAMPLE_EVERY === 0) {
const m = process.memoryUsage();
// rss = whole process (incl. native engine heap); heapUsed = V8 only
console.log(
`queries=${i}\trss=${mb(m.rss)}MB\theapUsed=${mb(m.heapUsed)}MB`,
);
}
}
}
main().finally(() => prisma.$disconnect());
Observed (with previewFeatures = ["metrics"]) — RSS grows roughly linearly and monotonically, while heapUsed stays flat, proving the growth is in the engine's native heap, not V8:
queries=0 rss=95.2MB heapUsed=5.1MB
queries=250000 rss=103.0MB heapUsed=5.3MB
queries=500000 rss=110.7MB heapUsed=5.2MB
queries=1000000 rss=126.5MB heapUsed=5.3MB
queries=2000000 rss=157.8MB heapUsed=5.4MB
Control: comment out the previewFeatures = ["metrics"] line, re-run npm start (it regenerates the client), and RSS stays flat at the same throughput.
Environment
Prisma: prisma and @prisma/client 5.18.0 (leak path also verified in engine source at 6.5.0)
Query engine: library engine (default; Node-API .node, runs in-process) — the leak is in the engine's native heap, not the V8 heap
Node.js: 22.x (LTS)
OS / base image: node:22-alpine3.20 (Alpine / musl libc) in the affected service. Also reproduced on a Debian (glibc) base image — same growth rate, which rules out a libc-allocator fragmentation explanation.
Package manager: npm (10.x)
Target database (production, where the leak was observed): PostgreSQL on AWS RDS 15.x/16.x
Target database (minimal reproduction): SQLite (the leak is engine-level and provider-agnostic — no server or dataset required)
Preview features enabled: ["metrics", ...]
Deployment context: containerized with a hard memory limit (AWS ECS Fargate); the linear RSS growth eventually crosses the limit and the task is OOM-killed (~30h cycle at ~33k queries/min)
Additional context
The leak is not in V8 — heap snapshots are clean and heapUsed stays flat; only process RSS grows. --max-old-space-size and forced GC have no effect.
Growth is linear with query throughput (~31 bytes/query in our workload) and is never reclaimed for the life of the process.
Reproduces regardless of whether $metrics is ever read.
Package and version
@prisma/client: 5.18.0
What happened?
nabling the metrics preview feature causes steady, unbounded native memory growth (RSS), linear with query throughput, that is never reclaimed. It is invisible to V8 heap snapshots because the growth is in the query engine's native (Rust) heap, not the JS heap.
In our staging environment, a high-throughput service running ~33,000 queries/min leaked RSS at a constant ~1 MB/min (~31.5 bytes per query) until it hit the container memory limit and was OOM-killed (~30h cycle). Removing "metrics" from previewFeatures stopped the growth completely (dropped to ~0.22 bytes/query, RSS flat) at comparable throughput.
Notably, the leak occurs even when the metrics are never read — we never call $metrics.json() / $metrics.prometheus(). Simply enabling the feature and running queries is enough.
How to reproduce
Set previewFeatures = ["metrics"] in the schema.
Instantiate PrismaClient and run a query in a tight loop (any simple query works).
Do not call $metrics at all.
Watch process RSS (not V8 heap — e.g. process.memoryUsage().rss, or OS-level brk/RSS). It grows linearly and monotonically, roughly proportional to the number of queries executed, and never comes back down (GC and --max-old-space-size have no effect, confirming it is outside V8).
The growth rate scales directly with query volume, which is why it's most visible on high-throughput services.
Our root-cause analysis
We traced this to how histogram metrics are stored in the engine's metrics crate.
Every Prisma query records two histograms (prisma_client_queries_duration_histogram_ms and prisma_datasource_queries_duration_histogram_ms). In libs/metrics/src/registry.rs (query-engine/metrics/src/registry.rs in 5.x), histograms are stored in a Registry<Key, GenerationalAtomicStorage> and updated on every record:
However, both read paths use the non-consuming data_with(...), which reads the samples without clearing them:
So histogram samples accumulate for the entire lifetime of the process:
If $metrics is never read (our case), nothing ever touches the bucket → it grows forever.
Even if $metrics is scraped periodically, data_with doesn't clear it → it still grows forever.
Counters and gauges are unaffected (they use a single fixed-size atomic cell), which is consistent with the small, constant per-query cost we measured (~two f64 samples + AtomicBucket block/atomic overhead ≈ 31.5 bytes/query).
The MetricRegistry map itself is bounded (a fixed set of metric keys), so this is easy to overlook — the leak is in the per-histogram sample buffer behind each key, not in the number of keys.
Affected versions
We compared the metrics implementation and the leaking code path is identical in:
@prisma/client / engines 5.18.0 (query-engine/metrics/src/registry.rs)
6.5.0 (libs/metrics/src/registry.rs)
get_or_create_histogram(...).record(...) into AtomicBucket + non-draining data_with() reads are unchanged across these releases, so upgrading does not fix it. (6.x did stop routing each update through a trace!/serde_json event, which reduces transient allocation churn, but not the fundamental unbounded histogram retention.)
Environment
@prisma/client: 5.18.0 (also verified in engine source at 6.5.0)
Query engine: library engine (default; native .node, runs in-process)
Provider: PostgreSQL
Runtime: Node.js on Linux (containerized)
previewFeatures: ["metrics", ...]
What did you expect to happen?
Histogram metrics should use bounded memory regardless of query volume or how often (or whether) $metrics is scraped — e.g. by using a cumulative bucketed histogram (fixed bucket counters, like counters/gauges) instead of retaining every raw sample, or by draining the AtomicBucket on read (clear_with/consuming data) so accumulated samples are reclaimed.
Minimal reproduction
Fully self-contained: SQLite, no dataset, no models needed. The query is a bare SELECT 1, which still triggers the per-query histogram recording. RSS climbs linearly while the V8 heap stays flat; disabling the metrics preview feature makes RSS flat.
{
"name": "prisma-metrics-leak-repro",
"type": "module",
"scripts": {
"start": "prisma generate && tsx index.ts"
},
"dependencies": {
"@prisma/client": "5.18.0"
},
"devDependencies": {
"prisma": "5.18.0",
"tsx": "^4.19.1"
}
}
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["metrics"] // <-- comment out this line for the control run
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
// A model is only needed so
prisma generatehas something to emit;// the repro never touches it (it runs a bare
SELECT 1).model Ping {
id Int @id @default(autoincrement())
}
index.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const TOTAL = 2_000_000; // queries
const SAMPLE_EVERY = 50_000;
function mb(bytes: number) {
return (bytes / 1024 / 1024).toFixed(1);
}
async function main() {
// We deliberately NEVER call prisma.$metrics.* — the leak happens purely
// from recording, regardless of whether metrics are ever read.
for (let i = 0; i < TOTAL; i++) {
await prisma.$queryRaw
SELECT 1;}
}
main().finally(() => prisma.$disconnect());
Observed (with previewFeatures = ["metrics"]) — RSS grows roughly linearly and monotonically, while heapUsed stays flat, proving the growth is in the engine's native heap, not V8:
queries=0 rss=95.2MB heapUsed=5.1MB
queries=250000 rss=103.0MB heapUsed=5.3MB
queries=500000 rss=110.7MB heapUsed=5.2MB
queries=1000000 rss=126.5MB heapUsed=5.3MB
queries=2000000 rss=157.8MB heapUsed=5.4MB
Control: comment out the previewFeatures = ["metrics"] line, re-run npm start (it regenerates the client), and RSS stays flat at the same throughput.
Environment
Prisma: prisma and @prisma/client 5.18.0 (leak path also verified in engine source at 6.5.0)
Query engine: library engine (default; Node-API .node, runs in-process) — the leak is in the engine's native heap, not the V8 heap
Node.js: 22.x (LTS)
OS / base image: node:22-alpine3.20 (Alpine / musl libc) in the affected service. Also reproduced on a Debian (glibc) base image — same growth rate, which rules out a libc-allocator fragmentation explanation.
Package manager: npm (10.x)
Target database (production, where the leak was observed): PostgreSQL on AWS RDS 15.x/16.x
Target database (minimal reproduction): SQLite (the leak is engine-level and provider-agnostic — no server or dataset required)
Preview features enabled: ["metrics", ...]
Deployment context: containerized with a hard memory limit (AWS ECS Fargate); the linear RSS growth eventually crosses the limit and the task is OOM-killed (~30h cycle at ~33k queries/min)
Additional context
The leak is not in V8 — heap snapshots are clean and heapUsed stays flat; only process RSS grows. --max-old-space-size and forced GC have no effect.
Growth is linear with query throughput (~31 bytes/query in our workload) and is never reclaimed for the life of the process.
Reproduces regardless of whether $metrics is ever read.