Skip to content

Single TCP socket write() larger than 64 KiB silently destroys the connection (production runtime only) #7074

Description

@okomarov

Summary

A single writer.write() on a cloudflare:sockets TCP socket deterministically destroys the connection when the chunk is larger than 65,536 bytes. Exactly 65,536 bytes succeeds; 65,537 fails, every time. The write() promise resolves normally — the failure only surfaces on the next read() as Error: Network connection lost. (or, with some larger payloads, as a hang until "script will never generate a response"). Writing the identical bytes as multiple chunks of ≤ 64 KiB always succeeds.

  • Reproduces: deployed workers and wrangler dev --remote (production runtime).
  • Does not reproduce: local wrangler dev, any size (256 KiB single writes pass).
  • Independent of nodejs_compat (repro doesn't use it), TLS (plaintext), destination (reproduced against two unrelated peers: httpbin.org:80 and Supabase's Supavisor pooler aws-1-eu-west-2.pooler.supabase.com:6543), and compatibility_date (tested 2026-05-14 and 2026-08-01).
  • Not documented as a limit on the TCP sockets page.

Minimal repro (self-contained, no dependencies)

One plain-JS worker: it opens a TCP connection to httpbin.org:80, writes an ordinary HTTP POST (124-byte header + size-byte body) as one writer.write() (or split into chunks writes), then reads the response status line.

wrangler.toml + src/index.js
name = "socket-write-repro"
main = "src/index.js"
compatibility_date = "2026-08-01"
workers_dev = true
import { connect } from 'cloudflare:sockets'

function timeout(ms, label) {
  return new Promise((_, reject) => setTimeout(() => reject(new Error(`TIMEOUT ${label} after ${ms}ms`)), ms))
}

export default {
  async fetch(req) {
    const u = new URL(req.url)
    const size = Number(u.searchParams.get('size') ?? 65536)
    const chunks = Number(u.searchParams.get('chunks') ?? 1)
    const host = u.searchParams.get('host') ?? 'httpbin.org'
    const log = []

    const body = new Uint8Array(size).fill(120)
    const head = new TextEncoder().encode(
      `POST /post HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/octet-stream\r\nContent-Length: ${size}\r\nConnection: close\r\n\r\n`,
    )
    const payload = new Uint8Array(head.length + size)
    payload.set(head, 0)
    payload.set(body, head.length)

    log.push(`target=${host}:80 payloadBytes=${payload.length} chunks=${chunks}`)

    let sock
    try {
      sock = connect(`${host}:80`)
      const writer = sock.writable.getWriter()
      const reader = sock.readable.getReader()

      const t0 = Date.now()
      if (chunks === 1) {
        await Promise.race([writer.write(payload), timeout(10000, 'write')])
      } else {
        const per = Math.ceil(payload.length / chunks)
        for (let i = 0; i < payload.length; i += per) {
          await Promise.race([writer.write(payload.subarray(i, i + per)), timeout(10000, 'write')])
        }
      }
      log.push(`WRITE OK in ${Date.now() - t0}ms`)

      const dec = new TextDecoder()
      let acc = ''
      while (true) {
        const r = await Promise.race([reader.read(), timeout(10000, 'read')])
        if (r.done) {
          log.push(`socket closed by peer, got ${acc.length} response bytes`)
          break
        }
        acc += dec.decode(r.value, { stream: true })
        if (/^HTTP\/1\.1 (\d+)/.test(acc)) {
          log.push(`HTTP RESPONSE: ${acc.slice(0, acc.indexOf('\r\n'))}`)
          break
        }
      }
    } catch (err) {
      log.push(`ERROR: ${err?.constructor?.name}: ${err?.message}`)
    } finally {
      try {
        sock?.close()
      } catch {}
    }
    return new Response(`${log.join('\n')}\n`, { headers: { 'content-type': 'text/plain' } })
  },
}
npx wrangler dev --remote --port 8789   # production runtime; local dev does NOT repro

curl 'http://127.0.0.1:8789/?size=65411&chunks=1'   # 65,535 B single write -> HTTP/1.1 200 OK
curl 'http://127.0.0.1:8789/?size=65412&chunks=1'   # 65,536 B single write -> HTTP/1.1 200 OK
curl 'http://127.0.0.1:8789/?size=65413&chunks=1'   # 65,537 B single write -> Network connection lost
curl 'http://127.0.0.1:8789/?size=200000&chunks=1'  # 200 KB single write   -> Network connection lost
curl 'http://127.0.0.1:8789/?size=200000&chunks=7'  # same bytes, 7 writes  -> HTTP/1.1 200 OK

Observed output for the failing case (2026-08-20):

target=httpbin.org:80 payloadBytes=65537 chunks=1
WRITE OK in 0ms                      <- the write() promise resolved
ERROR: Error: Network connection lost.

Expected

Either the write succeeds (streams should segment arbitrarily large chunks), or write() rejects with a documented error. Resolving the write and then destroying the connection makes the failure look like a server-side reset — which is exactly how it presented in production for us.

Local-runtime comparison

The same worker pointed at a local byte-counting TCP sink under plain wrangler dev accepts single writes of 262,144 bytes without issue, so local workerd does not have the limit — repro requires --remote or a deployed worker.

12-line sink server used for the local comparison
import net from 'node:net'

const port = Number(process.argv[2] ?? 9977)
net
  .createServer((sock) => {
    let total = 0
    sock.on('data', (buf) => {
      total += buf.length
      sock.write(`${total}\n`)
    })
  })
  .listen(port, () => console.log(`sink listening on ${port}`))

Real-world impact

Postgres wire-protocol drivers send each protocol message as a single write(). postgres.js's Cloudflare build (cf/polyfills.js) does exactly that, so any SQL statement whose wire size exceeds 64 KiB — e.g. a multi-row INSERT — fails from a Worker with CONNECTION_CLOSED (or hangs), while the identical statement succeeds from Node against the same server. We hit this in an ETL worker whose bulk INSERTs of document metadata (~600 rows, ~230 KB per statement) could never persist; the job retried for days before we isolated the boundary. Chunking writes to ≤ 32 KiB inside the driver's socket shim fully fixes it. Possibly related undiagnosed report: cloudflare/workers-sdk#6179.

Environment

  • wrangler 4.124.0, via wrangler dev --remote and deployed workers
  • compatibility_date tested: 2026-05-14 and 2026-08-01; no compatibility_flags
  • Account is on the Workers Free plan — I found no documented plan-dependent socket limit, and total throughput is clearly not capped (200 KB in 7 chunks succeeds), but please flag it if per-write size is somehow plan-gated

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