Skip to content

Keep-alive connection reuse stalls up to ~500ms on an idle event loop (possible regression in 8.8.0) #5600

Description

@hbacelar

Bug Description

Since undici 8.8.0, reusing an idle keep-alive socket can stall for up to ~500ms before the
request is written to the wire. The request is created, queued, and then simply sits there.
This was discovered using a pool with a single connection.

I think cause is scheduleIdleSocketValidation() in lib/dispatcher/client-h1.js, which was changed
from an unref'd setTimeout(…, 0) to an unref'd setImmediate().

Used / Tested Environment

undici 8.9.0 (also reproduces on 8.8.0)
last good 8.7.0
Node.js v24.18.0
Platform macOS (darwin 25.5.0, arm64)

Reproduction

Standalone reproduction script:

import assert from 'node:assert'
import http from 'node:http'
import { test } from 'node:test'
import { Pool } from 'undici'

const REQUESTS = 6
const SERVER_DELAY_MS = 10
const MAX_TOTAL_MS = 400

test('reusing an idle keep-alive socket must not stall', async (t) => {
  const server = http.createServer((req, res) => {
    setTimeout(() => {
      res.writeHead(200, { 'content-length': 2 })
      res.end('ok')
    }, SERVER_DELAY_MS)
  })

  await new Promise((resolve) => server.listen(0, resolve))

  const pool = new Pool(`http://127.0.0.1:${server.address().port}`, { connections: 1 })

  t.after(async () => {
    await pool.close()
    server.close()
  })

  const durations = []
  const start = Date.now()

  for (let i = 0; i < REQUESTS; i++) {
    const requestStart = Date.now()
    const res = await pool.request({ path: '/', method: 'GET' })
    await res.body.text()
    durations.push(Date.now() - requestStart)
  }

  const total = Date.now() - start

  t.diagnostic(`per-request: ${durations.map((d) => `${d}ms`).join(', ')}`)
  t.diagnostic(`total: ${total}ms`)

  assert.ok(
    total < MAX_TOTAL_MS,
    `${REQUESTS} sequential requests took ${total}ms (per-request: ${durations.join('ms, ')}ms). ` +
      `The server replies in ${SERVER_DELAY_MS}ms, so this should be well under ${MAX_TOTAL_MS}ms.`
  )
})

Expected Behavior

reusing an idle keep-alive socket must not stall (84.42725ms)
per-request: 20ms, 13ms, 13ms, 12ms, 13ms, 13ms
total: 84ms

The server takes 10ms to respond, so every request should be ~10–20ms - and on 8.7.0 every one is.
On 8.9.0, reuses of the keep-alive socket cost hundreds of milliseconds each.

Actual Behavior

reusing an idle keep-alive socket must not stall (1482.400041ms)
per-request: 20ms, 458ms, 34ms, 51ms, 450ms, 464ms
total: 1477ms

  AssertionError [ERR_ASSERTION]: 6 sequential requests took 1477ms
  (per-request: 20ms, 458ms, 34ms, 51ms, 450ms, 464ms).

The server replies in 10ms, so this should be well under 400ms.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions