next/image: a client that disconnects mid-request permanently hangs that /_next/image cache key for every later client (self-hosted)
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 142k
- Forks
- 32.4k
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 351
Description
Link to the code that reproduces this issue
https://github.com/lpoulter-gradle/next-image-abort-hang-repro
To Reproduce
pnpm install && pnpm run gen && pnpm run build && pnpm start(production server on :3999;genwrites a synthetic 2560×1440public/hero.webp).- In another terminal:
pnpm run repro.
repro.mjs does, for each of five widths: open a raw TCP socket, send GET /_next/image?url=%2Fhero.webp&w=<n>&q=75 with Accept: image/webp, destroy the socket ~2 ms later (long enough for the server to accept the request and start optimizing, too short for it to finish — this is exactly what a browser does when it cancels an image request on viewport resize / navigation / tab close). Then it issues a normal http.get for the same URL with an 8 s timeout, and finally a control request for a width that was never aborted.
Current vs. Expected behavior
Current (16.4.0-canary.22, also 16.2.6):
w=640 follow-up: TIMEOUT after 8003ms
w=750 follow-up: TIMEOUT after 8001ms
w=828 follow-up: TIMEOUT after 8003ms
w=1080 follow-up: TIMEOUT after 8002ms
w=1200 follow-up: TIMEOUT after 8005ms
control (w=1920, never aborted): 200 after 2773ms
5/5 keys are now permanently hung (restart the server to clear).
After this, curl -m 10 -H 'Accept: image/webp' 'http://localhost:3999/_next/image?url=%2Fhero.webp&w=640&q=75' hangs indefinitely for any client, while the same image at another width, another quality, or with Accept: image/avif answers in milliseconds. Nothing is logged. Only restarting the server clears it.
Expected: a client disconnecting mid-request should not affect other clients. The follow-up request should return 200 (either by joining the in-flight optimization or by starting a fresh one).
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.5.0
Available memory (MB): 65536
Available CPU cores: 12
Binaries:
Node: 24.14.0
npm: 11.9.0
Yarn: 1.22.22
pnpm: 10.33.0
Relevant Packages:
next: 16.4.0-canary.22 // Latest available version is detected (16.4.0-canary.22).
eslint-config-next: N/A
react: 19.2.8
react-dom: 19.2.8
typescript: N/A
Next.js Config:
output: N/A
Also reproduced on next@16.2.6 with output: standalone (Linux container on AWS Lightsail, and macOS next start).
Which area(s) are affected? (Select all that apply)
Image (next/image)
Which stage(s) are affected? (Select all that apply)
next start (local), Other (Deployed) — self-hosted
Additional context
Where it goes wrong (traced in dist/server):
NextNodeServer.imageOptimizer→fetchInternalImage(href, req.originalRequest, res.originalResponse, …)buildscreateRequestResponseMocks({ socket: _req.socket })— the client's socket — and thenawait mocked.res.hasStreamed(image-optimizer.js).hasStreamedonly resolves onfinish/endof the mocked response (lib/mock-request.js).- The internal request is served by
serve-static→ bundledsend, which gates streaming on on-finished'sisFinished(res):Boolean(res.finished || (socket && !socket.writable)). TheMockedResponsereportsfinished = falsebut exposes the client's socket. Once the client has disconnected, the socket is not writable, sosendtreats the response as already finished, writes nothing and never callsend(). hasStreamedtherefore never settles → theresponseGeneratorpromise passed toResponseCache.getnever settles →Batcher.batchnever reachesfinally { this.pending.delete(cacheKey) }(lib/batcher.js) → every laterimageResponseCache.get(key)is handed the same dead pending promise.
Fix that makes the repro pass (validated locally against 16.2.6): keep socket on the MockedRequest (the router reads encrypted / remoteAddress from it) but construct the MockedResponse with socket: null — it is a buffer, not a wire, so a departed client must not make send consider it finished.
// packages/next/src/server/lib/mock-request.ts — createRequestResponseMocks
res: new MockedResponse({
- socket,
+ socket: null,
resWriter,
maximumResponseBody
})
With that one-line change the identical script goes from 5/5 permanent hangs to 5/5 200 responses (40–165 ms), and pages still render their images. A belt-and-braces addition would be a timeout on the generator in ResponseCache so a responseGenerator that never settles is evicted from the batcher rather than poisoning the key for the life of the process.
Impact: on Vercel this is masked by the CDN and short-lived functions. On self-hosted next start / standalone (single long-running process) one cancelled image request from one visitor makes that image at that size disappear for everyone until the next deploy. Behind a CDN it still bites every cache miss. It presents as "some images never load", with nothing in the logs.
Related earlier reports of the same class (mocked response never emitting finish): #33441 / #33719, discussion #47692.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/next/src/server/lib/mock-request.ts at createRequestResponseMocks, then follow NextNodeServer.imageOptimizer and the linked reproduction. Run the repro against a self-hosted production server and verify that aborted image requests do not poison their cache keys: every follow-up and control request should return 200 without a restart.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, nextjs, node.js
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100