Fixing a Prisma Connection Leak in the content‑automation Service
TL;DR: I eliminated the “Connection limit exceeded” errors that were crashing our weekly content‑automation runs by turning the ad‑hoc Prisma client into a proper singleton and wiring graceful shutdown hooks. The change cuts open PostgreSQL connections from dozens per request to a single pooled client, stabilizing the production environment.
The Problem
Our content‑automation microservice runs a nightly job that pulls data from a PostgreSQL database (Neon) and publishes posts to multiple platforms (Medium, Substack, Bluesky). During a routine log review I started seeing this stack trace:
Error: Connection limit exceeded
at Pool.<anonymous> (node_modules/pg-pool/lib/index.js:129:23)
at processTicksAndRejections (node:internal/process/task_queues:96:5)
The error appeared right after the job started processing the first batch of articles. Monitoring tools showed the number of active PostgreSQL connections climbing from the default 5 to the server‑imposed limit of 100 within seconds, then the process threw and the job aborted.
Root cause: every iteration of the job created a brand‑new PrismaClient instance, which internally opens a new connection pool. Because we never called prisma.$disconnect(), the pools accumulated until the DB rejected new connections.
What I Tried First
My initial fix was to add an explicit await prisma.$disconnect() at the end of the job function:
await generatePosts()
await prisma.$disconnect()
That seemed to work locally, but in production the job was sometimes killed by the scheduler before reaching the disconnect call, leaving the pool dangling. Moreover, other parts of the codebase (e.g., the API server) also instantiated their own Prisma clients, so the disconnect in the job didn’t address the broader leak.
I also experimented with reducing the pool size via datasource db { url = env("DATABASE_URL") pool_size = 2 } in schema.prisma, but that only delayed the inevitable crash.
The Implementation
The reliable solution was to treat the Prisma client as a true singleton that lives for the lifetime of the process and is closed exactly once on shutdown. Below are the concrete file changes.
1. Create a singleton Prisma client (src/lib/prisma.ts)
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined
}
/**
* Export a single PrismaClient instance.
* In dev mode we attach it to the global object to preserve the client
* across Hot Module Reloads (e.g., when using ts-node-dev).
*/
const prisma = global.__prisma ?? new PrismaClient({
// Explicitly limit the pool size to avoid exhausting Neon limits
datasource: {
url: process.env.DATABASE_URL,
},
// Optional: configure connection timeout
errorFormat: 'pretty',
})
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma
}
export default prisma
Key points:
-
Singleton pattern –
global.__prismaensures only one instance per Node process. -
Pool size – we rely on Neon’s default pool but can override with
connection_limitin the connection string if needed. - Dev‑mode safety – avoids creating multiple clients during hot reloads.
2. Refactor the job runner to use the singleton (src/jobs/generatePosts.ts)
// src/jobs/generatePosts.ts
import prisma from '../lib/prisma'
import { fetchArticles } from '../services/articleService'
import { publishToMedium } from '../integrations/medium'
import { publishToBluesky } from '../integrations/bluesky'
export async function generatePosts() {
const articles = await fetchArticles()
for (const article of articles) {
// Example query using the singleton client
const author = await prisma.author.findUnique({
where: { id: article.authorId },
})
// Publish to platforms
await Promise.all([
publishToMedium(article, author),
publishToBluesky(article, author),
])
}
// No explicit disconnect – handled globally
}
The job now reuses the same prisma instance for every DB call.
3. Add graceful shutdown handling (src/server.ts)
// src/server.ts
import express from 'express'
import prisma from './lib/prisma'
import { generatePosts } from './jobs/generatePosts'
const app = express()
app.use(express.json())
app.post('/run-job', async (_req, res) => {
try {
await generatePosts()
res.sendStatus(200)
} catch (e) {
console.error(e)
res.sendStatus(500)
}
})
// Graceful shutdown
function shutdown(signal: string) {
console.log(`Received ${signal}. Closing Prisma...`)
prisma
.$disconnect()
.then(() => {
console.log('Prisma disconnected. Exiting.')
process.exit(0)
})
.catch((err) => {
console.error('Error during Prisma disconnect', err)
process.exit(1)
})
}
process.on('SIGINT', () => shutdown('SIGINT'))
process.on('SIGTERM', () => shutdown('SIGTERM'))
app.listen(3000, () => console.log('🚀 Server listening on :3000'))
Now, whether the process ends via a manual Ctrl‑C, a container stop signal, or a crash, Prisma’s pool is always closed.
4. Update CI/CD metadata (content/2026/08/07/content-automation/metadata.json)
The commit that introduced these changes also toggles the generated‑content flags:
{
"repo": "content-automation",
"date": "2026-08-07",
"languages": ["es", "en"],
"topics": ["Productivity"],
"commits": 2,
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,
"medium_generated": true,
"substack_generated": true
}
5. Verify the fix
After deploying the new version, I ran the job 20 times in a row. The PostgreSQL pg_stat_activity view never exceeded 2 active connections (one for the pool, one for the health‑check). No more “Connection limit exceeded” errors appeared in the logs.
Key Takeaway
Never instantiate a PrismaClient per request or per job iteration; always use a process‑wide singleton and close it exactly once on shutdown. This pattern eliminates connection leaks, reduces resource consumption, and makes graceful termination deterministic.
What's Next
-
Health‑check endpoint – expose
/healthzthat runs a cheap `
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-08-08
#playadev #buildinpublic
Top comments (0)