All articles

Capping the Next.js 16 image cache with maximumDiskCacheSize

In Next.js 16 you can cap the disk cache used by the image optimiser with images.maximumDiskCacheSize, a size in bytes in next.config.js. Once the cache directory passes that size, Next.js deletes the least recently used optimised images on its own. If you leave it unset, the cap defaults to half of the free space on the cache's filesystem, which on a large network volume is effectively no cap at all. Setting it is a better answer than a shell job that prunes files by age.

Where the problem came from

I look after a group of Next.js front ends that run as containers, each with a persistent volume for the parts of .next/cache that should survive a restart. After moving rendered pages out to a shared cache, the only thing left on the volume was the optimised image cache: every resized and re-encoded variant that /_next/image has produced.

That cache only ever grows by default. Each new combination of source image, width, quality and format adds a file, and a content-heavy site produces a lot of combinations. On a network volume that nobody is watching, the question is not whether it fills up but when.

Why pruning by modification time was the wrong tool

The first fix was the obvious one: a step in the container's start script that deleted cache entries older than some number of days. It worked, in the sense that the directory stopped growing without bound. It also had three problems that only became clear once it was in place.

  • Age is not use. A file's modification time tells you when the variant was written, not when it was last served. A hero image on the home page that is requested constantly looks exactly as old as a variant that was requested once. Prune by age and you throw away the popular ones as readily as the dead ones.
  • It was coupled to the CMS. The prune window had to stay above the cache lifetime the CMS sends for its images, or the script would delete entries the optimiser still considered fresh and force needless re-optimisation. Change the CMS headers and the script silently becomes wrong.
  • It ran at start-up. Walking a large directory on network storage before the server starts adds latency to every pod start, which is the moment you most want to be quick.

How maximumDiskCacheSize works in Next.js 16

Reading the image optimiser in Next.js 16.4 makes the behaviour clear, and it is simpler than the docs make it sound:

  • On first use, each server process builds an in-memory LRU index of the cache directory. It reads every existing entry, sorts them by expiry and replays them into the LRU, so a restarted pod knows what is already on disk.
  • Every cache hit promotes that entry to most recently used. Every write adds it, and if the total passes the cap, the least recently used entries are evicted and their files deleted.
  • If maximumDiskCacheSize is unset, the cap is computed once at start-up as half of the available space reported by statfs for the cache directory.
  • Setting it to 0 turns the disk cache off entirely.
  • An image larger than the whole cap cannot be tracked, so it is logged as a write failure and not cached.

That is exactly the policy the shell script was trying and failing to approximate. Recency of use is what you want to keep, and the server is the only place that sees every hit.

The configuration

The change is one line in next.config.js, plus deleting the prune step from the start script:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    // Optimised variants kept on disk; least recently used are evicted past this size.
    maximumDiskCacheSize: 2 * 1024 * 1024 * 1024, // 2 GiB
    minimumCacheTTL: 60 * 60 * 4, // the Next.js 16 default, shown for clarity
  },
};

module.exports = nextConfig;

Pick a size that holds the working set comfortably. A few gigabytes is plenty for most marketing and content sites; if you are unsure, look at how large the cache directory grew before you capped it and how much of it was touched recently. Too small a cap means popular variants get evicted and re-optimised, which costs CPU on every miss.

What to watch for

A few things are worth knowing before you rely on it.

The index is per process. The LRU lives in memory in each Node.js process. If several pods share one volume, each one builds its index from what is on disk when it starts and then only tracks its own reads and writes. Each pod enforces the cap against its own view, so the directory can drift above the cap while files written by other pods go unaccounted for until the next restart. With one volume per pod this does not arise.

It only applies to the default disk cache. If you have configured a custom cache handler for images, the disk LRU is not initialised and this setting does nothing; eviction is then the handler's job.

The default is relative to free space, not total space. Half of the free space is measured once, when the index is first built. On a mostly empty multi-terabyte volume that is a very large number, and on a nearly full disk it is a small one. Either way it is unlikely to be the number you would choose, which is the main reason to set it.

Key takeaways

  • Next.js 16 evicts least-recently-used optimised images once the cache passes images.maximumDiskCacheSize, so you do not need a separate prune job.
  • Leaving maximumDiskCacheSize unset caps the cache at half the free space on its filesystem, which on a large network volume is effectively unbounded.
  • Pruning the image cache by file age deletes popular images as readily as unused ones, because modification time records when a variant was written, not when it was served.
  • The LRU index is held in memory per process, so pods that share one cache volume each enforce the cap only against their own view of it.
  • The setting has no effect when a custom image cache handler is configured, and 0 disables the disk cache.