Skip to content

fetch() with a zero-length body never settles against slack.com on Node 26 / win32; omitting body works #5645

Description

@leafbird

Summary

On Node 26.0.0 / win32, fetch() with a zero-length but present body (body: '' or an empty URLSearchParams) never settles against https://slack.com. The promise neither resolves nor rejects — no error, no timeout. Omitting body entirely, or sending a single space, returns HTTP 200 in ~300ms.

The same script on Node 22.22.3 / linux (WSL2, same machine, same network) is fine for every form.

Reproduction

No credentials needed — auth.test answers HTTP 200 {"ok":false,"error":"invalid_auth"} for a bogus token, so the hang is visible with a made-up string.

const URL_ = 'https://slack.com/api/auth.test'
const HEADERS = {
  Authorization: 'Bearer xoxb-not-a-real-token',
  'Content-Type': 'application/x-www-form-urlencoded',
}

async function run(label, init) {
  const started = Date.now()
  try {
    const res = await Promise.race([
      fetch(URL_, { method: 'POST', headers: HEADERS, ...init }),
      new Promise((_, reject) => setTimeout(() => reject(new Error('8s timeout')), 8000)),
    ])
    console.log(`${label.padEnd(26)} -> HTTP ${res.status} in ${Date.now() - started}ms`)
  } catch {
    console.log(`${label.padEnd(26)} -> HUNG (no settle in ${Date.now() - started}ms)`)
  }
}

console.log(`${process.version} ${process.platform} ${process.arch}`)
await run('no body', {})
await run("body: ''", { body: '' })
await run('body: URLSearchParams()', { body: new URLSearchParams() })
await run("body: ' '", { body: ' ' })
process.exit(0)

Output

v26.0.0 win32 x64
no body                    -> HTTP 200 in 304ms
body: ''                   -> HUNG (no settle in 8012ms)
body: URLSearchParams()    -> HUNG (no settle in 8013ms)
body: ' '                  -> HTTP 200 in 274ms
v22.22.3 linux x64          (WSL2 on the same host, same network)
no body                    -> HTTP 200 in 332ms
body: ''                   -> HTTP 200 in 287ms
body: URLSearchParams()    -> HTTP 200 in 204ms
body: ' '                  -> HTTP 200 in 218ms

The hang is deterministic: 5/5 fresh processes, over several hours.

What I ruled out

The request bytes look identical. Pointing the same calls at a local net server and dumping the raw request head, no body and body: '' produce a byte-identical 235-byte request — same header set, same content-length: 0, and a single socket.write in both cases. So whatever differs is not visible at the plain-HTTP request-framing level.

Not the network or a middlebox. From the same Windows host:

  • curl.exe -X POST https://slack.com/api/auth.test (i.e. Content-Length: 0) → HTTP 200 in 355ms
  • node:https with the same headers and an explicit Content-Length: 0, both with and without req.write('')HTTP 200 in ~300ms for both

So the old HTTP stack is fine on the exact same machine, host and request shape; only fetch hangs.

Not host-independent. With body: '', https://api.github.com/, https://example.com/ and an internal HTTPS API all respond normally on the same Node 26 / win32 process. Only slack.com hangs.

Not environment contamination. No HTTP(S)_PROXY / ALL_PROXY / NO_PROXY / NODE_EXTRA_CA_CERTS / NODE_TLS_REJECT_UNAUTHORIZED set; globalThis.fetch is the native one; no custom dispatcher installed.

I could not isolate version from platform — this machine only has one Node on Windows (26.0.0) and one in WSL (22.22.3), so "Node 26" and "win32" are confounded in the data above. Happy to run anything that would separate them.

My guess is that a zero-length body still goes out as a separate (empty) write on the TLS socket, which some servers never respond to, while no body skips that write. I could not confirm it — patching TLSSocket.prototype.write did not intercept undici's writes.

Why it matters

@slack/web-api sends body: '' for every parameterless API call. Two of those are on the startup path of any Bolt Socket Mode app: auth.test and apps.connections.open. On Node 26 / win32 the app therefore hangs during boot with no error, no log and no timeout — the process stays alive and simply never connects, which is very hard to attribute. It took me a full day to narrow it down to the body shape.

Workaround, for anyone hitting this — normalize zero-length bodies away before they reach undici:

const real = globalThis.fetch
globalThis.fetch = (input, init) => {
  if (init && (init.body === '' || (init.body instanceof URLSearchParams && init.body.size === 0))) {
    const { body, ...rest } = init
    return real(input, rest)
  }
  return real(input, init)
}

Environment

  • Node.js v26.0.0, win32 x64 (Windows 11 26200) — reproduces
  • Node.js v22.22.3, linux x64 (WSL2, same host) — does not reproduce
  • Bundled undici in both cases (no separate install)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions