Repository navigation
next/image: a single 0-byte file in .next/cache/images/ permanently poisons the disk-LRU singleton #93757
Description
Activity
- addedImage (next/image)Related to Next.js Image Optimization.Related to Next.js Image Optimization.
on May 11, 2026 Re-verified on the latest canary (
next@16.3.0-canary.19) — bug is still unfixed at HEAD, behaviour identical to 16.2.4.Two small clarifications to the steps above for anyone trying to repro locally:
-
In
next dev, the disk cache lives at.next/dev/cache/images/, not.next/cache/images/— the 0-byte file has to land in the dev path. The path in the original steps is correct fornext start/ production builds; for dev mode use:mkdir -p .next/dev/cache/images/anyKey : > .next/dev/cache/images/anyKey/14400.1.a.b.avif
-
The disk LRU singleton initializes lazily on the first image-optimization request, so the 0-byte file must already be present at that moment. If you plant it after the first request has succeeded, restart the dev server before re-hitting the endpoint.
With those, the reproduction is deterministic. Exact stdout (cache-key in the log line is the current request's key, not the corrupt entry's — matching the diagnosis in the issue):
⨯ Failed to write image to cache ZG8a4zz89PiXBBeSnEICZzxYBo2fWMoRuPjxSAAVd9M Error: LRUCache: calculateSize returned 0, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth. at ignore-listed frames ⨯ unhandledRejection: Error: LRUCache: calculateSize returned 0, but size must be > 0. ...Singleton poison confirmed: every subsequent
/_next/image?...request re-emits the same error +unhandledRejectionpair, indefinitely.Workaround verified clean — after
find .next -path '*/cache/images*' -size 0 -deleteand a server restart, three follow-up image requests all returnedHTTP 200with zero LRU errors in stdout.Reacted by Dushyant Singh Hada, Taras and Erik Margetis-
Same root cause, second trigger:
images.unoptimized: true+ read-only FS leaks the rejected init promise as anunhandledRejectionWe hit a different path into the exact same fragility described here (the module-scoped
_diskLRUPromiseindisk-lru-cache.external.tsbecoming permanently rejected, with the rejection escaping into the global scope). Sharing it because the trigger and the leak mechanism are distinct from the 0-byte-file case, and theunoptimizedvariant produces a genuinely unhandled rejection (notry/catchever runs), which is worse for serverless deployments.Next.js: 16.2.7 · Runtime: Node 24.x · Platform: AWS Lambda (
output: 'standalone', read-only/var/taskfilesystem) · Config:images.unoptimized: trueWhat we observed (production, Sentry)
Error: ENOENT: no such file or directory, mkdir at async Object.mkdir (node:internal/fs/promises) at next/dist/server/lib/disk-lru-cache.external.js:36 // await fs.promises.mkdir(cacheDir, { recursive: true }) mechanism: auto.node.onunhandledrejection transaction: GET /_next/imageunhandledPromiseRejection: true. The HTTP request itself responds fine (a404— see below), so this is purely a leaked background promise, not a render failure. It fires roughly once per cold start (because_diskLRUPromiseis a per-process module singleton).Why it leaks (no
try/catchever consumes it)The chain in
handleNextImageRequest(next/dist/server/next-server.js) is, in order:- The cache is constructed unconditionally, before any config check:
const imageOptimizerCache = new ImageOptimizerCache({ distDir, nextConfig, cacheHandler });
ImageOptimizerCache's constructor eagerly fires the disk-LRU init and stores the promise without awaiting it (next/dist/server/image-optimizer.js):On a read-only filesystem theconstructor({ distDir, nextConfig, cacheHandler }) { this.cacheDir = join(distDir, 'cache', 'images'); // "Eagerly start LRU initialization for filesystem cache" if (!cacheHandler && nextConfig.images.maximumDiskCacheSize !== 0 && nextConfig.experimental.isrFlushToDisk) { this.isDiskCacheEnabled = true; this.cacheDiskLRU = getOrInitDiskLRU(this.cacheDir, ...); // fire-and-forget, rejected on read-only FS } }
mkdirinsidegetOrInitDiskLRUrejects, sothis.cacheDiskLRUis now a rejected promise.- Immediately after construction, the handler short-circuits for
unoptimized/ custom loader and returns:const imagesConfig = this.nextConfig.images; if (imagesConfig.loader !== 'default' || imagesConfig.unoptimized) { await this.render404(req, res); return true; // <-- get()/set() are NEVER reached }
The only places that
await this.cacheDiskLRUareImageOptimizerCache#get/#set, and those awaits are wrapped intry/catch. Withunoptimized: truethe handler returns at step 3, so neitherget()norset()ever runs → the rejectedcacheDiskLRUpromise is never awaited by anything → Node reports a trueunhandledRejection.So this is the mirror image of the 0-byte case: there the
set()path does await it (caught, then logged + still re-surfaced); here, underunoptimized, nothing awaits it at all, and eager init means we pay a disk-LRU init for a feature we explicitly turned off.Minimal local reproduction (no Lambda needed)
npx create-next-app@latest enoent-repro --ts --app --no-src-dir --use-npm cd enoent-repro # next.config: images.unoptimized: true (loader stays 'default') node -e "require('fs').writeFileSync('next.config.mjs','export default { images: { unoptimized: true } }\n')" npm run build # Make the image-cache parent unwritable to emulate a read-only serverless FS: rm -rf .next/cache chmod 0555 .next npx next start & sleep 4 curl -sS -o /dev/null "http://localhost:3000/_next/image?url=%2Fnext.svg&w=64&q=75"
Server stdout logs an unhandled
Error: ENOENT/EACCES ... mkdiroriginating fromdisk-lru-cache.external, while the request returns404. (On a normally writable FS the sameunoptimizedrequest just404s with no error — the leak only surfaces whenmkdirfails.)Suggested fixes (either/both)
- Don't construct/init the disk cache when it can't be used. Move the
new ImageOptimizerCache(...)after theloader !== 'default' || unoptimizedearly-return, or make the constructor's eager-init guard also check those (the disk image cache is pointless whenunoptimizedor a non-default loader is configured). - Make the singleton self-healing / never leak (also fixes next/image: a single 0-byte file in .next/cache/images/ permanently poisons the disk-LRU singleton #93757): attach a
.catch()when storing_diskLRUPromise, reset it tonullon rejection so a transient failure doesn't poison the process, and treat init failure as "disk cache unavailable" (fall through to cache-miss) instead of letting the rejection escape. Tolerating a corrupt entry duringinitCacheEntries(the 0-byte case) fits naturally here too.
Workaround for anyone on read-only/serverless FS today
Setting
images.maximumDiskCacheSize: 0makes the constructor's guard skip the eager init entirely, sogetOrInitDiskLRUis never called and the rejection never happens. This is a clean complement tounoptimized: true(there is nothing to cache anyway).- The cache is constructed unconditionally, before any config check:
We had the same issue on Next 15.5.18 and 15.5.19 which resulted in prod pods dying as soon as a single 0-byte image found its way into the LRU cache. In our case it was not a write interrupted as outlined by @MohamedEslam04 but the upstream image service serving an empty 200.
Every subsequent image request is a cache miss, so every image gets re-optimized through sharp on every request — no disk cache at all. That’s a sustained CPU + memory spike, eventually OOM killing the pod.
I don't won't to open yet another AI fuelled Pull Request, but as it might be helpful for anyone trying to work around this, here's a patch for Next 15.5.19 (which probably can be ported to v16) Claude Code generated for us. It can be applied using
patch-packageand it so far does seem to solve the issue:next+15.5.19.patchdiff --git a/node_modules/next/dist/server/image-optimizer.js b/node_modules/next/dist/server/image-optimizer.js index b631800..2d62b30 100644 --- a/node_modules/next/dist/server/image-optimizer.js +++ b/node_modules/next/dist/server/image-optimizer.js @@ -178,6 +178,14 @@ async function initCacheEntries(cacheDir) { for (const cacheKey of cacheKeys){ try { const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey); + // PATCH: a 0-byte entry would throw in LRUCache.set (size must be > 0), + // rejecting the module-level disk-LRU init promise and poisoning the image cache for the + // lifetime of the process (every image is then re-optimized via sharp → CPU/memory → OOM). + // Self-heal: delete the stray file and skip it so init always stays healthy. + if (buffer.byteLength === 0) { + await deleteFromCacheDir(cacheDir, cacheKey); + continue; + } entries.push({ key: cacheKey, size: buffer.byteLength, @@ -246,6 +254,12 @@ function getImageEtag(image) { async function writeToCacheDir(cacheDir, cacheKey, extension, maxAge, expireAt, buffer, etag, upstreamEtag) { const dir = (0, _path.join)(/* turbopackIgnore: true */ cacheDir, cacheKey); const filename = (0, _path.join)(dir, `${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`); + // PATCH: write atomically (temp file in the cache root, then rename) so a write + // interrupted by pod termination can never leave a truncated 0-byte file inside the cache dir. + // A 0-byte cache entry throws in LRUCache.set (size must be > 0) and poisons the disk LRU for the + // lifetime of the process. The temp file lives in cacheDir root (sibling of the cacheKey dir), so + // readFromCacheDir — which only reads files *inside* the dir — never picks it up. See vercel/next.js#93757. + const tmpFilename = (0, _path.join)(/* turbopackIgnore: true */ cacheDir, `.${cacheKey}.${process.pid}.tmp`); await _fs.promises.rm(dir, { recursive: true, force: true @@ -253,7 +267,13 @@ async function writeToCacheDir(cacheDir, cacheKey, extension, maxAge, expireAt, await _fs.promises.mkdir(dir, { recursive: true }); - await _fs.promises.writeFile(filename, buffer); + await _fs.promises.writeFile(tmpFilename, buffer); + await _fs.promises.rename(tmpFilename, filename).catch(async (err)=>{ + await _fs.promises.rm(tmpFilename, { + force: true + }).catch(()=>{}); + throw err; + }); } async function readFromCacheDir(cacheDir, cacheKey) { const dir = (0, _path.join)(/* turbopackIgnore: true */ cacheDir, cacheKey); @@ -743,6 +763,11 @@ class ImageOptimizerCache { if (!this.isDiskCacheEnabled) { return; } + // PATCH: never cache a 0-byte image. LRUCache.set throws on size 0 and a 0-byte + // file on disk poisons LRU init on the next start. Skip it instead of logging a write failure. + if (!value.buffer || value.buffer.byteLength === 0) { + return; + } // Fall back to filesystem cache const expireAt = Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 + Date.now(); try {Note, how it requires three changes:
- initCacheEntries — the crash-stopper. On LRU init, any 0-byte cache file is now deleted and skipped instead of being replayed into lru.set(key, 0). This is what was throwing at disk-lru-cache.external.js:46 and poisoning the disk-LRU singleton for the pod's lifetime. Self-healing: even a pre-existing 0-byte file (or one from any future source) can no longer kill the pod.
- writeToCacheDir — atomic write. Writes to a temp file in the cache root, then renames it into place. A write interrupted by pod termination can no longer leave a truncated 0-byte file inside the cache dir. The temp file lives outside the cacheKey dir, so readFromCacheDir never picks it up, and a leftover .tmp is harmless (it's not a dir, so init skips it; your cleanup job sweeps it later).
- ImageOptimizerCache.set — request-time guard. Returns early when the optimized buffer is empty, so a 0-byte image is never written and you stop getting the Failed to write image to cache … log line.
- added a commit that references this issue
on Jul 2, 2026 Hi @styfle, sorry for tagging you directly,
I wonder if this bug is something you could take a look at? The fix seems simple enough; it just needs somebody's attention :)Thanks!
These two PRs should fix it:
Reacted by Taras, Erik Margetis, Mohamed Eslam and Philippegithub-actions commented
on Aug 13, 2026 on Aug 13, 2026 – with GitHub ActionsContributorMore actionsThis issue was closed by a maintainer. If you think it was closed by mistake, you can request that it be reopened within the next 14 days.
Reply with
Reopen: <reason>and explain why the issue is still relevant. If you opened this issue or commented before it was closed, the issue will be reopened automatically.After 14 days, please open a new issue with up-to-date details and a reproduction, when applicable.
Hey @styfle, are there plans to backport this in 16.3.x or will it be included in 16.4.0 only?
- Reacted by Taras, Philippe and Erik Margetis
github-actions commented
on Sep 19, 2026 on Sep 19, 2026 – with GitHub ActionsContributorMore actionsThis closed issue has been automatically locked because it had no new activity for 2 weeks. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.
- locked as resolved and limited conversation to collaborators
on Sep 19, 2026
Link to the code that reproduces this issue
https://github.com/MohamedEslam04/next-image-lru-repro
To Reproduce
Fresh Next.js app with App Router + Turbopack:
npx create-next-app@canary lru-repro --typescript --app --turbopack --use-npm cd lru-reproPlant a 0-byte file in the image cache. This is exactly what
next devleaves behind when killed mid-write on Windows (Ctrl-C, terminal restart, OOM, antivirus interrupt) —fs.promises.writeFilecreates the inode before flushing bytes.Start dev and trigger image optimization (use a raster source — SVG needs
dangerouslyAllowSVG). The disk-LRU singleton initializes lazily on the first image request, so the 0-byte file must be present before this step. If it isn't, restartnext devafter planting and re-curl.Observe the dev server stdout:
The error fires on every subsequent image-optimization request, indefinitely. Pages still render, but the disk LRU is permanently broken for the lifetime of the process.
Current vs. Expected behavior
Current: A single 0-byte file under
.next/cache/images/causes:initCacheEntriesinnext/dist/server/image-optimizer.js:179pushes{ key, size: 0 }for it.getOrInitDiskLRUinnext/dist/server/lib/disk-lru-cache.external.js:43-47replays the entry withlru.set(key, 0).LRUCache.setinnext/dist/server/lib/lru-cache.js:91-99throws becausesize <= 0._diskLRUPromiserejected. Every futureawait getOrInitDiskLRU(...)re-throws — the cache is poisoned for the rest of the process.Failed to write image to cache <key>is the current write's key, not the corrupt entry's. This makes the bug hard to diagnose — the corrupt key never appears in any log line.Expected: A corrupt/empty file in the cache directory should be silently ignored during init (or atomic writes should prevent it from ever existing). One bad entry should never permanently disable image optimization.
Provide environment information
Which area(s) are affected? (Select all that apply)
Image (next/image)
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Where the bug lives
next/dist/server/image-optimizer.jsinitCacheEntriesreads every file's byte length and pushes it asentry.size— including 0-byte files.next/dist/server/lib/disk-lru-cache.external.jsgetOrInitDiskLRUreplays all entries into the LRU. Module-scoped_diskLRUPromisecaches the rejection.next/dist/server/lib/lru-cache.jsLRUCache.setthrows unconditionally forsize <= 0.Suggested fixes (any one resolves it)
Skip 0-byte entries during init (smallest change):
Atomic writes in
writeToCacheDir— write to<filename>.tmp, fsync, then rename. Prevents 0-byte files from ever existing on disk.Don't poison the singleton in
getOrInitDiskLRU— iflru.setthrows for any one entry during replay, log + continue rather than reject the whole init promise.Graceful skip in
LRUCache.set— forsize <= 0, warn (not throw) and returnfalse(matching the existing behavior forsize > maxSizeat line 100-103).Workaround for affected users
Provenance
The disk image cache was introduced in vercel/next.js@39eb8e0 (
feat(next/image): add lru disk cache and images.maximumDiskCacheSize). All affected code paths above were added in that commit.How I hit this
next@16.2.4+bun run devon Windows 11. A previousnext devwas killed by Ctrl-C mid-image-optimization, leaving a 0-byte AVIF under.next/dev/cache/images/<key>/. From the nextnext devonwards, every image request logged the LRU error. Settingimages.unoptimized: truesilenced it; downgrading tonext@16.1.x(pre-disk-cache) avoided it entirely. After removing the 0-byte file, the error stopped immediately.