Memory grows without limit when $transaction([...]) batches vary in shape
Package and version
@prisma/[email protected]
What happened?
Our API ran out of heap roughly every 29 hours after moving to Prisma 7.
We traced it to the query plan cache: every $transaction([...]) whose
shape changes from call to call adds a new entry that is never reused,
and large batches make each entry several megabytes.
By "shape" I mean anything that survives parameterization: the number of
queries in the batch, and whether a field is null or a value. A batch
built from data - e.g. rows.map(r => prisma.x.upsert(...)) - almost
never repeats its shape, so almost every call is a cache miss that gets
stored.
The cache is limited to 1000 entries, but not by size. With ~450 upserts per batch each entry is ~10 MB, so the process runs out of memory long before the limit matters.
What did you expect to happen?
The cache should not be able to exhaust memory. Some options:
- don't cache batch plans at all, or not above N queries;
- limit the cache by size instead of entry count.
Minimal reproduction
Schema:
model Metric {
key String
hour DateTime
checks Int
latency Int?
speed Int?
@@id([key, hour])
}Script (node --expose-gc repro.js):
const { PrismaClient } = require('@prisma/client');
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
const prisma = new PrismaClient({
adapter: new PrismaMariaDb({ host: '127.0.0.1', port: 3306, user: 'root', password: 'pw', database: 'repro' }),
});
const rows = () => Array.from({ length: 400 + Math.floor(Math.random() * 70) }, (_, i) => ({
key: `k${i % 20}-${Math.floor(i / 20)}`,
hour: new Date(Date.UTC(2026, 0, 1, i % 24)),
checks: 4,
latency: Math.random() < 0.2 ? null : 80,
speed: Math.random() < 0.7 ? null : 90000,
}));
const upsert = (db, r) => db.metric.upsert({
where: { key_hour: { key: r.key, hour: r.hour } },
create: r,
update: { checks: r.checks, latency: r.latency, speed: r.speed },
});
(async () => {
for (let i = 0; i <= 40; i++) {
if (i % 4 === 0) {
global.gc();
console.log(i, (process.memoryUsage().heapUsed / 1048576).toFixed(1), 'MB');
}
await prisma.$transaction(rows().map((r) => upsert(prisma, r)));
}
await prisma.$disconnect();
})();The table starts empty; the script fills it itself.
Output on 7.10.0 (heapUsed after GC, MB):
| batch # | $transaction([...]) |
interactive $transaction(async tx => …) |
|---|---|---|
| 0 | 8.0 | 8.0 |
| 8 | 99.7 | 14.2 |
| 16 | 171.8 | 14.4 |
| 24 | 249.8 | 14.4 |
| 32 | 330.7 | 14.5 |
| 40 | 408.8 | 14.6 |
~10 MB is retained per batch with the array form. The interactive variant
writes exactly the same rows, one await at a time
($transaction(async (tx) => { for (const r of rows) await upsert(tx, r) })),
and stays flat.
Environment
- @prisma/client 7.10.0, @prisma/adapter-mariadb 7.10.0, mariadb 3.4.5
- MariaDB 12.3
- Node.js 24
- OS: Linux (Debian 12, Docker) in production, Windows 11 for the repro
Additional context
- Batches with
createManyget a new cache entry for every distinct row count. The single-query path already skips caching forcreateMany; the batch path doesn't. - Maybe related to #29294 (OOM after 7.3.0 → 7.4.1), but I haven't verified that.
- Workaround: interactive transactions with sequential awaits for data-dependent writes.
Source: prisma/prisma