DEV Community

Cover image for Our Next.js App Crashed Every 24 Hours. Here Are the Six Memory Fixes.
Digital Craft Workshop
Digital Craft Workshop

Posted on • Originally published at levelup.gitconnected.com

Our Next.js App Crashed Every 24 Hours. Here Are the Six Memory Fixes.

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({ ... });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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

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)