Skip to content

Prisma in dev: "too many clients already" after a few saves

Next.js hot reload re-runs your module and builds a new PrismaClient every time. Cache one instance on globalThis and the connection leak stops.

Prisma4 min readships at docs/solutions/prisma/hot-reload-client-leak.md

Tags: prisma · nextjs · hot-reload · connections · dev

You start the dev server, everything works. You edit a file, save, edit, save. Ten minutes later the page throws:

PrismaClientInitializationError:
Error querying the database: FATAL: sorry, too many clients already

Or, on a hosted Postgres:

Error: Can't reach database server at db.example.com:5432

Restarting the dev server fixes it. For about ten minutes. Then it comes back.

Nothing about your query changed. What changed is how many database connections your laptop is holding open.

Why it happens

Next.js dev mode does hot module replacement. When you save a file, the module graph that depends on it is invalidated and re-evaluated. That is the whole point: it is why your change appears without a full restart.

Now look at the code almost every tutorial shows:

// src/db/prisma.ts, the version that leaks
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";

export const prisma = new PrismaClient({
  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});

new PrismaClient() is a module-level side effect, and a PrismaClient is not a thin object. Since Prisma 7 every client runs on a driver adapter, and the adapter owns a connection pool: node-postgres opens up to 10 connections by default.

Every re-evaluation of this module constructs another one. The old client is no longer referenced by your code, but its sockets are still open: the pool is lazy about closing, the garbage collector has no idea it is holding an expensive external resource, and nothing calls $disconnect(). So the connections accumulate.

On a laptop Postgres with the default max_connections = 100, a pool of 10 connections means you get roughly ten saves before the server refuses new ones. On a free hosted tier with a limit of 20 or 30, you get two or three.

This is also why the bug is so confusing: it is time-and-edit-dependent, not input-dependent. It never reproduces in a test, never happens in production (where the module is evaluated once per instance), and disappears the moment you restart to investigate.

The fix

Cache the client on globalThis. Hot reload replaces modules; it does not replace the global object. So the second evaluation finds the client the first one made and reuses it.

// src/db/prisma.ts
import { PrismaClient } from "@/generated/prisma/client";
import { createAdapter } from "./driver"; // PrismaPg or PrismaNeon, one file

const globalForPrisma = globalThis as unknown as {
  prismaClient?: PrismaClient;
};

function createClient(): PrismaClient {
  return new PrismaClient({
    adapter: createAdapter(),
    log: process.env.NODE_ENV === "production" ? ["error"] : ["warn", "error"],
  });
}

export const prisma: PrismaClient = globalForPrisma.prismaClient ?? createClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prismaClient = prisma;
}

Three details worth understanding rather than copying:

The cast exists because globalThis is typed as having no such property. Casting through unknown to a one-property shape keeps the rest of the global object honest, instead of reaching for any.

?? not ||. A falsy-but-present client is not a thing here, but the nullish operator states the intent exactly: reuse if defined.

The write is guarded by NODE_ENV !== "production". In production the module is evaluated once per serverless instance or once per server process, so the cache buys nothing, and parking a client on the global object outlives the code that wants it. The read is unguarded on purpose: it is harmless, and keeping it symmetrical invites someone to "simplify" the guard away.

Import it, and only it

The singleton only helps if it is the only client. One new PrismaClient() hidden in a route handler, a test helper or a script that the dev server also loads brings the leak straight back.

// anywhere in the app
import { prisma } from "@/db/prisma";

const user = await prisma.user.findUnique({ where: { id } });

Worth grepping for before you close the issue:

grep -rn "new PrismaClient" src/ scripts/ prisma/

The only legitimate hit is inside src/db/prisma.ts.

What about $disconnect()?

You will find advice to call prisma.$disconnect() after each request. Do not do that in a long-lived server or a serverless function. Disconnecting drops the pool, so the next request pays full connection setup (including the TLS handshake) before it can run a query. Prisma's own guidance is to let the client live as long as the process does.

$disconnect() belongs in one-shot scripts: a seed, a backfill, a verify script. Those need it, otherwise the process hangs with an open pool and never exits.

// prisma/seed.ts
import { prisma } from "../src/db/prisma";

async function seed() {
  // ...
}

seed()
  .then(() => prisma.$disconnect())
  .catch(async (error) => {
    console.error(error);
    await prisma.$disconnect();
    process.exit(1);
  });

Confirming it worked

Ask the database how many connections you are holding, then save a file five times and ask again. The number should not move:

select count(*), application_name
from pg_stat_activity
where datname = current_database()
group by application_name;

If it climbs by a pool's worth per save, something is still constructing clients. If it climbs by 1 per save, you probably have a second module doing the same thing with a different variable name.

The same shape, elsewhere

Any expensive client with a connection pool or a background timer has this problem in Next.js dev: Redis, a Kafka producer, a Postgres Pool from pg, a websocket client, a metrics agent. The globalThis cache is the standard fix for all of them, and it is worth recognising the pattern rather than remembering it once per library.