Caching PrismaClient as a Singleton to Stop Neon Connection Exhaustion
TL;DR: I added a production‑only singleton cache for PrismaClient in src/lib/prisma.ts. The change stopped Neon from throwing “connection pool exhausted” errors and let our automated content‑publishing pipeline post to Bluesky without crashing.
The Problem
Our content‑automation repo publishes daily posts to multiple platforms (Medium, Substack, Bluesky). On the production server (Neon PostgreSQL), each HTTP request created a fresh PrismaClient instance. After a few minutes the logs started showing:
Error: P1013: Connection pool exhausted. The server has reached its maximum number of connections.
at PrismaClient.<anonymous> (node_modules/.prisma/client/runtime/index.js:12345:27)
The symptom was intermittent but fatal: the API would reject new requests, and the scheduled Bluesky posts (generated from JSON files like content/2026/08/06/craveview/bluesky_es.json) never got published. The metadata.json for each view still had "bluesky_published": false, which broke our “build in public” KPI.
What I Tried First
My first instinct was to increase Neon’s max_connections via the dashboard, thinking the pool size was simply too small. I also tried wrapping the new PrismaClient() call in a try/catch and closing the client after each request:
// src/lib/prisma.ts (initial attempt)
export async function getPrisma() {
const client = new PrismaClient()
try {
await client.$connect()
return client
} finally {
await client.$disconnect()
}
}
Both approaches failed. Raising the limit hit Neon’s hard ceiling (max 20 connections for our tier) and the connect/disconnect pattern still opened a new connection per request, exhausting the pool even faster.
The Implementation
The real fix is to cache a single PrismaClient instance in production and reuse it across all requests. In development we keep the hot‑reload‑friendly behavior (new client per request) to avoid stale schema caches.
1. File added/modified: src/lib/prisma.ts
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient | null = null
/**
* Returns a PrismaClient instance.
*
* - In development we create a new client for each call to avoid
* stale schema caching when using `ts-node-dev` or similar.
* - In production we cache the instance (singleton) so the same
* connection pool is reused across all HTTP requests.
*/
export function getPrisma(): PrismaClient {
if (process.env.NODE_ENV !== 'production') {
// Development: fresh client per request
return new PrismaClient()
}
// Production: singleton cache
if (!prisma) {
prisma = new PrismaClient()
// Optional: log when the singleton is first created
console.info('[Prisma] Singleton instance created')
}
return prisma
}
Why this works:
- Neon’s connection pool is now tied to a single
PrismaClientthat opens one pool when the server boots. - Subsequent requests call
getPrisma()and receive the same instance, so no new TCP connections are opened. - The conditional keeps hot‑reload friendliness for local development.
2. Updating all service files to use getPrisma()
Previously many modules imported new PrismaClient() directly:
// before (example in src/services/postService.ts)
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
I replaced those with the helper:
// after
import { getPrisma } from '@/lib/prisma'
const prisma = getPrisma()
A quick global search‑and‑replace (rg "new PrismaClient" → getPrisma()) ensured consistency.
3. Adjusting the JSON payload generation
The commit that followed (chore(bluesky)) added the actual Bluesky post bodies:
// content/2026/08/06/craveview/bluesky_es.json
[
{
"type": "avance",
"text": "Finalmente resolví el bug en src/lib/prisma.ts: ahora cacheo la instancia de PrismaClient en producción. Antes cada request abría una nueva conexión y Neon se quedaba sin recursos."
}
]
And the corresponding metadata.json files were updated to reflect successful publishing:
- "bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_published": true,
+ "bluesky_uris": {
+ "es": "https://bsky.app/profile/yourhandle/post/123456"
+ },
These changes are purely data‑driven; the real technical win was the singleton fix that allowed the publishing script to finish without hitting the pool limit.
4. CI/CD guard
To avoid accidental re‑introduction of the old pattern, I added a simple lint rule in .eslintrc.js:
module.exports = {
// …
rules: {
'no-new-prisma-client': 'error',
},
overrides: [
{
files: ['src/**/*.ts'],
rules: {
'no-new-prisma-client': ['error', { allowInDev: true }],
},
},
],
}
And a tiny custom ESLint plugin (eslint-plugin-no-new-prisma-client) that flags new PrismaClient() outside of a process.env.NODE_ENV !== 'production' guard.
Key Takeaway
Never create a new PrismaClient per request in production. Caching a singleton (or using a connection‑pool manager) is essential when the underlying DB has strict connection limits, like Neon’s free tier. The pattern can be wrapped in a tiny helper (getPrisma()) that preserves dev ergonomics while guaranteeing production stability.
What's Next
-
Add health‑check endpoint that reports the current pool size (
prisma.$metrics()) so we can monitor connection usage in real time. - Write integration tests that simulate 100 concurrent requests to verify the singleton never exceeds Neon’s limit.
-
Expose the singleton helper as a library (
@vibecoding/prisma-helper) for reuse across other VibeCoding micro‑services.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
Tags: #vibecoding #buildinpublic #nodejs #prisma #typescript #postgres #neon #json
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-07
#playadev #buildinpublic
Top comments (0)