Networking in C · advanced · ~15 min
- By the end you can bind a TCP socket to loopback, accept one client, and complete a full HTTP request/response cycle by hand. - By the end you can hand-write a syntactically correct HTTP/1.0 response, including the status line, headers, the CRLF blank-line separator, and a Content-Length-framed body. - By the end you can read a request defensively with a partial-read loop that waits for the end-of-headers marker instead of assuming one read() delivers everything. - By the end you can explain why a single-connection toy differs from a production server: framing, partial reads, slow clients, and concurrency. - By the end you can keep the whole exercise safe and lab-only by binding to 127.0.0.1 and letting the OS pick an ephemeral port.
You already built a raw TCP server (bind/listen/accept/read/write) and an HTTP GET client (you know a request looks like GET / HTTP/1.0\r\n...\r\n\r\n). This lesson fuses those two skills: you keep the exact TCP plumbing from the server lesson, but now the bytes you write back are a valid HTTP response instead of raw text, and the bytes you read are an HTTP request you must frame correctly.
An HTTP server is, at its core, just a TCP server that speaks one specific text protocol on top of the byte stream. There is no magic: the socket does not know or care that it is carrying HTTP. Your job is to send bytes in the shape HTTP defines and to stop reading the request at the right place. We will build the smallest thing that a real browser or curl would accept, run it entirely on loopback, and then name every hard problem that separates this toy from nginx.
Almost every service you will ever write or defend speaks HTTP: REST APIs, health-check endpoints, admin panels, webhooks, and metrics exporters. Understanding the protocol at the byte level is what lets you debug a curl that hangs, spot a malformed response that a framework silently produced, or reason about why a stream never terminates. On the security side, the two bugs that bite beginners here — trusting a single read() to contain the whole request, and computing Content-Length wrong — are the same class of bug behind request smuggling and response-splitting vulnerabilities in real servers. Framing bytes precisely is not academic; it is the boundary an attacker probes.
The socket layer gives you an ordered, reliable byte stream — nothing more. HTTP is a convention layered on top: the client sends a request made of text lines, the server sends a response made of text lines, and both sides agree on how those lines are delimited and where a message ends. Strip HTTP away and you have exactly the bind → listen → accept → read → write → close loop you already know.
client server
| connect() -------------> accept() returns cfd
| write(request bytes) --> read() the request
| (parse / ignore)
| read(response bytes) <-- write(response bytes)
| <-------- close() ------ close(cfd)
An HTTP/1.0 response has three parts in a fixed order: a status line, zero or more header lines, a blank line, then the optional body. Every line ends with CRLF — carriage return (\r, 0x0D) followed by line feed (\n, 0x0A). The blank line is just a CRLF with nothing before it, so on the wire you see \r\n\r\n marking the boundary between headers and body.
H T T P / 1 . 0 SP 2 0 0 SP O K \r \n <- status line
C o n t e n t - L e n g t h : SP 5 \r \n <- one header
\r \n <- blank line: headers end here
h e l l o <- body: exactly 5 bytes
The status line has a strict grammar: HTTP-Version SP Status-Code SP Reason-Phrase CRLF. For a success that is HTTP/1.0 200 OK\r\n. Drop the reason phrase, the space, or the version and a strict client rejects it.
| Part | Example | Rule |
|---|---|---|
| Status line | HTTP/1.0 200 OK\r\n |
version, space, 3-digit code, space, reason, CRLF |
| Header line | Content-Length: 5\r\n |
Name: value, CRLF-terminated |
| Blank line | \r\n |
separates headers from body; always required |
| Body | hello |
raw bytes; length must match Content-Length |
Knowledge check: What is the minimum valid status line for a 200 response, and why can't you shorten it to 200 OK\r\n?
The minimum is
HTTP/1.0 200 OK\r\n(orHTTP/1.1 200 OK\r\n). The grammar isHTTP-Version SP Status-Code SP Reason-Phrase CRLF— the version token is mandatory and comes first.200 OK\r\nomits the version, so a conforming client cannot tell which protocol you are speaking and will treat the response as malformed.
A TCP stream has no built-in "message over" marker. So how does the client know it has read the whole body and can stop? In HTTP/1.0 the answer is either Content-Length — you promise exactly N body bytes — or connection close — the body runs until the server closes the socket. Content-Length is the robust choice: send Content-Length: 5\r\n and then exactly 5 body bytes. If your header says 5 but you send 4, the client blocks forever waiting for the fifth byte; if it says 5 but you send 6, the extra byte is mis-parsed as the start of the next response. Count the body bytes, not the characters you think you typed.
The symmetric hazard is on the read side. read() on a TCP socket returns whatever bytes have arrived so far — it may hand you the whole request, half of it, or a single byte, and it can split the request line and headers across several calls. You must loop, appending into a buffer, until you see the end-of-headers marker \r\n\r\n. Only then do you know the request head is complete.
read #1 -> "GET / HTTP/1.0\r\nHo" (no \r\n\r\n yet -> keep reading)
read #2 -> "st: x\r\n\r\n" (found \r\n\r\n -> headers complete)
For a hard-coded toy that always replies the same thing you could technically ignore the request entirely, but you still must drain enough of it that the client's write completes, and reading to \r\n\r\n is the correct, honest habit.
Our server calls accept() once, serves that client, and exits. A real server loops on accept() forever, and each connection can take a long time, so it must serve clients concurrently. The classic options:
| Strategy | How | Trade-off |
|---|---|---|
| One at a time | accept() in a loop, serve, repeat |
Simple; a slow client blocks everyone behind it |
fork per connection |
new process per client | Strong isolation; heavier, per-process memory |
| Thread per connection | pthread_create per client |
Cheaper than fork; shared state needs locking |
select/poll/epoll |
one thread watches many FDs | Scales to thousands; more complex control flow |
Because each connection needs its own file descriptor, the kernel gives you a new socket FD from accept() (the cfd) that is distinct from the listening FD (lfd). The listener keeps listening; the connected FD carries this one conversation.
Binding to 127.0.0.1 (loopback) means only processes on your own machine can connect — packets never leave the host. Binding to 0.0.0.0 exposes the port to the whole network. For learning, always use loopback and let the OS choose a free port with port 0; you get a working server with zero blast radius.
int socket(int domain, int type, int protocol) — AF_INET (IPv4), SOCK_STREAM (TCP), 0 (default proto). Returns a file descriptor or -1/errno on error. Must be close()d.
int bind(int fd, const struct sockaddr *addr, socklen_t len) — assigns a local address/port. Cast your struct sockaddr_in * to struct sockaddr *. Returns 0 or -1/errno (e.g. EADDRINUSE).
int listen(int fd, int backlog) — marks the socket passive; backlog bounds the queue of not-yet-accepted connections. Returns 0 or -1.
int accept(int fd, struct sockaddr *addr, socklen_t *len) — blocks until a client connects, returns a new connected FD (the listener FD stays open). Returns -1/errno on error. The new FD must be close()d.
int setsockopt(int fd, int level, int opt, const void *val, socklen_t len) — with SOL_SOCKET, SO_REUSEADDR lets you re-bind a port immediately after a prior run exited.
int getsockname(int fd, struct sockaddr *addr, socklen_t *len) — fills in the address actually bound; use it to learn the ephemeral port the OS chose when you bound to port 0.
ssize_t read(int fd, void *buf, size_t n) — returns bytes read (> 0), 0 at end-of-stream (peer closed), or -1/errno. May return fewer bytes than requested — always loop.
ssize_t write(int fd, const void *buf, size_t n) — returns bytes written, possibly fewer than n; loop until all sent. -1/errno on error (EINTR means retry).
uint16_t htons(uint16_t) / uint16_t ntohs(uint16_t) — convert port between host and network byte order. int inet_pton(int af, const char *src, void *dst) — parse "127.0.0.1" into struct in_addr; returns 1 on success.
A toy HTTP server needs only a few steps:
127.0.0.1) so the server only accepts connections from your own machine.The response is a single hard-coded HTTP message:
HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello
The \r\n sequences are carriage-return + line-feed, the line ending HTTP requires. A blank line (\r\n\r\n) separates the headers from the body. Here the body is hello, which is 5 bytes long.
This design serves exactly one connection. To handle multiple clients you need either:
fork per connection — start a new process for each client, orselect / poll multiplexing — watch many connections at once in a single process.Even an HTTP/1.0 toy server runs into genuine problems:
This is why production HTTP servers are far from trivial.
// A hermetic HTTP/1.0 server demo: everything runs on loopback in ONE process.
// The main thread is the server; a helper thread is the client. No root, no
// external network, no fixed port (we bind to 127.0.0.1:0 and ask the OS which
// port it gave us). This lets us show the full request/response cycle safely.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
// The one hard-coded response the server always sends. Note the CRLF (\r\n)
// line endings and the blank line (\r\n\r\n) that separates headers from body.
static const char RESPONSE[] =
"HTTP/1.0 200 OK\r\n"
"Content-Type: text/plain\r\n"
"Content-Length: 5\r\n"
"\r\n"
"hello";
// write_all: TCP send() may write FEWER bytes than asked, so loop until done.
static int write_all(int fd, const char *buf, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t n = write(fd, buf + sent, len - sent);
if (n < 0) {
if (errno == EINTR) continue; // interrupted by signal: retry
return -1;
}
sent += (size_t)n;
}
return 0;
}
// The client thread: connect to the port the server printed, send a request,
// read the whole response, print it. Argument is the port (as a small int).
static void *client_thread(void *arg) {
int port = (int)(long)arg;
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("client socket"); return NULL; }
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("connect"); close(fd); return NULL;
}
const char *req = "GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n";
write_all(fd, req, strlen(req));
char buf[512];
ssize_t total = 0, n;
while ((n = read(fd, buf + total, sizeof buf - 1 - (size_t)total)) > 0) {
total += n;
if ((size_t)total >= sizeof buf - 1) break;
}
buf[total] = '\0';
printf("[client] received %zd bytes:\n%s\n", total, buf);
close(fd);
return NULL;
}
int main(void) {
// 1. Create the listening socket.
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) { perror("socket"); return 1; }
// Allow immediate re-bind of the port after the program exits.
int one = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
// 2. Bind to LOOPBACK only (127.0.0.1). Port 0 = "OS, pick a free port".
struct sockaddr_in srv = {0};
srv.sin_family = AF_INET;
srv.sin_port = htons(0);
inet_pton(AF_INET, "127.0.0.1", &srv.sin_addr);
if (bind(lfd, (struct sockaddr *)&srv, sizeof srv) < 0) {
perror("bind"); close(lfd); return 1;
}
// 3. Discover which port the OS actually assigned.
struct sockaddr_in bound;
socklen_t blen = sizeof bound;
getsockname(lfd, (struct sockaddr *)&bound, &blen);
int port = ntohs(bound.sin_port);
printf("[server] listening on 127.0.0.1:%d\n", port);
// 4. Mark the socket passive so it can accept() connections.
if (listen(lfd, 1) < 0) { perror("listen"); close(lfd); return 1; }
// Start the client in another thread so it can connect to us.
pthread_t t;
pthread_create(&t, NULL, client_thread, (void *)(long)port);
// 5. Accept exactly ONE connection.
struct sockaddr_in cli;
socklen_t clen = sizeof cli;
int cfd = accept(lfd, (struct sockaddr *)&cli, &clen);
if (cfd < 0) { perror("accept"); close(lfd); return 1; }
// 6. Read the request headers until we see the blank line (\r\n\r\n).
// Data may arrive in pieces, so we loop and scan the growing buffer.
char req[1024];
size_t got = 0;
while (got < sizeof req - 1) {
ssize_t n = read(cfd, req + got, sizeof req - 1 - got);
if (n <= 0) break; // client closed or error
got += (size_t)n;
req[got] = '\0';
if (strstr(req, "\r\n\r\n")) break; // end of headers found
}
printf("[server] got request of %zu bytes; first line:\n", got);
// Print just the request line (up to first \r\n).
char *eol = strstr(req, "\r\n");
if (eol) { *eol = '\0'; printf(" %s\n", req); }
// 7. Send the fixed response and 8. close the connection.
write_all(cfd, RESPONSE, sizeof RESPONSE - 1);
close(cfd);
close(lfd);
pthread_join(t, NULL);
printf("[server] done.\n");
return 0;
}
RESPONSE[] — the entire response, spelled out with explicit \r\n. Adjacent C string literals concatenate, so this is one message: status line, two headers, the blank line ("\r\n" on its own), then the 5-byte body hello. Content-Length: 5 must equal the body length exactly.
write_all() — wraps write() in a loop because a single write() can send fewer bytes than requested on a busy socket. It advances sent by each partial write and retries on EINTR (a signal interrupted the call). Real servers always need this; assuming one write suffices is a latent bug.
client_thread() — runs the client side inside the same process so the demo is self-contained. It builds a sockaddr_in for 127.0.0.1 and the server's port, connect()s, sends a normal GET / HTTP/1.0 request terminated by the blank line, then reads until the peer closes (read() returns 0) and prints what it got.
socket() / setsockopt(SO_REUSEADDR) — create the listening FD and allow the port to be reused right after a previous run, avoiding EADDRINUSE during rapid testing.
bind() to 127.0.0.1 port 0 — loopback keeps the server unreachable from the network; port 0 tells the kernel to assign any free ephemeral port, so the demo never collides with something already listening.
getsockname() — because we bound to port 0, we don't yet know the real port; this reads it back so we can print it and hand it to the client thread.
listen(lfd, 1) — flips the socket into passive mode with a backlog of 1 (we only expect one client).
accept() — blocks until the client thread connects, then returns cfd, a brand-new FD for this one conversation. lfd stays open and could accept more.
The read loop — the heart of the framing lesson: append into req, NUL-terminate, and stop only when strstr finds \r\n\r\n. This tolerates the request arriving in multiple read() chunks. We then trim at the first \r\n to print just the request line.
write_all(cfd, RESPONSE, sizeof RESPONSE - 1) — sizeof includes the trailing NUL, so we subtract 1 to send only the real bytes. close(cfd) ends the response (which is also how the HTTP/1.0 client knows the stream is over), and pthread_join waits for the client to finish printing before main returns.
1. Trusting one read() to hold the whole request.
char req[1024];
read(cfd, req, sizeof req); // WRONG: may return just a few bytes
// ... proceed as if the full request is here
Why it breaks: TCP is a stream; read() returns whatever has arrived, often a fragment. You parse garbage or miss headers, and on real traffic it fails intermittently.
size_t got = 0; ssize_t n;
while (got < sizeof req - 1 &&
(n = read(cfd, req + got, sizeof req - 1 - got)) > 0) {
got += (size_t)n; req[got] = '\0';
if (strstr(req, "\r\n\r\n")) break; // FIX: loop to end-of-headers
}
2. Wrong Content-Length.
"HTTP/1.0 200 OK\r\nContent-Length: 6\r\n\r\nhello" // body is 5, not 6
Why it breaks: the client waits for a 6th body byte that never comes and hangs, or (if too small) treats leftover bytes as the next response. Fix: count the body exactly.
"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello" // matches the 5 bytes
3. \n instead of \r\n, or a missing blank line.
"HTTP/1.0 200 OK\nContent-Length: 5\nhello" // LF only, no blank line
Why it breaks: HTTP requires CRLF line endings and a \r\n\r\n separator; strict clients reject the response or never find the body boundary. Fix: use \r\n everywhere and an empty line before the body.
"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello"
4. Writing back on the listening FD instead of the accepted FD.
accept(lfd, ...); // return value ignored!
write(lfd, RESPONSE, ...); // WRONG: lfd is the listener, not a client
Why it breaks: the listening socket has no peer; the write fails or goes nowhere. Fix: use the FD accept() returned.
int cfd = accept(lfd, ...);
write_all(cfd, RESPONSE, sizeof RESPONSE - 1);
Test the running server from another terminal with curl -v http://127.0.0.1:PORT/ — the -v flag prints the request it sent and the raw response headers, so you immediately see if your status line or Content-Length is malformed. nc 127.0.0.1 PORT (netcat) lets you type a request by hand and watch the exact bytes come back.
To see the framing on the wire, run strace -e trace=network,read,write ./server (Linux) or dtruss (macOS) and watch each accept, read, and write with its byte counts — a read returning fewer bytes than the request length is the partial-read case made visible. If bind fails, errno/perror printing EADDRINUSE means the port is still held; SO_REUSEADDR or a fresh ephemeral port fixes it. If curl hangs waiting for the body, your Content-Length is larger than the bytes you sent. Add printf around your read loop to log got after each iteration and confirm you actually reach \r\n\r\n. Under gdb, breakpoint on accept and write_all to step through the handshake; run with -fsanitize=address to catch any buffer overrun in the request buffer.
The request buffer is the main hazard. Always cap read() at sizeof buf - 1 - got so you leave room for a NUL terminator and never write past the array — an off-by-one here is a classic stack overflow. Because you strstr() the buffer, it MUST be NUL-terminated after every read; forgetting the req[got] = '\0' lets strstr walk into uninitialized memory (undefined behavior, and AddressSanitizer will flag it). Never assume the request fits: a real server treats an over-long request as an error, not as a reason to grow the buffer unboundedly.
On the write side, use sizeof RESPONSE - 1 (not strlen on binary data, and not sizeof including the NUL) so you transmit exactly the intended bytes. Every successful accept() returns an FD you must close(); leaking connection FDs exhausts the process's descriptor table and eventually accept() fails with EMFILE. In the threaded demo, the client and server touch only their own FDs and local buffers, so there is no shared mutable state to race on — but the moment you add a shared counter or connection list across threads you need a mutex; unsynchronized access is a data race and undefined behavior. Finally, ignoring SIGPIPE matters: writing to a socket whose peer already closed raises SIGPIPE and kills the process by default — production servers ignore it and handle the EPIPE return instead.
This exact skeleton underlies health-check and metrics endpoints (Prometheus exporters, Kubernetes liveness probes), embedded-device config pages, local development servers, and the minimal HTTP servers inside language runtimes. Load balancers and reverse proxies (nginx, HAProxy, Envoy) are elaborate versions of the same accept/read/frame/write loop with concurrency, TLS, and full RFC parsing bolted on.
Best practice for anything beyond a toy: never hand-roll request parsing for untrusted network input in production — use a vetted HTTP library that has been hardened against smuggling and header-injection. If you do write raw sockets, always set read/write timeouts so a slow-loris client cannot pin a connection forever, bind to the narrowest interface you need (loopback for local-only services), ignore SIGPIPE, cap request sizes, and reject anything that does not frame cleanly rather than guessing. Concurrency should use a thread pool or an event loop (epoll/kqueue) rather than unbounded thread-per-connection so a flood of clients cannot exhaust memory.
Change the response body to Hello, HTTP! and update Content-Length to the correct value by hand-counting the bytes. Verify with curl -v that the client reads exactly that many body bytes and no more.
Add logging that prints the full request the server received (all bytes up to \r\n\r\n), then confirm with nc that a request split across two sends is still assembled correctly by your read loop.
Turn the single accept() into an infinite while (1) loop that serves connection after connection, closing each cfd before looping. Connect twice in a row with curl and confirm both succeed.
Parse the request line: extract the method and path (e.g. GET and /) and return 404 Not Found with a short body for any path other than /. Make sure your 404 status line and Content-Length are both valid.
Make the server concurrent by spawning a detached pthread (or a fork) per accepted connection so a deliberately slow client cannot block a second client. Prove it by having one client sleep mid-request while another completes immediately.
socket→bind→listen→accept→read→write→close) that speaks a text protocol on top of the byte stream.HTTP/1.0 200 OK\r\n, headers, a blank \r\n, then the body. All line endings are CRLF (\r\n).Content-Length must equal the exact body byte count — too big hangs the client, too small corrupts the next message.read() returns whatever has arrived; loop until you see \r\n\r\n to frame the request. write() can be partial too — loop with a write_all helper.accept() returns a NEW FD per connection, distinct from the listening FD; close every FD you open.127.0.0.1 (loopback) and port 0 to stay safe and lab-only. Serving many clients needs fork, threads, or select/poll/epoll, plus timeouts against slow clients.