#30276·Prisma

prisma.$transaction() leaks native (non-heap) RSS memory per call — reproduces on 7.9.1 and 7.10.0 with the libSQL adapter

Author: Zat-ACreated Sep 13, 2026Updated Sep 13, 2026

Package and version

@prisma/client + prisma + @prisma/adapter-libsql, reproduced on 7.9.1 and 7.10.0 (latest stable 7.x). I could not test against Prisma 8: @prisma/adapter-libsql has no published 8.0.0 release — its version list jumps straight from 7.10.0 to unstable 8.1.0-dev.* snapshots — so there's currently no stable/RC Prisma-8 + libSQL-adapter combination to verify against.

What happened?

Every prisma.$transaction(...) call permanently leaks a small, fixed amount of native (non-V8) memory — invisible to process.memoryUsage().heapUsed / .external / .arrayBuffers, which all stay completely flat — and only visible as steadily climbing RSS. --expose-gc + an explicit global.gc() between every call does not reclaim it.

Minimal isolated proof, same exact query, same exact client/connection, run 500 times each way:

  • Bare (prisma.widget.findFirst(...), unwrapped): RSS flat, ~118–121MB the whole run.
  • Wrapped (prisma.$transaction(async (tx) => { await tx.widget.findFirst(...) })): RSS climbs steadily and does not plateau — 105MB → 184MB over 500 calls on 7.9.1 (~0.16MB/call), and the same ~0.16MB/call rate on 7.10.0 (119MB → 200MB).

The transaction body here does a single read and nothing else — there is nothing for an application to be holding onto. The leak reproduces identically against a fresh, empty SQLite file with a single trivial model.

This isn't specific to a toy case, either — I found it while diagnosing a real Next.js app's dev server growing to ~12GB RSS over a multi-hour session. Its own world-simulation tick wraps most of its writes in $transaction() (financial charges, settlement, etc.), and bisecting which of ~9 systems was "the" leak showed it wasn't any one of them — every system that called $transaction() contributed roughly in proportion to how many transactions it opened per tick. The leak is the sum across every $transaction() call made over the process's lifetime, not tied to any particular query or table.

What did you expect to happen?

RSS to plateau (or at least not grow unboundedly) once the JS heap and Node's own tracked external/arrayBuffers memory are stable — an interactive $transaction() that opens, does one read, and commits should leave no permanent trace once it resolves.

Minimal reproduction

prisma
// prisma/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
}

model Widget {
  id   String @id @default(cuid())
  name String
}
typescript
// prisma.config.ts
import "dotenv/config";
import { defineConfig } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: { url: process.env["DATABASE_URL"] },
});
typescript
// repro.mts
import "dotenv/config";
import { PrismaLibSql } from "@prisma/adapter-libsql";
import { PrismaClient } from "./generated/prisma/client.js";

const prisma = new PrismaClient({ adapter: new PrismaLibSql({ url: process.env.DATABASE_URL! }) });
const MODE = process.argv[2] ?? "transaction"; // "bare" | "transaction"
const ITERATIONS = Number(process.argv[3] ?? 500);
const mb = (b: number) => (b / 1024 / 1024).toFixed(1) + "MB";

async function main() {
  await prisma.widget.deleteMany();
  await prisma.widget.create({ data: { name: "seed" } });

  console.log(`iter\trss\theapUsed\texternal`);
  for (let i = 0; i < ITERATIONS; i++) {
    if (MODE === "bare") {
      await prisma.widget.findFirst({ select: { id: true } });
    } else {
      await prisma.$transaction(async (tx) => {
        await tx.widget.findFirst({ select: { id: true } });
      });
    }
    if (i % 25 === 0 || i === ITERATIONS - 1) {
      if (global.gc) global.gc();
      const m = process.memoryUsage();
      console.log(`${i}\t${mb(m.rss)}\t${mb(m.heapUsed)}\t${mb(m.external)}`);
    }
  }
  await prisma.$disconnect();
}
main();
bash
# .env
DATABASE_URL="file:./repro.db"
bash
npm install [email protected] @prisma/[email protected] @prisma/[email protected] dotenv tsx
npx prisma generate
npx prisma db push
node --expose-gc --import tsx repro.mts bare 500        # flat RSS
node --expose-gc --import tsx repro.mts transaction 500 # RSS climbs, never plateaus

Sample output on 7.10.0, transaction mode (bare mode stays flat at ~118–121MB for comparison):

iter    rss     heapUsed  external
0       118.9MB 16.9MB    13.4MB
100     134.0MB 17.6MB    10.3MB
200     150.5MB 17.7MB    10.3MB
300     167.1MB 17.8MB    10.3MB
400     183.5MB 17.9MB    10.3MB
499     199.7MB 17.9MB    10.3MB

Environment

  • Node: v24.18.1
  • OS: Windows 11 Home
  • Package manager: npm
  • Database: SQLite (local file), via @prisma/adapter-libsql
  • Reproduced on prisma/@prisma/client/@prisma/adapter-libsql 7.9.1 and 7.10.0 (identical ~0.16MB/transaction-call leak rate on both)

Additional context

  • Related but distinct: the 7.9.0 changelog fixed a connection leak specifically on an interactive transaction that times out (maxWait) while starting. This is not that — every transaction here starts and commits successfully, quickly, with no timeout involved.
  • Possibly the same underlying class of issue as #25714 (Cloudflare D1 driver adapter) and prisma/orm#25371 (Postgres, RSS grows while heap stays flat) — neither has a confirmed root cause or fix, and I couldn't find an existing issue that isolates it down to bare $transaction() itself against a trivial single-row query the way this one does.
  • Given the leak is invisible to heapUsed/external/arrayBuffers, I'd guess it's native memory held by the query-compiler/interpreter layer per transaction (@prisma/client-engine-runtime) rather than anything in the JS driver-adapter glue, but I don't have visibility into that layer to confirm.