Skip to content

What is a socket and how does it work?

The load-balancing algorithms module closed by pointing at the layer underneath every backend a load balancer ever routes to: the socket a client and a server each open to actually exchange bytes over a connection. Every article up to this point in the course described protocols — TCP's handshake, HTTP's request format, DNS's queries — from the outside, as an exchange of segments and messages between two hosts. This article looks at the same exchange from inside a running program, where all of it happens through one specific operating system primitive: the socket.

A socket is a file descriptor, not a network concept

A socket is the operating system's handle for one endpoint of a network connection — and concretely, on Linux, that handle is a file descriptor: a small integer a process holds that the kernel uses internally to look up everything it actually needs to know about that connection. This isn't a loose analogy. Linux genuinely treats a socket the same way it treats an open file, a pipe, or a terminal — as an entry in the process's file descriptor table, each one just an index pointing to a kernel-side data structure that happens to describe a network connection instead of a file on disk.

That single design choice is why the same three calls — read() and write(), or a language's socket library wrapping them — work on a network connection exactly as they work on a file: the kernel doesn't distinguish between "data from disk" and "data from the network" once something is holding an open file descriptor. What differs entirely is what's on the other end of that descriptor and how it got created in the first place, which is what the rest of this article walks through.

Creating a socket: the address family and the type

A program creates a socket with a system call, wrapped in every language's standard library — Python's socket.socket(), for instance — that takes two pieces of information before anything else can happen: which address family the socket will use, and which type of communication it will provide.

The address family says what kind of address this socket speaks — AF_INET for IPv4 addresses, AF_INET6 for IPv6, among others. This is the same distinction IP addressing drew between the two address formats; a socket has to commit to one family at creation time, because an IPv4 address and an IPv6 address aren't just longer or shorter versions of the same thing to the kernel, they're different structures entirely.

The socket type says what delivery guarantees the socket provides, and there are exactly two that matter for this course: SOCK_STREAM, which gives a reliable, ordered byte stream — this is TCP, and Python's own documentation calls it "typically used for TCP" — and SOCK_DGRAM, which gives connectionless, unordered, unacknowledged individual messages — this is UDP. Everything the TCP vs UDP comparison explained about the difference between the two protocols is the exact same difference between these two socket types; nothing new is being introduced here except which system call requests which behavior.

import socket

tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

At this point, neither socket is connected to anything, listening for anything, or bound to any address. It's just a file descriptor sitting in the process, waiting to be told what to do with it.

Two very different lives for a socket: listening versus connecting

From here, a socket's life splits in two directions depending on whether the process is going to be the one accepting connections or the one initiating them — and the sequence of calls is genuinely different on each side, not just a naming convention.

A server socket goes through three steps before it can accept anything:

  1. bind() attaches the socket to a specific IP address and port on the local machine — telling the kernel "connections arriving for this address and this port belong to me." This is the same IP-and-port pairing the ports and protocols introduction already established as how the kernel identifies a specific listening process, applied here at the exact system call that creates that binding.
  2. listen() tells the kernel this socket will be used to accept incoming connections, rather than to initiate one. It also gives the kernel a backlog value — Linux's own manual page for listen() defines it as "the maximum length to which the queue of pending connections... may grow." That queue matters more than its one-line definition suggests, and the next section covers exactly what it's a queue of.
  3. accept() blocks until a connection has actually completed, then returns a brand-new socket — a different file descriptor from the one listen() was called on — representing that one specific connection. Python's own documentation is precise about this: accept() returns "a new socket object usable to send and receive data on the connection." The original listening socket keeps listening; it's never used to exchange data itself.

A client socket skips bind() and listen() entirely and instead calls connect(), giving it the remote address and port to reach. The kernel picks an ephemeral source port automatically — exactly the mechanism dynamic port range already covered — and, for a SOCK_STREAM socket, this is the call that actually drives the three-way handshake: connect() doesn't return successfully until SYN, SYN-ACK, and the final ACK have all completed.

Server:  socket() -> bind() -> listen() -> accept() [blocks] -> (new socket for this connection)
Client:  socket() -> connect() [drives the handshake] -> (same socket used for the connection)

A listening socket's only job is producing connection sockets; it never carries a single byte of application data itself. Every read and write from that point on happens on the new socket accept() handed back, not on the one listen() was called on.

The queue between "handshake completed" and "your code has it"

accept() doesn't create a connection — it hands over one the kernel already finished setting up. Between a client's SYN arriving and a program's own accept() call returning, the kernel is doing work the application never sees directly, and understanding that work explains a specific, real failure mode later in this course's hands-on module.

When a SYN arrives for a listening socket, the kernel doesn't wait for the application to do anything — it immediately replies with a SYN-ACK on its own and places that half-open attempt into what's commonly called the SYN queue, tracking it until either the final ACK arrives or it times out. The three-way handshake article already showed exactly this state from the outside — a connection sitting in SYN-RECV, visible via ss -n state syn-recv, waiting for that final ACK.

Once the final ACK arrives, completing the handshake, the kernel moves that connection out of the SYN queue and into a second queue — the accept queue — where it sits, fully established at the TCP level, waiting for the application to actually call accept() and claim it. This is the queue listen()'s backlog argument actually sizes: not pending handshakes, but completed connections the application hasn't picked up yet — the exact number ss -tulpn prints in its Send-Q column for a LISTEN row, which the ss article already flagged as "the configured backlog," not a per-connection send buffer.

SYN arrives  -> kernel replies SYN-ACK -> parked in SYN queue (half-open)
Final ACK    -> handshake complete     -> moved to accept queue (fully open, unclaimed)
accept()     -> removes one entry from the accept queue -> new socket, in your process

A backlog that's too small for how fast connections arrive, combined with an application that isn't calling accept() quickly enough, means the accept queue fills up — and on Linux, once it's full, the kernel's behavior depends on the client: it may refuse new completed connections outright, or, since TCP can retransmit, simply let a client's final ACK go unacknowledged so the client retries. Either way, from the client's perspective, it looks exactly like a slow or unresponsive server, even though every layer below the application accepted the connection correctly and on time.

The kernel caps the backlog value regardless of what your code requests

Linux silently caps whatever backlog value a program passes to listen() at the value in /proc/sys/net/core/somaxconn — the manual page for listen() states this explicitly, and notes the default is 4096 on kernel 5.4 and later, versus 128 on older kernels. Passing a backlog of 10,000 on an older kernel doesn't actually get you a 10,000-deep queue; it silently gets you 128, which matters if a service starts refusing connections under load and the configured backlog looks generous on paper.

Binding to a specific address versus binding to everything

bind() takes an address as well as a port, and which address gets used there is a decision with real security consequences, not a formality.

A machine typically has several addresses simultaneously — the loopback address 127.0.0.1, one or more addresses on real network interfaces, and possibly others from a VPN tunnel or a container bridge network. Binding to one specific address — bind(('127.0.0.1', 8080)), for instance — means the socket only accepts connections arriving on that specific interface: a request reaching the machine's public interface, addressed to the same port, is refused, because the kernel only delivers it to a socket bound to a matching local address.

Binding to 0.0.0.0 instead means "accept connections arriving on any of this machine's interfaces, whichever address they were sent to" — every interface at once, including a public one, if the machine has one. It's a common default specifically because it's convenient during development, on a laptop with no public interface to worry about, and that same convenience is exactly what turns dangerous the moment the identical code runs on a cloud server with a public IP address.

Binding to 0.0.0.0 on a machine with a public IP address exposes that service to the entire internet

A development tool, an internal admin panel, or a database started with no authentication and bound to 0.0.0.0 is reachable from anywhere the moment it runs on a host with a public interface — not just from the local network. This is a well-documented, recurring cause of real data breaches: unauthenticated MongoDB and Elasticsearch instances left listening on all interfaces have been found and emptied by automated internet-wide scans, not by a targeted attack. Bind a service that should only be reachable locally or from a private network to 127.0.0.1 or to the specific private-network address it actually needs, and confirm with sudo ss -tulpn — the same command and the same 0.0.0.0-versus-127.0.0.1 distinction the ss article already covers — which address it's actually listening on before exposing the host to any public network.

Practice exercises

  1. Explain, using the sequence of calls above, why a client socket never calls bind() explicitly in most programs, even though the resulting connection still has a source port on the client side.
  2. A server's backlog is configured to 5000 on a host running a pre-5.4 Linux kernel. Using the somaxconn note above, state what the effective backlog actually is, and why.
  3. A teammate binds a new internal debugging endpoint to 0.0.0.0 on a cloud VM "just for local testing," planning to remove it before deploying. Using the section above, explain concretely what has to go wrong for this to become a real incident, and what single change would have prevented it regardless.

Everything in this article describes one socket handling one connection. The next article puts these exact calls to work in a complete, runnable program — a TCP echo server that binds, listens, accepts, and answers real connections, followed line by line.

Sources