AlgoMaster Logo

Network Sockets: Buffers, Backlog, and Accept Queues

21 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

A backend server handles requests normally until a short traffic burst arrives. CPU usage is moderate and existing connections still work, yet new clients experience long connection times or failures.

The application code may contain only three relevant calls:

Those calls create and consume kernel state. Incoming connections can finish kernel-level establishment before the server calls accept(). They wait in a bounded accept queue, and that queue can fill when the application accepts more slowly than clients arrive.

Network socket behavior therefore depends on two layers at once: the protocol visible to the application and the queues, buffers, descriptors, wakeups, and resource limits managed by the kernel. This chapter follows the kernel side.

Socket Descriptors and Kernel State

socket() creates a kernel socket object and installs a file descriptor that refers to it:

The descriptor is a small process-local integer. The kernel object holds the information needed for that endpoint, including:

  • Address family and socket type
  • Local and remote endpoint information
  • Protocol state
  • Send and receive buffering
  • Error and shutdown state
  • Processes waiting for an operation
  • Readiness information

The process uses the descriptor with bind(), connect(), listen(), accept(), send(), recv(), and related calls. Closing the descriptor releases that process's reference. Duplicated or inherited descriptors can keep the same socket object alive.

A listening stream socket and an accepted stream socket have different roles. The listening socket represents the server endpoint that receives connection requests. Every successful accept() creates another descriptor for one established client connection.

The accepted connections no longer occupy the accept queue. They consume descriptors, socket memory, and protocol state until the application and kernel finish closing them.

The Send Path

For a normal stream socket, send() or write() copies application bytes into kernel-managed socket memory:

A successful return means the kernel accepted that many bytes from the calling process. It does not prove that the network device transmitted them, that the remote kernel received them, or that the remote application processed them.

The kernel can retain bytes in the send buffer while protocol and device conditions determine when they move onward. As space becomes available, blocked senders can wake and nonblocking senders can report writable readiness again.

If the send buffer cannot accept the complete request, a blocking call may wait. A nonblocking call can return a partial count or fail with EAGAIN. Stream code must advance by the returned byte count instead of assuming the full application message was accepted.

Send-buffer capacity provides transport backpressure. It does not limit how many logical jobs the application has already admitted into its own memory.

The Receive Path

Packet arrival begins outside the process:

  1. The network device places received data into kernel-owned memory.
  2. Interrupt-driven or polling work schedules kernel packet processing.
  3. The network stack validates and identifies the destination socket.
  4. Payload is added to that socket's receive queue or buffer.
  5. The kernel wakes a blocked receiver or marks the socket readable.
  6. recv() copies available data into the application's buffer.

The device and interrupt details vary by hardware and driver. The important scheduling boundary is that the application thread does not need to be running when packets arrive. Kernel work records the data and makes the socket eligible for a later receive.

Loopback traffic skips the physical network-device portion of this path while retaining kernel protocol processing, socket lookup, buffering, and wakeups.

  1. The network device receives the packet.
  2. Kernel packet processing runs.
  3. A socket lookup finds the destination.
  4. The data lands in the socket receive buffer.
  5. The kernel wakes the receiver, or marks the socket readable.
  6. The application's recv() copies it out.

Data reaches the receive buffer whether or not the application is currently asking for it. That buffer is why a slow reader shows up as growing memory rather than as immediate packet loss.

For a stream socket, the receive buffer presents ordered bytes. Packet boundaries do not become application message boundaries. recv() can return fewer bytes than requested even when the connection remains open.

Reading frees receive-buffer capacity. If the application stops reading, buffered data accumulates until the protocol applies its own backpressure or the socket reaches a failure condition.

Packet Arrival and Linux Softirq Work

The kernel does not schedule an application thread for every arriving packet. Linux network drivers commonly use NAPI, which combines interrupts with bounded polling. An arrival causes the kernel to schedule receive processing, and the driver can collect a batch of packets before yielding.

Much of the network-stack work runs in softirq context. The kernel parses packets, performs socket lookup, and queues payload without executing the receiving process. If processing exceeds the immediate budget, work can continue through the per-CPU ksoftirqd kernel thread.

This separation explains a useful production symptom: a host can spend substantial CPU time in network softirq processing before application request handlers run. Heavy NET_RX activity may indicate packet rate, interrupt placement, driver work, or traffic that the kernel later discards.

Linux exposes per-CPU softirq counts through:

Counts should be compared over time. A large cumulative number on a long-running host is not an incident by itself.

net.core.netdev_max_backlog concerns packets waiting for kernel receive processing when the device path produces them faster than the networking code can consume them. It is separate from a listening socket's accept backlog.

Send and Receive Buffer Sizes

Applications can request per-socket limits with SO_SNDBUF and SO_RCVBUF:

The requested value is not a promise that the application can place exactly that many payload bytes in one operation. The kernel also accounts for socket metadata and protocol overhead.

Linux doubles values set through these options for internal bookkeeping and reports the doubled value through getsockopt(). System-wide maxima such as net.core.wmem_max and net.core.rmem_max can cap unprivileged requests.

TCP receive buffering can also grow automatically within system policy. Setting a fixed value may change that automatic behavior. Buffer tuning should therefore begin with the actual values reported by the running system and the workload's measured queueing, memory use, and throughput.

Larger buffers can absorb bursts and keep a high-latency transfer active. They also cost memory per connection and allow more data to wait before backpressure reaches the sender. With 50,000 sockets, an extra 256 KiB allowance per socket represents a large potential memory commitment.

Smaller buffers reduce memory exposure but can limit throughput when a connection needs more in-flight data. One global value rarely fits every workload.

Binding and Listening

A TCP server normally creates a stream socket, binds a local address and port, then calls listen():

bind() reserves the local endpoint according to address, port, and socket-option rules. listen() changes the socket into a passive endpoint that can receive connection requests.

The backlog argument does not count active clients. It limits pending connections that the kernel has established but the application has not accepted, subject to operating-system policy.

On Linux, the requested backlog is capped by:

If an application requests a larger value, Linux applies the cap without returning an error. Raising somaxconn alone has no effect when the program continues to call listen() with a smaller value.

Linux’s Two Listening Queues

Linux tracks two relevant groups of pending TCP connections:

  • The incomplete connection queue contains requests whose protocol-level establishment has not finished.
  • The accept queue contains fully established connections that the application has not accepted.

A connection passes through both:

  1. New client requests arrive.
  2. They enter the incomplete connection queue.
  3. The kernel completes connection establishment.
  4. They move to the accept queue, established but not yet accepted.
  5. The application calls accept().
  6. It receives a connected socket descriptor.

Connections reach step 4 without the application doing anything. A server that stops calling accept() therefore fills that queue while the kernel keeps completing handshakes.

The protocol packets used during establishment are outside this chapter's scope. From the operating-system perspective, the distinction matters because the two queues contain different states and have different controls.

On Linux:

  • The listen() backlog, capped by net.core.somaxconn, limits the accept queue.
  • net.ipv4.tcp_max_syn_backlog influences the incomplete queue.
  • SYN cookies can change how the kernel handles pressure on incomplete state.

The word backlog also appears in other networking settings. net.core.netdev_max_backlog, for example, concerns packets waiting for kernel network processing, not connections waiting for accept(). These are separate queues with separate producers and consumers.

What accept() Does

accept() removes one completed connection from the accept queue and creates a new descriptor:

The original listening descriptor remains available for more connections. The returned descriptor owns the send buffer, receive buffer, errors, shutdown state, and addressing information for that client.

If the accept queue is empty, a blocking accept() sleeps until a connection becomes available or an error interrupts the call. A nonblocking listener returns EAGAIN.

The application should accept promptly and hand work to an appropriate execution path. A server that performs lengthy request processing before returning to accept() can let pending connections accumulate even when request handlers have spare capacity elsewhere.

Readiness notification can report that the listener has pending work, but the state can change before accept() runs. Nonblocking accept loops therefore continue until EAGAIN rather than assuming one notification corresponds to one connection.

On Linux, accept4() can apply flags atomically:

This avoids a race in multithreaded programs between accepting a descriptor and setting nonblocking or close-on-exec flags with separate fcntl() calls.

When the Accept Queue Fills

Let completed connections arrive at rate A while the application removes them with accept() at rate B.

During a burst, approximate queue growth is:

When A later falls below B, the application can drain the backlog. A larger queue can absorb a longer burst.

If A remains above B, every finite queue eventually fills. Increasing backlog delays overload but does not increase the application's accept rate.

Once the accept queue is full, new establishment attempts can be delayed, retried by the protocol, or rejected, depending on kernel settings and timing. Linux exposes net.ipv4.tcp_abort_on_overflow to choose whether a full accept queue can cause immediate resets in relevant cases. Enabling it changes client-visible failure behavior rather than repairing a slow accept path.

Common reasons the queue stops draining include:

  • The accept thread is not scheduled promptly.
  • The process pauses for runtime or memory-management work.
  • The accept loop performs slow work before accepting again.
  • The process reaches its file-descriptor limit.
  • A lock or overloaded handoff queue stalls the accepting thread.

The application needs capacity at every stage. Draining the kernel queue into an unbounded application queue moves overload into process memory rather than solving it.

Loading simulation...

Admission Blocking from File-Descriptor Exhaustion

Every accepted connection needs a descriptor. If the process reaches its per-process limit, accept() fails with EMFILE. If the system cannot allocate another descriptor, it can fail with ENFILE.

The client connection may already occupy the accept queue when this happens. Repeated accept() failures leave the queue under pressure, so new clients begin to fail even though the listening descriptor remains open.

A process should reserve descriptor capacity for sockets, logs, configuration files, and recovery operations. It must also close accepted sockets along every error path.

Increasing the descriptor limit without controlling connection count expands resource exposure. Each connection also consumes socket memory and application state.

A Backlog Observation Server

The following server binds only to 127.0.0.1. It waits for a configurable number of seconds before calling accept(), making pending connections visible through Linux diagnostics.

Compile it:

Start a server that delays acceptance for ten seconds:

During the delay, create several local connection attempts from another terminal:

Inspect the listening socket before the delay ends:

For a listening socket, Recv-Q reports completed connections waiting for accept(), while Send-Q reports the configured accept-backlog limit shown by ss. Exact observations vary with timing and kernel behavior, so the experiment demonstrates queueing rather than an exact five-client outcome.

The server begins accepting after the delay and prints each peer address. Increasing the backlog can accommodate more of the short burst, but it does not change how quickly the loop calls accept().

UDP Receive Queues Without accept()

A UDP server binds a datagram socket but does not call listen() or accept(). Incoming datagrams are demultiplexed directly to the bound socket's receive queue.

Each recvfrom() or recvmsg() removes one datagram. Message boundaries are preserved. If the application buffer is too small, the datagram can be truncated, and the discarded remainder cannot be read in a second call.

When the receive queue is full, the kernel can drop new datagrams. A successful sendto() at the sender means the local kernel accepted the datagram for transmission; it does not confirm receipt by the destination process.

Receive-buffer sizing and prompt reads therefore matter for bursty UDP workloads. Larger buffers absorb longer bursts but cannot compensate for a sustained arrival rate above the application's processing rate.

Observing Socket State on Linux

ss reports listening and connected sockets:

The -m option includes socket-memory information. This helps distinguish data waiting for the application from a process that has already drained its receive buffers.

Global socket usage appears in:

Relevant configuration can be read with:

Listen overflow counters are available through:

A growing counter shows that the kernel could not admit all incoming connection work. It does not identify why the application stopped draining the queue, so CPU scheduling, file-descriptor use, runtime pauses, and accept-loop behavior still need inspection.

strace can verify that a process is reaching the expected calls:

Tracing changes timing. It is useful for checking control flow and errors, while queue counters and workload measurements describe overload with less disturbance.

Tuning Backlog Without Hiding the Bottleneck

Backlog tuning should start with the arrival pattern and the rate at which the application calls accept().

A larger accept queue is useful when:

  • Traffic arrives in short bursts.
  • The server can drain those bursts soon afterward.
  • Available kernel memory and descriptor capacity support the larger admitted set.

It cannot repair sustained overload. If request processing is the bottleneck, accepting every connection faster may increase application memory use and response latency. Admission limits, load balancing, or rejecting work earlier may provide more predictable behavior.

The listening backlog also needs to match across layers. A framework may pass its own value to listen(), while the kernel caps that request with somaxconn. Changing only one side can leave the effective limit unchanged.

Measurements should include successful connection rate, connection latency, listen overflow counters, accept rate, active descriptor count, socket memory, and application queue depth. One queue length cannot describe the whole server.

Summary

A network socket descriptor refers to kernel state that includes addressing, protocol state, waiters, errors, and send and receive buffers. A successful send normally confirms admission to the local kernel, while receive calls drain bytes or datagrams already assigned to that socket.

Linux TCP listeners maintain incomplete connection state separately from the accept queue of completed connections. listen(backlog) controls the accept queue subject to net.core.somaxconn, and accept() removes one completed connection into a new descriptor.

Backlog and buffer increases can absorb bursts, but they cannot repair sustained overload. Correct diagnosis combines queue counters, accept rate, descriptor use, socket memory, and application capacity.

Quiz

Network Sockets: Buffers, Backlog, and Accept Queues Quiz

5 quizzes