Skip to content
Get started
Get started
TCP Protocol Architecture: Wire Format & Pipelining
View Markdown
architecture · tcp protocol

Frames on the wire.

bunqueue uses a high-performance binary protocol over TCP with MessagePack serialization and optional pipelining. This page covers the wire format, connection lifecycle, and command set.

Each message is a length-prefixed MessagePack frame:

BytesContent
0-3Frame length (4 bytes, big-endian uint32)
4-NMessagePack payload

Maximum frame size: 64 MB

Both directions preserve frame ordering under socket backpressure. Bun’s TCP write is unbuffered and may accept only a prefix, so the reference client and server retain the exact unwritten tail and place later frames behind it until drain. Each queue belongs to one physical socket and is discarded on close; commands are never blindly replayed after reconnect because the broker may already have applied them.

Pipelining allows multiple commands to be sent without waiting for responses, dramatically improving throughput.

Client Server
│── PUSH job1 ────────────>│
│<── { ok, id } ───────────│ wait ~1ms
│── PUSH job2 ────────────>│
│<── { ok, id } ───────────│ wait ~1ms
│── PUSH job3 ────────────>│
│<── { ok, id } ───────────│ wait ~1ms
Total: 3 round-trips ≈ 3ms
Throughput: ~1,000 ops/sec (one command per 1ms round-trip)
Client Server
│── PUSH job1 (reqId:1) ──>│
│── PUSH job2 (reqId:2) ──>│ no wait
│── PUSH job3 (reqId:3) ──>│ no wait
│<── { ok, reqId:1 } ──────│
│<── { ok, reqId:2 } ──────│
│<── { ok, reqId:3 } ──────│
Total: 1 round-trip ≈ 1ms
Throughput: ~3,000 ops/sec (three commands per 1ms round-trip)

Result: 3x faster in this illustrative 1 ms example. Real throughput depends on latency, connection count, batching, durability, database size, and storage backend. The current benchmark page publishes measured workloads instead of treating this round-trip sketch as a capacity claim.

  1. Client sends commands with unique reqId identifiers
  2. Server processes in parallel (up to 50 concurrent per connection)
  3. Responses include reqId for matching (may arrive out of order)
  4. Client matches responses using a Map<reqId, Promise>
const queue = new Queue('my-queue', {
connection: {
host: 'localhost',
port: 6789,
pipelining: true, // Enable pipelining (default: true)
maxInFlight: 100, // Max concurrent commands (default: 100)
poolSize: 32, // Connection pool size
commandTimeout: 30000, // Timeout per command (ms)
pingInterval: 30000, // Health-check ping interval (ms, 0 disables)
maxCommandTimeouts: 3, // Consecutive command timeouts → reconnect (0 disables)
},
});
OptionDefaultDescription
pipeliningtrueEnable TCP pipelining
maxInFlight100Max commands in flight per connection (a whole number >= 1, or Infinity)
poolSize4Number of TCP connections (a whole number up to 65535; below 1 means one); a Worker defaults to min(concurrency, 8)
commandTimeout30000Command timeout (ms, >= 1; Infinity = no client-side timeout)
pingInterval30000Health-check ping interval (ms; 0 or Infinity disables, otherwise >= 1)
maxCommandTimeouts3Consecutive command timeouts (no intervening success) before the link is concluded dead and reconnect is forced (a whole number; 0 disables)

The timeout, interval and in-flight values, and host (a non-blank string) and port (a whole number from 1 to 65535), are checked when the Queue, Worker, FlowProducer, QueueEvents or SandboxedWorker is constructed. A value that is not a number throws a TypeError; NaN, a negative value, a fraction of a millisecond or a fractional count throws a RangeError naming the option, for example TcpClient: pingInterval must be a finite number of milliseconds >= 0 or Infinity (got NaN). undefined or null keeps the default. Durations have no upper bound: a value above 2^31 - 1 ms (about 24.8 days) is honoured exactly instead of firing after about 1 ms.

A client can negotiate the protocol version explicitly with Hello (the Bun client exposes it as hello(); it is not sent automatically on connect):

// Client → Server
{ cmd: 'Hello', protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'] }
// Server → Client
{ ok: true, protocolVersion: 3, capabilities: ['pipelining', 'separate-job-name'], server: 'bunqueue', version: '2.x.y' }

Protocol v3 supports pipelining and a separate top-level job name, leaving the user-owned data value unchanged. The server accepts clients that omit Hello; legacy job input without top-level name is decoded only at the inbound protocol boundary.

States:

  1. DISCONNECTED → Initial state
  2. CONNECTING → Socket.connect() in progress
  3. CONNECTED → Ready for commands
  4. RECONNECTING → Auto-reconnect with backoff

Connect sequence:

  1. TCP socket connect
  2. Send Auth (if token configured)
  3. Start ping timer
  4. Ready for commands

Reconnect strategy:

  • Base delay: 100ms (reconnectDelay)
  • Max delay: 30s (maxReconnectDelay)
  • Closing the client always wins: close() during a connect attempt closes that socket and rejects the attempt with ClientClosedError, and close() from a reconnecting listener cancels the retry
  • A connection attempt that times out closes its socket, and a superseded socket’s late events never affect the current connection
  • Backoff: exponential (2x each attempt)
  • Jitter: additive, up to +30% of the computed delay

Dead-link detection (half-open sockets):

A socket can go half-open, the peer vanishes with no FIN/RST (suspended host, NAT/load-balancer silently dropping an idle connection). Writes still succeed and no close/error event fires, so the client must detect it actively. Two independent signals conclude the link is dead and trigger forceReconnect():

  1. Health-check ping, after maxPingFailures (3) consecutive failed pings.
  2. Command timeouts, after maxCommandTimeouts (3) consecutive command timeouts with no intervening success. This is the path that recovers a worker whose PULLs keep timing out, without waiting for the slower ping cycle (and it works even when the ping is disabled). The counter resets on any successful response.

On detection the socket is torn down, all in-flight commands are rejected immediately (Connection lost) so callers unblock at once, and the reconnect/backoff loop above re-establishes a fresh connection. SO_KEEPALIVE is also enabled so the OS can surface a dead peer on its own rather than lingering until tcp_retries2 (~15 min).

For fast recovery, lower pingInterval / commandTimeout, e.g. { pingInterval: 10000, commandTimeout: 5000 } recovers in ~tens of seconds vs ~120s on defaults (each default timeout is 30s, so timeout-based detection is inherently coarse).

If AUTH_TOKENS is configured on the server, clients must authenticate:

// Client → Server
{ cmd: 'Auth', token: 'your-secret-token' }
// Server → Client
{ ok: true } // or { ok: false, error: 'Invalid token' }

Token comparison uses constant-time algorithm to prevent timing attacks.

CommandDescriptionRequestResponse
PUSHAdd single job{ cmd, queue, data, priority?, delay? }{ ok, id }
PUSHBAdd batch{ cmd, queue, jobs }{ ok, ids }
PUSHFAdd a whole flow atomically{ cmd, jobs } (validated graph){ ok, data: { jobs } }
PULLGet single job{ cmd, queue, timeout?, group? }{ ok, job, token? }
PULLBGet batch{ cmd, queue, count, timeout?, group? }{ ok, jobs, tokens? }
ACKComplete job{ cmd, id, result?, token? }{ ok, data?: { applied, reason } }
ACKBComplete batch{ cmd, ids, results?, tokens? }{ ok, data?: { ignoredIds, ignoredIndices } }
FAILFail job{ cmd, id, error?, token? }{ ok, data?: { applied, reason } }
CommandDescription
GetJobGet job by ID
GetJobByCustomIdGet job by custom ID
GetStateGet job state
GetResultGet job result
GetJobsList jobs with filters
GetJobCountsQueue statistics
GetCountsPerPriorityCounts grouped by priority
GetProgressGet job progress
CountCount jobs in queue

GetGroupJobsCount, GetGroupsJobsCount, and GetGroupActiveCount expose server-authoritative depth. Set/Get/RemoveGroupRateLimit, GetGroupRateLimitTtl, and Set/Get/RemoveGroupConcurrency manage local group overrides. Results are wrapped in data; see the wire reference for exact shapes.

CommandDescription
PauseStop processing queue
ResumeResume processing
IsPausedCheck if queue is paused
DrainRemove waiting jobs
ObliterateDelete queue completely
CleanRemove old jobs
CancelRemove pending/completed job
PromoteMove delayed job to waiting
MoveToDelayedMove job to delayed state
ProgressUpdate job progress
ListQueuesList all queues
CommandDescription
DlqList DLQ entries
RetryDlqRetry failed jobs
RetryCompletedRetry completed jobs
PurgeDlqClear DLQ
CommandDescription
CronAdd scheduled job
CronGetGet one scheduled job
CronDeleteRemove scheduled job
CronListList all cron jobs
CommandDescription
StatsServer statistics
MetricsQueue metrics
PrometheusPrometheus format
PingHealth check
HeartbeatWorker heartbeat
JobHeartbeatPer-job heartbeat
AddLogAdd job log entry
GetLogsGet job logs
RegisterWorkerRegister worker with server
UnregisterWorkerUnregister worker
ListWorkersList registered workers

The client maintains a pool of TCP connections for load balancing:

// Default: 4 connections, configurable via poolSize
const pool = new TcpConnectionPool({
host: 'localhost',
port: 6789,
poolSize: 32, // 32 connections for high throughput
});

Selection strategy: Round-robin, preferring connected sockets.

TcpConnectionPool and getSharedPool also take the connection-level reconnect and health settings, validated like the options above:

OptionDefaultDescription
connectTimeout5000Connection attempt timeout (ms, finite, >= 1)
autoReconnecttrueReconnect after a lost connection
reconnectDelay100First reconnect delay, doubled per attempt (ms, above 0, or Infinity)
maxReconnectDelay30000Reconnect delay ceiling, plus up to 30% jitter (ms, >= 1, or Infinity)
maxReconnectAttemptsInfinityAttempts before giving up (a whole number; 0 gives up at once)
maxPingFailures3Consecutive failed pings before a reconnect (a whole number >= 1, or Infinity)

getSharedPool checks the options before it looks up an existing pool, so invalid options never receive a shared one, and it shares a pool only between callers whose options are all equal (unset, undefined and the explicit default count as equal): a Queue with other timeouts, ping or reconnect settings, or another token, gets its own pool instead of silently running with the first caller’s.

Features:

  • Automatic reconnection
  • Health tracking (latency, errors)
  • Shared pools (reference counted)

When a client disconnects, the server:

  1. Identifies all jobs owned by client
  2. Releases job locks (returns to queue)
  3. Cleans up client tracking

Jobs with active locks are automatically requeued for other workers.

ParameterLimit
Queue nameMax 256 chars, alphanumeric + _-.:
Job dataMax 10 MB JSON on push (an Update has no size limit)
PriorityAny finite number (grouped: integer 0 to 2,097,151)
DelayFinite; a negative delay is a past run time (ready, sorts first)
Timeout, TTLFinite, 0 or more
Stall timeout (stallTimeout)Finite
Max attemptsAny number (Infinity allowed); 1 or less runs once, a fraction rounds up, at most 2,147,483,647
Backoff (number, delay)Finite, 0 or more; the object form’s delay defaults to 1000
Backoff maxDelay0 to 24 hours
timestampWithin ±4,320,000,000,000,000
dedup.ttl, debounceTtl, repeat.everyFinite (repeat.every must be positive)
stackTraceLimit / keepLogs / sizeLimitFinite (stored as given)
ChangeDelay/MoveToDelayed delayRequired, finite; a negative delay is a past run time (ready, sorts first)
lockTtl, ExtendLock(s)/JobHeartbeat durationAny finite number (as in 2.9.10)
ChangePriority priorityAny finite number; missing means 0; lifo is made a boolean

Every value must be a finite number (a plain numeric string counts as its number); durations above about 136,900 years are clamped. Every value 2.9.10 accepted in either mode is still accepted with the same result. Embedded mode applies the same rules (Queue.add, addBulk, flows, job schedulers and job commands throw the same messages, naming the SDK option such as attempts), so a job is never admitted in one mode and refused in the other.

bunqueue also exposes an HTTP API on port 6790:

EndpointMethodDescription
/healthGETHealth + memory stats
/healthzGETKubernetes liveness
/readyGETKubernetes readiness
/prometheusGETPrometheus metrics
/statsGETJSON statistics
/queues/:queue/jobsPOSTAdd job
/queues/:queue/jobsGETPull job
/jobs/:idGETGet job
/jobs/:id/ackPOSTAcknowledge
/jobs/:id/failPOSTFail
/wsGETWebSocket
/eventsGETServer-Sent Events