I opened the Render dashboard on Monday morning and saw the same thing I had seen every day for a week. Memory at 100%, service restarting. Our Next.js app was eating all 512MB within ~24 hours, then OOM-killing itself.
I spent a day digging through the codebase with process.memoryUsage() logs and Render's built-in metrics. I found six separate problems. One of them cut memory usage by more than half in a single deploy.
Here is every fix, in order of impact.
The Stack
Next.js 16 (App Router) with Drizzle ORM, PostgreSQL, Clerk auth, Stripe payments, Resend for transactional email, and Inngest for background jobs. I covered the full background job setup in a previous post — five functions, one route, $0/month.
Hosting: Render.com Starter plan — 512MB RAM, $7/month.
The symptom: no sudden spikes. Just a steady upward line on the Render memory graph that ended in an OOM-kill roughly every 24 hours.
The Fix That Cut Memory in Half
The Resend email SDK was being created with new Resend(apiKey) inside 10 different files. API routes, Inngest functions, utility helpers. Every request spun up a new client with its own HTTP connections, event listeners, and internal buffers. None of them got cleaned up between requests.
This is what it looked like:
// ❌ In 10 different files
export async function POST(req: Request) {
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({ ... });
}
And the fix — a lazy singleton in one file:
// ✅ lib/resend.ts
let instance: Resend | null = null;
export function getResend(): Resend {
if (!instance) {
instance = new Resend(process.env.RESEND_API_KEY!);
}
return instance;
}
Every handler now calls getResend(). One instance for the entire process lifetime. I already had this pattern for Stripe and Anthropic — Resend slipped through because it was added later, in a hurry, across multiple files.
Memory dropped from ~93% to ~35% immediately after deploy. This single change was responsible for more than half of the total improvement.
V8 Knows Your Container Limit. It Still Overallocates.
Node.js 20+ reads cgroup limits and auto-sizes the V8 heap based on your container's available memory. So in theory, it should know about the 512MB limit. In practice, the auto-detected heap still leaves too little room for everything else — non-heap memory, native buffers, connection pools, and the OS itself.
On my 512MB container, V8's auto-detected heap was larger than what the process could safely use. Memory would climb slowly — not because of a leak, but because V8 was not running GC aggressively enough for the remaining headroom.
The fix is one flag:
node --max-old-space-size=384 server.js
Setting it to ~75% of the container limit gives V8 a tighter budget. It starts garbage collecting earlier, well before the container runs out.
One gotcha I hit: I initially set NODE_OPTIONS=--max-old-space-size=384 as a Render environment variable. That applies to the build step too. The TypeScript compiler also runs under Node — and it crashed at 384MB during the build.
The fix: set the flag only in the start command, not as a global env var.
// Render start command (not an env var)
node --max-old-space-size=384 .next/standalone/server.js
After the singleton fix brought memory down to ~35%, this flag is what prevented it from slowly climbing back up.
If you would rather get my solo-dev build-and-debug notes broken into a few short, copy-paste steps in your inbox, I put the essentials into The Claude Code Memory Starter — a free email series.
Four More Leaks I Found
Clerk Admin SDK — Same Bug, Different Package
Same pattern as Resend, smaller scale. createClerkClient() was called inside request handlers in 3 files instead of being a singleton. Same lazy-init fix: one **getClerkAdmin()** function, one file, one instance.
Unbounded In-Memory Rate Limiter
My /api/track analytics endpoint used a global Map for rate limiting. Cleanup only kicked in when the map exceeded 1,000 entries. On a 512MB container, 1,000 entries of IP strings plus timestamps and counters adds up.
Fix: hard cap at 500 entries with LRU-style eviction, plus a setInterval cleanup every 2 minutes.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitMap) {
if (now - entry.timestamp > 120_000) {
rateLimitMap.delete(key);
}
}
}, 120_000);
cleanup.unref();
The .unref() call matters. Without it, the interval keeps the Node process alive and it will not exit cleanly during deploys or restarts.
Cron Jobs Loading Everything Into Memory
Two Inngest cron functions had memory problems:
Analytics cleanup used Drizzle's .returning({ id }) on a bulk DELETE. This loaded every deleted row ID into an array before returning. On a table with thousands of expired rows, that array gets big. Fixed with raw SQL that returns only **rowCount**.
Orphan image cleanup listed the entire Cloudflare R2 bucket three separate times — once per context (sequences, emails, subscribers). Fixed to list the bucket once and filter in memory. Three API calls became one.
Database Pool With a Silent Zero-Timeout Default
The PostgreSQL connection pool was created with new Pool({ connectionString }) and nothing else. Most defaults are fine — max: 10, idleTimeoutMillis: 10_000. But one default is dangerous: **connectionTimeoutMillis** defaults to 0, which means "wait forever."
If a connection attempt hangs — slow DNS, unresponsive database, network hiccup — the request sits there holding memory and a pool slot indefinitely. On a 512MB container, one stuck connection is all it takes to start a cascade.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 10_000,
connectionTimeoutMillis: 5_000,
});
Adding connectionTimeoutMillis: 5_000 means a stuck attempt fails fast instead of waiting forever. I would not have found this without reading the node-postgres docs during the debugging session.
Results
| Metric | Before | After |
|---|---|---|
| Baseline memory | ~93% (475MB) | ~35% (180MB) |
| Time to OOM | ~24 hours | Stable (no crash) |
| Memory after 24h | OOM crash | ~45% plateau |
The Resend singleton was the biggest single win — more than half of the total improvement in one deploy. The --max-old-space-size flag prevented the slow climb back up by giving V8 a tighter GC budget.
The Checklist
If you are running Next.js on a memory-constrained container, check for these:
- SDK clients (
new Client()) created inside request handlers instead of module-level singletons - Missing
**--max-old-space-size**— even on Node 20+ where cgroup detection works, an explicit cap gives V8 a tighter GC budget -
**--max-old-space-size**set as env var instead of start command only (breaks builds) - Unbounded
**Map**/**Set**/**Array**at module level without eviction or size cap -
**.returning()**on bulk DELETE/UPDATE loading all results into memory - Database pool with default
connectionTimeoutMillis: 0— set an explicit timeout
Most of these take 5–10 minutes to fix once you know what to look for.
What I Took Away From This
Most Node.js memory issues on small containers are not traditional memory leaks. They are per-request allocations that should be singletons, combined with V8 not running GC aggressively enough for the available headroom.
Fix those two patterns and you will likely be fine on 512MB. I have been running stable for weeks on Render's cheapest plan — no crashes, no restarts, memory plateaus at ~45%.
If you want more of how I build and ship as a solo developer landing in your inbox, The Claude Code Memory Starter walks you through the setup over a few short emails — free.
External Sources
- Node.js CLI docs — --max-old-space-size — the official V8 heap limit flag documentation
- Resend Node.js SDK — the email SDK referenced in this article
- Drizzle ORM — TypeScript-first SQL ORM used in the stack
- Inngest — background job platform for the cron functions
- node-postgres Pool API — connection pool configuration and defaults
Daniel Rusnok is a software developer and solo maker building Drippery — a dead-simple drip email tool for creators. He writes about TypeScript, Next.js, and the realities of shipping software alone.
Top comments (0)