#7043·fastify

close() resolves without draining in-flight requests on the secondary dual-stack listener

Author: oolothCreated Sep 22, 2026Updated Sep 22, 2026

Prerequisites

  • I have written a descriptive issue title
  • I have searched existing issues to ensure it has not already been reported

Fastify version

5.12.5

Node.js version

v26.7.0

Operating system

macOS (Darwin 25.6.0, arm64)

Description

fastify.close() resolves, and onClose hooks run, while a request is still executing — when the request arrived on the secondary listener created by the default dual-stack listen({ port }).

The documented lifecycle in docs/Reference/Server.md says that at step 5 "onClose hooks execute. All in-flight requests have completed and the server is no longer listening", and the Hooks reference calls onClose "the safe place for plugins to release resources such as database connection pools". Neither holds here.

Relationship to #4098

This looks similar to #4098 and has a different cause. That one was traced to a Node bug, fixed in Node 18.7.0 by nodejs/node#43890. This reproduces on Node v26.7.0 and is conditional on the bind: with host: '0.0.0.0' the identical script on the identical Node drains correctly, which points at Fastify's dual-stack path rather than at Node's idle-socket accounting.

Steps to Reproduce

javascript
import Fastify from 'fastify'

const app = Fastify()
let inFlight = 0

app.addHook('onClose', async () => console.log('onClose fired, inFlight =', inFlight))

app.get('/slow', async () => {
  inFlight++
  await new Promise((r) => setTimeout(r, 2000))
  inFlight--
  return { ok: true }
})

await app.listen({ port: 3000 })            // default dual-stack bind

fetch('http://127.0.0.1:3000/slow')         // arrives over IPv4

setTimeout(async () => {
  const t = Date.now()
  await app.close()
  console.log(`close() resolved after ${Date.now() - t}ms, inFlight =`, inFlight)
}, 500)

Actual:

onClose fired, inFlight = 1
close() resolved after 3ms, inFlight = 1

Expected: onClose fires with inFlight = 0 and close() resolves after roughly 1500ms.

Changing the bind to { port: 3000, host: '0.0.0.0' } — or pointing the client at ::1 — produces the expected behaviour. Varying the two independently, the default bind with an IPv4 client is the only combination that fails.

app.server.getConnections() reports 0 mid-request under the default bind and 1 with an explicit host. Bare node:http, Express and Hono all report 1 and wait.

Expected Behavior

close() should not resolve, and onClose should not run, until in-flight requests on both listeners have completed.

Possible cause

In lib/server.js (v5.12.5), the secondary server's close callback is discarded and its close is only triggered once the main server has already closed:

javascript
const closeSecondary = () => {
  // ...
  // We swallow any error from the secondary server
  secondaryServer.close(() => {})
  // ...
}

mainServer.on('unref', closeSecondary)
mainServer.on('close', closeSecondary)
mainServer.on('error', closeSecondary)

Since app.server is mainServer, the instance.server.close(cb) in fastify.js that gates done() only waits for the main listener. The secondary listener — which is where an IPv4 request lands under a dual-stack bind — is closed afterwards with a callback nobody awaits.

Why this matters

The failure is silent and it corrupts. Following the documented pattern of decorate plus an onClose that releases a resource — the shape fastify-postgres uses — the handle is released while a handler is still running. In my case the handler resumed 1.5s later, threw, and the client received 500 ERR_INVALID_STATE: database is not open over a half-written row. Nothing was logged as an error by the shutdown path itself.

I am happy to open a PR if a maintainer can indicate the preferred shape — awaiting the secondary server's close callback alongside the main one seems the minimal change, but the ordering comment above closeSecondary suggests the current sequencing is deliberate and I would not want to break the preClose guarantee it is protecting.