Skip to content

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

Description

@Zat-A

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/schema.prisma
generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "sqlite"
}

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

export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: { url: process.env["DATABASE_URL"] },
});
// 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();
# .env
DATABASE_URL="file:./repro.db"
npm install prisma@7.10.0 @prisma/client@7.10.0 @prisma/adapter-libsql@7.10.0 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 Possible memory leak using driver adapters with cloudflare D1 #25714 (Cloudflare D1 driver adapter) and Memory leak with RSS on concurrent query's with a larger dataset. #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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions