Link to the code that reproduces this issue
https://github.com/Neeptossss/next-image-abort-hang
To Reproduce
git clone https://github.com/Neeptossss/next-image-abort-hang
cd next-image-abort-hang
npm install
./repro.sh
repro.sh generates its own source image, builds the app, and starts and stops next start itself. It needs nothing but Node, npm and curl.
Each phase sweeps 84 cold cache keys (14 widths x 6 qualities), aborting each transform mid-flight with curl -m 0.02 and then requesting the very same URL as a fresh client with a 6 s timeout. A sweep is necessary because the trigger is a race. the disconnect has to land while the server is still streaming the source file so a single attempt rarely lands. Widths are never reused, since an already-successful transform is served from the disk cache and does not re-enter the code path under test.
Output on next@16.3.1:
=== 1. stock next: sweep 84 cold /public transforms, aborting each mid-flight ===
=> 6 of 84 URLs are now permanently unresponsive: 32:50 1080:50 48:75 1920:75 828:80 384:90
=== 2. control: external images (remotePatterns) get the same treatment ===
=> 0 of 84 external URLs unresponsive (this path never touches the client socket)
=== 3. it is process state, not disk state: restart, retry the dead URLs ===
w=32 q=50 after restart -> 200
w=1080 q=50 after restart -> 200
w=48 q=75 after restart -> 200
w=1920 q=75 after restart -> 200
w=828 q=80 after restart -> 200
w=384 q=90 after restart -> 200
=== 4. negative control: drop 'socket: _req.socket', same sweep ===
=> 0 of 84 URLs unresponsive
=== summary ===
stock next, local images: 6 / 84 permanently hung
stock next, external images: 0 / 84
patched, local images: 0 / 84
=== server.log (nothing is logged for any of this) ===
▲ Next.js 16.3.1
- Local: http://localhost:3000
✓ Ready in 60ms
✓ Running next.config.js took 7ms
Current vs. Expected behavior
Current: after a single client aborts one cold /_next/image request, every subsequent request for that exact transform URL hangs indefinitely, for all clients, until the process restarts. Other widths and qualities of the same image keep working. Nothing is logged at any level.
Expected: a client aborting its own request affects only that request. The shared transform either completes or fails, and the coalesced cache key is released either way.
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 27.0.0
Available memory (MB): 24576
Available CPU cores: 14
Binaries:
Node: 22.19.0
npm: 10.9.3
pnpm: 10.33.0
Relevant Packages:
next: 16.3.1
react: 19.2.0
react-dom: 19.2.0
Next.js Config:
output: standalone (production), N/A in the reproduction
Which area(s) are affected?
Image (next/image)
Which stage(s) are affected?
next start (local), self-hosted production
Additional context
This was found in production, not in a test. A live storefront runs output: standalone behind nginx. Two trust badges on every product page, loading="lazy", are rendered at a fixed 40 px with sizes="40px". Four cache keys were wedged:
| source |
w=32 |
w=48 |
w=64 |
w=96 |
w=128 |
| logo-a.webp |
200 |
hang |
hang |
200 |
200 |
| logo-b.webp |
200 |
hang |
hang |
200 |
200 |
| other images on the same page |
200 |
200 |
200 |
200 |
200 |
Two properties of this made it unusually hard to diagnose, and both come straight from the cache key being hash(href, width, quality, mimeType):
- It only affected some visitors. sizes="40px" resolves to w=48 at DPR 1 and w=64 at DPR 1.5, but to w=96 at DPR 2. The badges were invisible on ordinary desktop monitors and perfectly fine on every retina laptop and phone including the ones used to check whether the site was broken.
- curl said everything was fine. mimeType is part of the key, so the JPEG variant is a different entry from the WebP one. Any request without Accept: image/webp curl, uptime monitors, most crawlers got a healthy 200 from the intact JPEG key, while every real browser hung on the WebP key and rendered nothing. nginx returned 504 after 60 s; the Node process logged nothing at all.
Lazy loading is what makes this reachable at scale in the wild: a visitor who navigates away before a below-the-fold image enters the viewport aborts exactly the kind of cold transform this needs. The keys that died were the two most requested small widths of the two images present on every product page.
Relationship to existing reports. This is the same root cause as #96538, and #96542 proposes removing socket: _req.socket, which is also what step 4 of this reproduction isolates. What this report adds:
- an independent occurrence, in production rather than in CI, with the visitor-facing symptoms above;
- confirmation that it still reproduces on 16.3.1;
- a reproduction with a negative control, showing the fix candidate actually closes it (6/84 -> 0/84) rather than only showing the symptom;
- confirmation that external images are not affected, which scopes the fix.
Link to the code that reproduces this issue
https://github.com/Neeptossss/next-image-abort-hang
To Reproduce
git clone https://github.com/Neeptossss/next-image-abort-hang cd next-image-abort-hang npm install ./repro.shrepro.sh generates its own source image, builds the app, and starts and stops next start itself. It needs nothing but Node, npm and curl.
Each phase sweeps 84 cold cache keys (14 widths x 6 qualities), aborting each transform mid-flight with curl -m 0.02 and then requesting the very same URL as a fresh client with a 6 s timeout. A sweep is necessary because the trigger is a race. the disconnect has to land while the server is still streaming the source file so a single attempt rarely lands. Widths are never reused, since an already-successful transform is served from the disk cache and does not re-enter the code path under test.
Output on next@16.3.1:
=== 1. stock next: sweep 84 cold /public transforms, aborting each mid-flight === => 6 of 84 URLs are now permanently unresponsive: 32:50 1080:50 48:75 1920:75 828:80 384:90 === 2. control: external images (remotePatterns) get the same treatment === => 0 of 84 external URLs unresponsive (this path never touches the client socket) === 3. it is process state, not disk state: restart, retry the dead URLs === w=32 q=50 after restart -> 200 w=1080 q=50 after restart -> 200 w=48 q=75 after restart -> 200 w=1920 q=75 after restart -> 200 w=828 q=80 after restart -> 200 w=384 q=90 after restart -> 200 === 4. negative control: drop 'socket: _req.socket', same sweep === => 0 of 84 URLs unresponsive === summary === stock next, local images: 6 / 84 permanently hung stock next, external images: 0 / 84 patched, local images: 0 / 84 === server.log (nothing is logged for any of this) === ▲ Next.js 16.3.1 - Local: http://localhost:3000 ✓ Ready in 60ms ✓ Running next.config.js took 7msCurrent vs. Expected behavior
Current: after a single client aborts one cold /_next/image request, every subsequent request for that exact transform URL hangs indefinitely, for all clients, until the process restarts. Other widths and qualities of the same image keep working. Nothing is logged at any level.
Expected: a client aborting its own request affects only that request. The shared transform either completes or fails, and the coalesced cache key is released either way.
Provide environment information
Which area(s) are affected?
Image (next/image)
Which stage(s) are affected?
next start (local), self-hosted production
Additional context
This was found in production, not in a test. A live storefront runs output: standalone behind nginx. Two trust badges on every product page, loading="lazy", are rendered at a fixed 40 px with sizes="40px". Four cache keys were wedged:
Two properties of this made it unusually hard to diagnose, and both come straight from the cache key being hash(href, width, quality, mimeType):
Lazy loading is what makes this reachable at scale in the wild: a visitor who navigates away before a below-the-fold image enters the viewport aborts exactly the kind of cold transform this needs. The keys that died were the two most requested small widths of the two images present on every product page.
Relationship to existing reports. This is the same root cause as #96538, and #96542 proposes removing socket: _req.socket, which is also what step 4 of this reproduction isolates. What this report adds: