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.