HEAD requests for Next.js image return 400 Bad Request when image is not yet cached
Link to the code that reproduces this issue
https://github.com/Git-I985/next-head-image-empty-cache
Description
When making a HEAD request to an image served via the Next.js Image Optimizer (/_next/image) before the image is cached, the server returns 400 Bad Request.
- On Next.js 15.3.x:
- A first HEAD request poisoned the cache by storing an empty file, causing subsequent GET requests to return an empty response.
- On Next.js 15.5.x:
- The cache poisoning seems fixed (no empty files are written).
- However, the first uncached HEAD request still fails with 400 Bad Request.
- Only after a GET request succeeds and the image is cached, subsequent HEAD requests return 200 OK.
This behavior breaks CDNs/proxies that issue HEAD requests for validation or preflight, since they receive a 400 instead of headers describing the resource.
To Reproduce
Current version
- Build and start
npm run build && npm run start - Clear cache
rm -rf .next/cache/images
- Make HTTP HEAD request to the image resource
curl -IL "http://localhost:3000/_next/image?url=/_next/static/media/test-image.daca9759.jpg&w=3840&q=75"
Result (problem):
HTTP/1.1 400 Bad Request
Date: Tue, 23 Sep 2025 09:44:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
⨯ The requested resource isn't a valid image for /_next/static/media/test-image.daca9759.jpg received null
⨯ The requested resource isn't a valid image for /_next/static/media/test-image.daca9759.jpg received null
⨯ The requested resource isn't a valid image for /_next/static/media/test-image.daca9759.jpg received null
- Make HTTP GET request to the image resource
curl -D -- -o test.webp "http://localhost:3000/_next/image?url=/_next/static/media/test-image.daca9759.jpg&w=3840&q=75"
Result
HTTP/1.1 200 OK
Vary: Accept
Cache-Control: public, max-age=315360000, immutable
ETag: RyS1td48qGQV0eM3i0m8noPJ3D5-WZfkAMCZjHfkHHo
Content-Type: image/jpeg
Content-Disposition: attachment; filename="test-image.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache: HIT
Content-Length: 134217
Date: Tue, 23 Sep 2025 09:47:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Now image cached properly and next HTTP HEAD request will be OK 5. Make HTTP HEAD request to the image resource again
curl -IL "http://localhost:3000/_next/image?url=/_next/static/media/test-image.daca9759.jpg&w=3840&q=75"
Result:
HTTP/1.1 200 OK
Vary: Accept
Cache-Control: public, max-age=315360000, immutable
ETag: RyS1td48qGQV0eM3i0m8noPJ3D5-WZfkAMCZjHfkHHo
Content-Type: image/jpeg
Content-Disposition: attachment; filename="test-image.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
X-Nextjs-Cache: HIT
Content-Length: 134217
Date: Tue, 23 Sep 2025 09:48:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Older versions (reproducible on 15.3.X)
first HEAD request (when the image was not yet in the cache) poisoned the cache, and an empty file was stored there, so the following GET requests returned an empty file.
# Next 15.3.0
# clear next cache
# DO HTTP GET
$ curl -D - -o test.jpg "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:42:28 GMT
Content-Length: 2043793
Cache-Tag: nextjs-source
X-Nextjs-Cache: MISS
# DO HTTP GET AGAIN
$ curl -D - -o test.jpg "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:42:33 GMT
Content-Length: 2043793
Cache-Tag: nextjs-source
X-Nextjs-Cache: HIT
# Stable, normal, cached the response with the correct content-length.
# I cleared the cache inside the container, now I make a HEAD request.
$ curl -IL "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
HTTP/1.1 200 OK
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:43:23 GMT
Content-Length: 0 # <--- PROBLEM
Cache-Tag: nextjs-source
X-Nextjs-Cache: MISS # <--- CACHED AFTER THIS REQUEST
# DO HTTP GET
$ curl -D - -o test.jpg "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:44:22 GMT
Content-Length: 0 # <--- PROBLEM
Cache-Tag: nextjs-source
X-Nextjs-Cache: HIT
# Check downloaded file size
xdd test.jpg | head
# empty...
# Something went wrong. The first HEAD cached an empty response, and now GET returns it.
# I cleared the cache inside the container.
# First, I do a GET to cache a normal response.
$ curl -D - -o test.jpg "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:45:28 GMT
Content-Length: 2043793 # Normal response
Cache-Tag: nextjs-source
X-Nextjs-Cache: MISS
$ curl -IL "http://localhost:4000/_next/image?url=/_next/static/media/file.e2ff9d29.jpg&w=3840&q=74&t=12"
HTTP/1.1 200 OK
Cache-Control: public, max-age=315360000, immutable
Vary: Accept
ETag: Vy8iMWYyZjkxLTE5OTcxOTNjZjIzIg
Content-Type: image/jpeg
Content-Disposition: attachment; filename="file.jpeg"
Content-Security-Policy: script-src 'none'; frame-src 'none'; sandbox;
Date: Mon, 22 Sep 2025 13:45:31 GMT
Content-Length: 2043793 # # Normal response
Cache-Tag: nextjs-source
X-Nextjs-Cache: HIT
Current vs. Expected behavior
Current (15.5.x):
- First uncached HEAD request → 400 Bad Request with error logs (received null).
- No image written to cache.
- Only after a successful GET request, HEAD starts returning 200 OK.
Expected:
- HEAD request should behave like a GET request but without streaming the body.
- Even if the image is not cached yet, the first HEAD should:
- Return 200 OK.
- Include correct headers (Content-Type, Content-Length, ETag, etc.).
- Either skip writing to cache or safely populate cache metadata without breaking.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:40 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6041
Available memory (MB): 49152
Available CPU cores: 12
Binaries:
Node: 22.17.0
npm: 10.9.2
Yarn: N/A
pnpm: 9.11.0
Relevant Packages:
next: 15.5.3 // Latest available version is detected (15.5.3).
eslint-config-next: 15.5.3
react: 19.1.0
react-dom: 19.1.0
typescript: 5.9.2
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Image (next/image), Not sure
Which stage(s) are affected? (Select all that apply)
Other (Deployed), next dev (local), next start (local)
Versions
- Next.js 15.3.x → cache poisoned by HEAD (empty files stored).
- Next.js 15.5.x → cache poisoning fixed, but HEAD requests still fail with 400 Bad Request.
Impact
- CDNs/load balancers (e.g. Cloudflare, Fastly, etc.) that send HEAD may treat the image route as broken.
- It prevents reliable header-only introspection of images before cache warming.
Thanks for the report! I believe you found the root cause to another issue.
- https://github.com/vercel/next.js/issues/82703
I think the solution is we need to coerce HEAD to GET before making the request to the upstream src image. Then we can cache it as usual with the complete body, but then only serve the headers (not body) to fulfill the original HEAD request.
Would you like to submit a PR to fix this?
I also have a same issue. So I did put a option in configure and solved it temporary.
const nextConfig = {
images: {
unoptimized: true,
},
};
Thanks for the report! I believe you found the root cause to another issue.
I think the solution is we need to coerce HEAD to GET before making the request to the upstream src image. Then we can cache it as usual with the complete body, but then only serve the headers (not body) to fulfill the original HEAD request.
Would you like to submit a PR to fix this?
yes ive reproduced the issue by following steps
@styfle Im so sorry, Im a very junior developer, and Im not sure if I can figure out how everything works in Next.js or where to dig into :((
first week in frontend
Alright, I think I've got it, started a draft, I'll try to work on it later today. It looks like for external resources, we already use GET, but for internal we were using the incoming request method - I guess, there is a use case where one would want to keep the incoming request method?
I guess, there is a use case where one would want to keep the incoming request method?
No. It should always use the GET method when requesting the upstream src image.
I guess this can temporary fix the issue, i checked - it works, crutch, but anyway
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// avoid next image head requests cache poisoning
async function handleNextImageHEADRequest(request: NextRequest) {
const { pathname } = request.nextUrl;
const nextImagePathname = '/_next/image';
if (pathname === nextImagePathname && request.method === 'HEAD') {
const res = await fetch(request.url, {
method: 'GET',
headers: new Headers(request.headers),
});
return new NextResponse(null, {
status: res.status,
headers: new Headers(res.headers),
});
}
if (pathname === nextImagePathname) {
return NextResponse.next();
}
}
export async function middleware(request: NextRequest) {
let response = await handleNextImageHEADRequest(request);
if (response) {
return response;
}
response = /* some other logic if needed, e.g. next-intl etc */
}
export const config = {
// Matcher ignoring `/_next/` and `/api/`
matcher: ['/((?!healthz|api|_next/static|favicon.ico|config.json).*)'], // this is my own stuff
};
any news about this issue?
@Git-I985's fix works for me, but would be nice to have a real fix.
Oh wow, I had totally forgot about this - we need to revive the PR I had started. I'll try to pick it up this weekend, but I think it was pretty much ready, just needed to test the behavior.
https://github.com/vercel/next.js/pull/84180 I updated the PR, I still think there's one more item to consider (returning empty body, when the incoming request is HEAD) - might be a breaking change though 🤔