vercel / vercel/next.js

next/image: a client that disconnects mid-request permanently hangs that /_next/image cache key for every later client (self-hosted)

Open Beginner friendly
#98,402 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Image (next/image)
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
  1. pnpm install && pnpm run gen && pnpm run build && pnpm start (production server on :3999; gen writes a synthetic 2560×1440 public/hero.webp).
  2. 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):

  1. NextNodeServer.imageOptimizerfetchInternalImage(href, req.originalRequest, res.originalResponse, …) builds createRequestResponseMocks({ socket: _req.socket }) — the client's socket — and then await mocked.res.hasStreamed (image-optimizer.js). hasStreamed only resolves on finish/end of the mocked response (lib/mock-request.js).
  2. The internal request is served by serve-static → bundled send, which gates streaming on on-finished's isFinished(res): Boolean(res.finished || (socket && !socket.writable)). The MockedResponse reports finished = false but exposes the client's socket. Once the client has disconnected, the socket is not writable, so send treats the response as already finished, writes nothing and never calls end().
  3. hasStreamed therefore never settles → the responseGenerator promise passed to ResponseCache.get never settles → Batcher.batch never reaches finally { this.pending.delete(cacheKey) } (lib/batcher.js) → every later imageResponseCache.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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.