Skip to content

next/image: a single 0-byte file in .next/cache/images/ permanently poisons the disk-LRU singleton #93757

Description

@MohamedEslam04

Link to the code that reproduces this issue

https://github.com/MohamedEslam04/next-image-lru-repro

To Reproduce

  1. Fresh Next.js app with App Router + Turbopack:

    npx create-next-app@canary lru-repro --typescript --app --turbopack --use-npm
    cd lru-repro
  2. Plant a 0-byte file in the image cache. This is exactly what next dev leaves behind when killed mid-write on Windows (Ctrl-C, terminal restart, OOM, antivirus interrupt) — fs.promises.writeFile creates the inode before flushing bytes.

    # next dev (this issue):
    mkdir -p .next/dev/cache/images/anyKey
    : > .next/dev/cache/images/anyKey/14400.1.a.b.avif
    
    # next start / production builds use a different path:
    #   .next/cache/images/anyKey/14400.1.a.b.avif
  3. 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, restart next dev after planting and re-curl.

    # Add a tiny PNG to public/ so we have a raster source:
    node -e "require('fs').writeFileSync('public/pixel.png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=','base64'))"
    
    npm run dev &
    sleep 8
    curl -sS -o /dev/null "http://localhost:3000/_next/image?url=%2Fpixel.png&w=256&q=75"

Observe the dev server stdout:

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 ...
⨯ Failed to write image to cache <some-key>

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:

  1. initCacheEntries in next/dist/server/image-optimizer.js:179 pushes { key, size: 0 } for it.
  2. getOrInitDiskLRU in next/dist/server/lib/disk-lru-cache.external.js:43-47 replays the entry with lru.set(key, 0).
  3. LRUCache.set in next/dist/server/lib/lru-cache.js:91-99 throws because size <= 0.
  4. The throw escapes the async IIFE, leaving the module-scoped _diskLRUPromise rejected. Every future await getOrInitDiskLRU(...) re-throws — the cache is poisoned for the rest of the process.
  5. The cache key shown in 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

Operating System:
  Platform: win32
  Arch: x64
  Version: Windows 11 Pro
  Available memory (MB): 16160
  Available CPU cores: 12
Binaries:
  Node: 24.13.1
  npm: 11.10.1
  Yarn: 4.12.0
  pnpm: N/A
Relevant Packages:
  next: 16.2.4
  eslint-config-next: N/A
  react: 19.2.5
  react-dom: 19.2.5
  typescript: 6.0.3
Next.js Config:
  output: N/A

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

File Lines Role
next/dist/server/image-optimizer.js 179-196 initCacheEntries reads every file's byte length and pushes it as entry.size — including 0-byte files.
next/dist/server/lib/disk-lru-cache.external.js 30-52 getOrInitDiskLRU replays all entries into the LRU. Module-scoped _diskLRUPromise caches the rejection.
next/dist/server/lib/lru-cache.js 91-99 LRUCache.set throws unconditionally for size <= 0.

Suggested fixes (any one resolves it)

  1. Skip 0-byte entries during init (smallest change):

    // image-optimizer.js — initCacheEntries
    const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey);
    if (buffer.byteLength === 0) continue;
    entries.push({ key: cacheKey, size: buffer.byteLength, expireAt });
  2. Atomic writes in writeToCacheDir — write to <filename>.tmp, fsync, then rename. Prevents 0-byte files from ever existing on disk.

  3. Don't poison the singleton in getOrInitDiskLRU — if lru.set throws for any one entry during replay, log + continue rather than reject the whole init promise.

  4. Graceful skip in LRUCache.set — for size <= 0, warn (not throw) and return false (matching the existing behavior for size > maxSize at line 100-103).

Workaround for affected users

# Stop the dev server, then:
find .next/cache/images -size 0 -delete
# Restart dev server.

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 dev on Windows 11. A previous next dev was killed by Ctrl-C mid-image-optimization, leaving a 0-byte AVIF under .next/dev/cache/images/<key>/. From the next next dev onwards, every image request logged the LRU error. Setting images.unoptimized: true silenced it; downgrading to next@16.1.x (pre-disk-cache) avoided it entirely. After removing the 0-byte file, the error stopped immediately.

Activity

  1. MohamedEslam04 commented on May 11, 2026

    @MohamedEslam04
    Author

    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 for next 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 + unhandledRejection pair, indefinitely.

    Workaround verified clean — after find .next -path '*/cache/images*' -size 0 -delete and a server restart, three follow-up image requests all returned HTTP 200 with zero LRU errors in stdout.

  2. PhilMeyr commented on Jun 4, 2026

    @PhilMeyr

    Same root cause, second trigger: images.unoptimized: true + read-only FS leaks the rejected init promise as an unhandledRejection

    We hit a different path into the exact same fragility described here (the module-scoped _diskLRUPromise in disk-lru-cache.external.ts becoming 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 the unoptimized variant produces a genuinely unhandled rejection (no try/catch ever runs), which is worse for serverless deployments.

    Next.js: 16.2.7 · Runtime: Node 24.x · Platform: AWS Lambda (output: 'standalone', read-only /var/task filesystem) · Config: images.unoptimized: true

    What 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/image
    

    unhandledPromiseRejection: true. The HTTP request itself responds fine (a 404 — see below), so this is purely a leaked background promise, not a render failure. It fires roughly once per cold start (because _diskLRUPromise is a per-process module singleton).

    Why it leaks (no try/catch ever consumes it)

    The chain in handleNextImageRequest (next/dist/server/next-server.js) is, in order:

    1. The cache is constructed unconditionally, before any config check:
      const imageOptimizerCache = new ImageOptimizerCache({ distDir, nextConfig, cacheHandler });
    2. ImageOptimizerCache's constructor eagerly fires the disk-LRU init and stores the promise without awaiting it (next/dist/server/image-optimizer.js):
      constructor({ 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
        }
      }
      On a read-only filesystem the mkdir inside getOrInitDiskLRU rejects, so this.cacheDiskLRU is now a rejected promise.
    3. 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.cacheDiskLRU are ImageOptimizerCache#get / #set, and those awaits are wrapped in try/catch. With unoptimized: true the handler returns at step 3, so neither get() nor set() ever runs → the rejected cacheDiskLRU promise is never awaited by anything → Node reports a true unhandledRejection.

    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, under unoptimized, 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 ... mkdir originating from disk-lru-cache.external, while the request returns 404. (On a normally writable FS the same unoptimized request just 404s with no error — the leak only surfaces when mkdir fails.)

    Suggested fixes (either/both)

    1. Don't construct/init the disk cache when it can't be used. Move the new ImageOptimizerCache(...) after the loader !== 'default' || unoptimized early-return, or make the constructor's eager-init guard also check those (the disk image cache is pointless when unoptimized or a non-default loader is configured).
    2. 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 to null on 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 during initCacheEntries (the 0-byte case) fits naturally here too.

    Workaround for anyone on read-only/serverless FS today

    Setting images.maximumDiskCacheSize: 0 makes the constructor's guard skip the eager init entirely, so getOrInitDiskLRU is never called and the rejection never happens. This is a clean complement to unoptimized: true (there is nothing to cache anyway).

  3. derBlume commented on Jun 17, 2026

    @derBlume

    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-package and it so far does seem to solve the issue:

    next+15.5.19.patch

    diff --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:

    1. 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.
    2. 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).
    3. 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.
  4. taraspos commented on Jul 17, 2026

    @taraspos

    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 :)

  5. styfle commented on Aug 13, 2026

    @styfle
    Member
  6. github-actions commented on Aug 13, 2026

    @github-actions
    Contributor

    This 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.

  7. taraspos commented on Sep 2, 2026

    @taraspos

    Hey @styfle, are there plans to backport this in 16.3.x or will it be included in 16.4.0 only?

  8. styfle commented on Sep 2, 2026

    @styfle
    Member
  9. github-actions commented on Sep 19, 2026

    @github-actions
    Contributor

    This 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.

  10. locked as resolved and limited conversation to collaborators on Sep 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions