Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion docs/01-app/03-api-reference/02-components/image.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,36 @@ module.exports = {
}
```

#### `maximumDiskCacheSize`

The default image optimization loader will write optimized images to disk so subsequent requests can be served faster from the disk cache.

You can configure the maximum disk cache size in bytes, for example 500 MB:

```js filename="next.config.js"
module.exports = {
images: {
maximumDiskCacheSize: 500_000_000,
},
}
```

You can also disable the disk cache entirely by setting the value to `0`.

```js filename="next.config.js"
module.exports = {
images: {
maximumDiskCacheSize: 0,
},
}
```

If no value is configured, the default behavior is to check the current available disk space once during startup and use 50%.

When the disk cache exceeds the configured size, the least recently used optimized images will be deleted until the cache is under the limit again.

Alternatively, you can implement your own cache handler using [`cacheHandler`](/docs/app/api-reference/config/next-config-js/incrementalCacheHandlerPath) which will ignore the `maximumDiskCacheSize` configuration.

#### `maximumResponseBody`

The default image optimization loader will fetch source images up to 50 MB in size.
Expand Down Expand Up @@ -915,7 +945,6 @@ This `next/image` component uses browser native [lazy loading](https://caniuse.c
- Use CSS `@supports (font: -apple-system-body) and (-webkit-appearance: none) { img[loading="lazy"] { clip-path: inset(0.6px) } }`
- Use [`priority`](#priority) if the image is above the fold
- [Firefox 67+](https://bugzilla.mozilla.org/show_bug.cgi?id=1556156) displays a white background while loading. Possible solutions:

Comment thread
styfle marked this conversation as resolved.
- Enable [AVIF `formats`](#formats)
- Use [`placeholder`](#placeholder)

Expand Down Expand Up @@ -1275,6 +1304,7 @@ export default function Home() {

| Version | Changes |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `v15.5.14` | `maximumDiskCacheSize` configuration added. |
| `v15.3.0` | `remotePatterns` added support for array of `URL` objects. |
| `v15.0.0` | `contentDispositionType` configuration default changed to `attachment`. |
| `v14.2.23` | `qualities` configuration added. |
Expand Down
4 changes: 3 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,5 +787,7 @@
"786": "Server Actions are not enabled for this application. This request might be from an older or newer deployment.\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
"787": "Failed to find Server Action. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
"788": "Failed to find Server Action%s. This request might be from an older or newer deployment.\\nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action",
"789": "LRUCache: calculateSize returned %s, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth."
"789": "LRUCache: calculateSize returned %s, but size must be > 0. Items with size 0 would never be evicted, causing unbounded cache growth.",
"790": "Invariant: cache entry \"%s\" not found in dir \"%s\"",
"791": "image of size %s could not be tracked by lru cache"
}
1 change: 1 addition & 0 deletions packages/next/src/server/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,7 @@ export const configSchema: zod.ZodType<NextConfig> = z.lazy(() =>
.optional(),
loader: z.enum(VALID_LOADERS).optional(),
loaderFile: z.string().optional(),
maximumDiskCacheSize: z.number().int().min(0).optional(),
maximumResponseBody: z
.number()
.int()
Expand Down
138 changes: 114 additions & 24 deletions packages/next/src/server/image-optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { sendEtagResponse } from './send-payload'
import { getContentType, getExtension } from './serve-static'
import * as Log from '../build/output/log'
import isError from '../lib/is-error'
import { getOrInitDiskLRU } from './lib/disk-lru-cache.external'
import { parseUrl } from '../lib/url'
import type { CacheControl } from './lib/cache-control'
import { InvariantError } from '../shared/lib/invariant-error'
Expand Down Expand Up @@ -55,6 +56,29 @@ const BLUR_QUALITY = 70 // should match `next-image-loader`

let _sharp: typeof import('sharp')

async function initCacheEntries(
cacheDir: string
): Promise<Array<{ key: string; size: number; expireAt: number }>> {
const cacheKeys = await promises.readdir(cacheDir).catch(() => [])
const entries: Array<{ key: string; size: number; expireAt: number }> = []

for (const cacheKey of cacheKeys) {
try {
const { expireAt, buffer } = await readFromCacheDir(cacheDir, cacheKey)
entries.push({
key: cacheKey,
size: buffer.byteLength,
expireAt,
})
} catch {
// Skip entries that can't be read from disk
}
}

// Sort oldest-first so we can replay them chronologically into LRU
return entries.sort((a, b) => a.expireAt - b.expireAt)
}

export function getSharp(concurrency: number | null | undefined) {
if (_sharp) {
return _sharp
Expand Down Expand Up @@ -133,14 +157,16 @@ export function getImageEtag(image: Buffer) {
}

async function writeToCacheDir(
dir: string,
cacheDir: string,
cacheKey: string,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,
etag: string,
upstreamEtag: string
) {
const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
const filename = join(
dir,
`${maxAge}.${expireAt}.${etag}.${upstreamEtag}.${extension}`
Expand All @@ -152,6 +178,37 @@ async function writeToCacheDir(
await promises.writeFile(filename, buffer)
}

async function readFromCacheDir(cacheDir: string, cacheKey: string) {
const dir = join(/* turbopackIgnore: true */ cacheDir, cacheKey)
const files = await promises.readdir(dir)
const file = files[0]
if (!file) {
throw new Error(
`Invariant: cache entry "${cacheKey}" not found in dir "${cacheDir}"`
)
}
const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] = file.split(
'.',
5
)
const filePath = join(/* turbopackIgnore: true */ dir, file)
const buffer = await promises.readFile(/* turbopackIgnore: true */ filePath)
const expireAt = Number(expireAtSt)
const maxAge = Number(maxAgeSt)
return { maxAge, expireAt, etag, upstreamEtag, buffer, extension }
}

async function deleteFromCacheDir(cacheDir: string, cacheKey: string) {
return promises
.rm(join(/* turbopackIgnore: true */ cacheDir, cacheKey), {
recursive: true,
force: true,
})
.catch((err) => {
Log.error(`Failed to delete cache key ${cacheKey}`, err)
})
}

/**
* Inspects the first few bytes of a buffer to determine if
* it matches the "magic number" of known file signatures.
Expand Down Expand Up @@ -310,6 +367,8 @@ export async function detectContentType(
export class ImageOptimizerCache {
private cacheDir: string
private nextConfig: NextConfigComplete
private cacheDiskLRU?: ReturnType<typeof getOrInitDiskLRU>
private isDiskCacheEnabled?: boolean

static validateParams(
req: IncomingMessage,
Expand Down Expand Up @@ -496,35 +555,51 @@ export class ImageOptimizerCache {
}) {
this.cacheDir = join(distDir, 'cache', 'images')
this.nextConfig = nextConfig

// Eagerly start LRU initialization for filesystem cache
if (
nextConfig.images.maximumDiskCacheSize !== 0 &&
nextConfig.experimental.isrFlushToDisk
) {
this.isDiskCacheEnabled = true
this.cacheDiskLRU = getOrInitDiskLRU(
this.cacheDir,
nextConfig.images.maximumDiskCacheSize,
initCacheEntries,
deleteFromCacheDir
)
}
}

async get(cacheKey: string): Promise<IncrementalResponseCacheEntry | null> {
// If the filesystem cache is disabled, return early
if (!this.isDiskCacheEnabled) {
return null
}

// Fall back to filesystem cache
try {
const cacheDir = join(this.cacheDir, cacheKey)
const files = await promises.readdir(cacheDir)
const now = Date.now()
const { maxAge, expireAt, etag, upstreamEtag, buffer, extension } =
await readFromCacheDir(this.cacheDir, cacheKey)

for (const file of files) {
const [maxAgeSt, expireAtSt, etag, upstreamEtag, extension] =
file.split('.', 5)
const buffer = await promises.readFile(join(cacheDir, file))
const expireAt = Number(expireAtSt)
const maxAge = Number(maxAgeSt)
// Promote entry in LRU (mark as recently used)
const lru = await this.cacheDiskLRU
lru?.get(cacheKey)

return {
value: {
kind: CachedRouteKind.IMAGE,
etag,
buffer,
extension,
upstreamEtag,
},
revalidateAfter:
Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 +
Date.now(),
cacheControl: { revalidate: maxAge, expire: undefined },
isStale: now > expireAt,
}
return {
value: {
kind: CachedRouteKind.IMAGE,
etag,
buffer,
extension,
upstreamEtag,
},
revalidateAfter:
Math.max(maxAge, this.nextConfig.images.minimumCacheTTL) * 1000 +
Date.now(),
cacheControl: { revalidate: maxAge, expire: undefined },
isStale: now > expireAt,
}
} catch (_) {
// failed to read from cache dir, treat as cache miss
Expand Down Expand Up @@ -554,13 +629,28 @@ export class ImageOptimizerCache {
throw new InvariantError('revalidate must be a number for image-cache')
}

// If the filesystem cache is disabled, return early
if (!this.isDiskCacheEnabled) {
return
}

// Fall back to filesystem cache
const expireAt =
Math.max(revalidate, this.nextConfig.images.minimumCacheTTL) * 1000 +
Date.now()

try {
const lru = await this.cacheDiskLRU
const success = lru?.set(cacheKey, value.buffer.byteLength)
if (success === false) {
throw new Error(
`image of size ${value.buffer.byteLength} could not be tracked by lru cache`
)
}

await writeToCacheDir(
join(this.cacheDir, cacheKey),
this.cacheDir,
cacheKey,
value.extension,
revalidate,
expireAt,
Expand Down
60 changes: 60 additions & 0 deletions packages/next/src/server/lib/disk-lru-cache.external.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { promises } from 'fs'
import { LRUCache } from './lru-cache'

/**
* Module-level LRU singleton for disk cache eviction.
* Initialized once on first `set()`, shared across all consumers.
* Once resolved, the promise stays resolved — subsequent calls just await the cached result.
*/
let _diskLRUPromise: Promise<LRUCache<number>> | null = null

/**
* Initialize or return the module-level LRU for disk cache eviction.
* Concurrent calls are deduplicated via the shared promise.
*
* @param cacheDir - The directory where cached files are stored
* @param maxDiskSize - Maximum disk cache size in bytes
* @param readEntries - Callback to scan existing cache entries (format-agnostic)
*/
export async function getOrInitDiskLRU(
cacheDir: string,
maxDiskSize: number | undefined,
readEntries: (
cacheDir: string
) => Promise<Array<{ key: string; size: number; expireAt: number }>>,
evictEntry: (cacheDir: string, cacheKey: string) => Promise<void>
): Promise<LRUCache<number>> {
if (!_diskLRUPromise) {
_diskLRUPromise = (async () => {
let maxSize = maxDiskSize
if (typeof maxSize === 'undefined') {
// Ensure cacheDir exists before checking disk space
await promises.mkdir(cacheDir, { recursive: true })
// Since config was not provided, default to 50% of available disk space
const { bavail, bsize } = await promises.statfs(cacheDir)
maxSize = Math.floor((bavail * bsize) / 2)
}

const lru = new LRUCache<number>(
maxSize,
(size) => size,
(cacheKey) => evictEntry(cacheDir, cacheKey)
)

const entries = await readEntries(cacheDir)
for (const entry of entries) {
lru.set(entry.key, entry.size)
}

return lru
})()
}
return _diskLRUPromise
}

/**
* Reset the module-level LRU singleton. Exported for testing only.
*/
export function resetDiskLRU(): void {
_diskLRUPromise = null
}
29 changes: 25 additions & 4 deletions packages/next/src/server/lib/lru-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('LRUCache', () => {
})

it('should set and get values', () => {
cache.set('key1', 'value1')
expect(cache.set('key1', 'value1')).toBe(true)
expect(cache.get('key1')).toBe('value1')
})

Expand Down Expand Up @@ -105,11 +105,11 @@ describe('LRUCache', () => {
expect(cache.currentSize).toBe(8) // 5 + 2 + 1
})

it('should handle items larger than max size', () => {
it('should prevent adding item larger than max size when lru is empty', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const cache = new LRUCache<string>(5, (value) => value.length)

cache.set('key1', 'toolarge') // size 8 > maxSize 5
expect(cache.set('key1', 'toolarge')).toBe(false) // size 8 > maxSize 5

expect(cache.has('key1')).toBe(false)
expect(cache.size).toBe(0)
Expand All @@ -120,6 +120,27 @@ describe('LRUCache', () => {
consoleSpy.mockRestore()
})

it('should prevent adding item larger than max size when lru is not empty', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation()
const cache = new LRUCache<string>(5, (value) => value.length)

expect(cache.set('key1', 'ab')).toBe(true) // size 2
expect(cache.set('key2', 'cd')).toBe(true) // size 2, total = 4

expect(cache.set('key3', 'toolarge')).toBe(false) // size 8 > maxSize 5, should be rejected

expect(cache.has('key1')).toBe(true)
expect(cache.has('key2')).toBe(true)
expect(cache.has('key3')).toBe(false)
expect(cache.size).toBe(2)
expect(cache.currentSize).toBe(4)
expect(consoleSpy).toHaveBeenCalledWith(
'Single item size exceeds maxSize'
)

consoleSpy.mockRestore()
})

it('should update size when overwriting existing keys', () => {
const cache = new LRUCache<string>(10, (value) => value.length)

Expand Down Expand Up @@ -184,7 +205,7 @@ describe('LRUCache', () => {
describe('Edge Cases', () => {
it('should handle zero max size', () => {
const cache = new LRUCache<string>(0)
cache.set('key1', 'value1')
expect(cache.set('key1', 'value1')).toBe(false)
expect(cache.has('key1')).toBe(false)
expect(cache.size).toBe(0)
})
Expand Down
Loading
Loading