March 2026·8 min read·2 views

A Single Config Line Was Leaking 1.8GB in Our Next.js App

How a copy-pasted Edge runtime directive caused OG image generation to eat 4.4GB of memory in three minutes.

Next.jsDebuggingMemory LeakWASMNode.js

Our Next.js publishing platform at Logos was eating 4.4GB of memory within three minutes of load testing. The container would eventually OOM and restart, only to do it again. The fix was removing a single line of config.

The symptom#

The team had filed an issue about the develop container consuming most of the host's CPU and memory. The logs showed RuntimeError: unreachable errors from a WASM module inside og.js, the OG image generation route. The stack trace pointed to @resvg/resvg-wasm, which is what @vercel/og uses under the hood to render SVG-based social preview images.

A restart would fix it temporarily, but memory would climb right back up. Classic leak behavior.

Finding the root cause#

The OG image route had this line at the top:

export const config = { runtime: 'edge' }

This tells Next.js to run the route on the Edge runtime. Sounds harmless. The problem is that when you deploy to a standard Node.js environment (not Vercel Edge, not Cloudflare Workers), Next.js still tries to honor this directive. It falls back to using @resvg/resvg-wasm to render SVGs natively, because the Edge runtime's built-in renderer isn't available.

And resvg-wasm has a known memory leak. Every render allocates WASM memory that never gets freed. On a route that generates OG images for every page on the site, this compounds fast.

Removing that one line switches the route to a standard Node.js API handler, which uses next/og instead of @vercel/og and avoids the leaky WASM path entirely.

Measuring the fix#

I ran the same load test against master and the fix branch, sampling RSS and heap every 30 seconds (PR #264):

Time          Master RSS   Master Heap     Fix RSS    Fix Heap
18:08:21          1194MB          57MB      1424MB        46MB
18:08:52          2201MB          60MB      2425MB        77MB
18:09:22          2690MB         311MB      2514MB       152MB
18:09:53          3230MB         598MB      2597MB       238MB
18:10:23          3720MB         994MB      2553MB       109MB
18:10:54          4083MB        1194MB      2627MB       220MB
18:11:24          4404MB        1580MB      2571MB       148MB

After three minutes, master was at 4404MB RSS with heap still climbing. The fix branch stabilized around 2571MB RSS and 148MB heap. That's 1833MB saved, and more importantly, the heap was flat instead of climbing linearly.

The other leaks#

While I was in there, I found two more unbounded caches that would grow over the server's lifetime:

Placeholder image cache. The PlaceholderService stored base64 placeholder strings in a Map with no eviction. Every new image processed by getStaticProps during ISR revalidation added an entry that was never removed. I added a FIFO cap at 500 entries.

Search index cache. The PostSearchService accumulated Lunr inverted indexes keyed by query parameters. Same story: no eviction, unbounded growth. Capped at 50 entries with FIFO eviction.

Neither of these was the primary leak, but on a long-running server they would have caused problems eventually.

Font loading#

The original code fetched font files over HTTP on every OG image request:

const font = await fetch(new URL('../../assets/fonts/Lora.ttf', import.meta.url))

I replaced this with fs.readFileSync at module level, caching the buffers. If the read fails, the buffer resets to null so the next request retries instead of serving broken images forever. This cut per-request latency and removed a network dependency from the render path.

Caching and security#

Two more things while I had the file open:

Cache-Control header. OG images are deterministic for a given page. I added public, max-age=3600, immutable so the CDN caches them for an hour instead of regenerating on every social media crawler hit.

SSRF protection. The OG route accepted an image URL parameter that it would fetch and embed. I added an ALLOWED_IMAGE_HOSTS allowlist and a sanitizeImageUrl() function. Without this, an attacker could point the URL at internal services and use the OG route as a proxy.

The one-line lesson#

The root cause was a single config export that was probably copy-pasted from a Vercel tutorial. It worked fine on Vercel's Edge runtime. It leaked 1.8GB in three minutes on a standard Node.js deployment.

The broader lesson: runtime directives in Next.js are not just hints. They fundamentally change which rendering pipeline your code runs through. If you're deploying to anything other than Vercel's Edge network, audit every runtime: 'edge' declaration and make sure the fallback path is something you actually want.