Networking in C · advanced · ~12 min
**What you will learn** - Create an IPv4 stream socket with `socket()` and understand what descriptor it returns. - Fill in a `struct sockaddr_in` correctly using `AF_INET`, `htons()`, and `inet_pton()`. - Open a connection with `connect()` and reason about what happens during the TCP handshake. - Exchange bytes reliably with `send()`/`recv()` (or `write()`/`read()`), handling short reads and short writes. - Check every return value, distinguish EOF from errors, and clean up with `close()`. - Practice safely against the loopback address and understand why unauthorized connections are off-limits.
A TCP client is a program that reaches out across a network and opens a reliable, ordered, two-way byte stream to a server. Almost everything you do online rides on this pattern: a web browser fetching a page, an email program collecting mail, a game talking to its matchmaking service, a database driver querying a remote database. In each case a client speaks first — it dials a specific machine and port, waits to be accepted, and then the two sides trade bytes until someone hangs up.
This lesson builds directly on Sockets introduction, where you met the idea of a socket as a communication endpoint identified by a file descriptor, and saw the difference between stream (TCP) and datagram (UDP) sockets. Here we put those pieces into motion for the client side of a TCP conversation. (The matching server side — which waits for clients instead of dialing out — is the next lesson in this category.)
In plain language, being a TCP client is like making a phone call. You pick up the phone (create a socket), dial a specific number (an IP address and a port), wait for the other side to pick up (the connection is established), then talk and listen (send and receive), and finally hang up (close). The operating system and the TCP protocol handle the hard parts underneath: retransmitting lost packets, putting bytes back in order, and pacing the flow so neither side is overwhelmed. Your job is to describe who you want to reach, open the line, and move bytes carefully.
The terminology you will use throughout: a socket is the endpoint (an int file descriptor on Linux and macOS); an address is the struct sockaddr_in describing the destination for IPv4; byte order is the on-the-wire convention for multi-byte numbers (network byte order is big-endian); and the loopback address 127.0.0.1 is a special IP that always means "this same machine," perfect for practice.
Nearly every networked application you have ever used contains a TCP client somewhere. When you understand the client side, you understand the foundation of HTTP, TLS, SSH, SMTP, database wire protocols, message queues, and countless custom services. Higher-level libraries (an HTTP client, a Redis driver, a gRPC stub) are all wrappers around exactly the calls in this lesson.
Writing a client by hand teaches you what those libraries hide: that connect() can block or fail, that recv() can return fewer bytes than you asked for, that a return value of 0 means the peer closed the connection, and that forgetting htons() silently sends you to the wrong port. These are the details that separate code that works on your laptop from code that survives a real, lossy, congested network.
It also matters for security-minded programming. A client that trusts whatever it reads, copies it into a fixed buffer without checking the length, or ignores partial reads is a classic source of buffer overflows and logic bugs. Learning to treat every incoming byte as untrusted input is a habit that pays off in every program that touches a network.
Definition. A socket is a communication endpoint. socket(AF_INET, SOCK_STREAM, 0) asks the kernel for a TCP/IPv4 socket and returns a small non-negative integer — a file descriptor — that names it, just like open() returns a descriptor for a file.
How it works internally. The kernel allocates a data structure describing the socket (its protocol, buffers, and eventually the connection state) and hands you an index into your process's descriptor table. From then on you pass that int to connect(), send(), recv(), and close().
When to use / not use. Use SOCK_STREAM for TCP when you need reliable, ordered delivery (the vast majority of client work). Use SOCK_DGRAM (UDP) only when you can tolerate loss and want lower overhead. AF_INET selects IPv4; AF_INET6 selects IPv6.
Pitfall. socket() returns -1 on failure (out of descriptors, unsupported family). Beginners assume it always succeeds and then call connect() on -1, which fails with a confusing error. Always check.
Knowledge check: What type does
socket()return, and what value signals failure?
struct sockaddr_inDefinition. For IPv4, the destination is a struct sockaddr_in holding three things you must set: the address family, the port, and the IP address.
struct sockaddr_in {
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // port, network byte order (htons)
struct in_addr sin_addr; // 32-bit IPv4 address (inet_pton)
char sin_zero[8]; // padding, keep it zeroed
};
How it works internally. The socket API is generic: functions like connect() take a struct sockaddr * so they can accept IPv4, IPv6, or Unix addresses. sockaddr_in is the IPv4-specific layout; you fill it in and cast its pointer to struct sockaddr * when calling connect(). Zero the whole struct first (= {0}) so the padding and any unset fields are clean.
When to use / not use. Use sockaddr_in for IPv4. For IPv6 you would use sockaddr_in6; for name-based lookups ("example.com") you would use getaddrinfo() instead of hard-coding an address.
Pitfall. Leaving sin_port in host byte order, or leaving the struct uninitialized so sin_zero contains garbage.
htons() and inet_pton()Definition. Different CPUs store multi-byte integers in different orders (endianness). The network standard is big-endian, called network byte order. htons() ("host to network, short") converts a 16-bit value such as a port; inet_pton() ("presentation to network") parses a text IP like "127.0.0.1" into the correct binary form.
How it works internally. On a little-endian machine (like most x86 and ARM), htons(8080) swaps the two bytes so the wire sees them in big-endian order. On a big-endian machine it is a no-op. inet_pton() validates the text and writes 4 bytes into sin_addr.
When to use / not use. Always use htons() for the port and inet_pton() (or getaddrinfo()) for the address. Never build these by hand or assume your machine's order matches the wire.
Pitfall. Forgetting htons() means 8080 (0x1F90) is sent as 0x901F = 36895, so you try to connect to the wrong port. inet_pton() returns 1 on success, 0 for a malformed address, and -1 for an unsupported family — many learners ignore the 0 case.
| Function | Converts | Returns | Failure signal |
|---|---|---|---|
htons(port) |
16-bit host → network order | converted value | (cannot fail) |
inet_pton(AF_INET, str, dst) |
text IP → binary | 1 ok |
0 bad string, -1 bad family |
htonl(x) |
32-bit host → network order | converted value | (cannot fail) |
Knowledge check (find the bug):
a.sin_port = 8080;compiles and runs but connects to the wrong service. What is missing?
connect()Definition. connect(fd, addr, addrlen) performs the TCP three-way handshake and, on success, leaves the socket connected to the server.
How it works internally. TCP exchanges three packets to set up an ordered, reliable channel:
Client Server
|------- SYN ---------------->| "let's talk, my seq = x"
|<------ SYN, ACK ------------| "ok, my seq = y, ack x+1"
|------- ACK ---------------->| "got it, ack y+1"
|==== connection established ==|
By default connect() blocks until the handshake completes, the server refuses (RST → ECONNREFUSED), or a timeout expires (ETIMEDOUT). After it returns successfully, the socket is a live two-way byte pipe.
When to use / not use. Call connect() once per socket for TCP. For servers you would use bind()/listen()/accept() instead. For non-blocking or timeout-controlled connects you set the socket non-blocking and use select()/poll().
Pitfall. Ignoring the return value. ECONNREFUSED (nothing listening on that port) and ETIMEDOUT (host unreachable / firewalled) are the two you will meet constantly.
send() / recv() and short transfersDefinition. send(fd, buf, n, 0) writes up to n bytes; recv(fd, buf, n, 0) reads up to n bytes. write()/read() behave the same for sockets.
How it works internally. TCP is a byte stream with no message boundaries. One send() of 100 bytes may arrive as two recv()s of 40 and 60, or the reverse. recv() returns the number of bytes actually read, 0 when the peer has closed its end (EOF), or -1 on error. send() may accept fewer bytes than you offered when the send buffer is full.
sender: send("HELLO WORLD") // one call, 11 bytes
network: [HELLO WO][RLD] // split into segments
receiver: recv() -> "HELLO WO" // 8 bytes
recv() -> "RLD" // 3 bytes -> loop until you have what you need
When to use / not use. Always loop: keep calling recv() until you have a full message (using your protocol's length or delimiter), and keep calling send() until all your bytes are out. Do not assume one call transfers everything.
Pitfall. Treating a single recv() as a whole message, or treating the received bytes as a C string without adding a terminating '\0' — leading to buffer over-reads.
recv() return |
Meaning | Action |
|---|---|---|
> 0 |
that many bytes read | process them; maybe loop for more |
0 |
peer closed connection (EOF) | stop reading, close |
-1 |
error (check errno) |
handle/retry (e.g. EINTR) or abort |
Knowledge check (explain in your own words): Why can you not assume that one
send()on the server maps to exactly onerecv()on the client?
The client sequence, annotated:
#include <sys/socket.h> // socket, connect, send, recv
#include <netinet/in.h> // struct sockaddr_in
#include <arpa/inet.h> // htons, inet_pton
#include <unistd.h> // close
int fd = socket(AF_INET, SOCK_STREAM, 0); // IPv4 + TCP; returns fd or -1
struct sockaddr_in addr = {0}; // zero every field first
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(8080); // port in NETWORK byte order
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); // text IP -> binary
connect(fd, (struct sockaddr *)&addr, sizeof addr); // cast to generic sockaddr*
send(fd, buf, len, 0); // 4th arg = flags, 0 = default
recv(fd, buf, cap, 0); // returns bytes read, 0 = EOF, -1 = error
close(fd); // release the descriptor
Key points: include the four headers above; cast &addr to struct sockaddr *; pass sizeof addr as the length; the 0 flags argument is the common default.
A TCP client follows the same basic sequence every time:
socket() — create the socket (the endpoint you will communicate through).connect() — open a connection to the server.send() / recv() (or write() / read()) — exchange bytes.close() — shut the socket down when you are done.Before you can connect, you must describe where you are connecting to. For IPv4 this lives in a struct sockaddr_in. Fill in three fields:
sin_family to AF_INET (IPv4).sin_port using htons(), which converts the port number to network byte order (the byte ordering used on the wire).sin_addr using inet_pton(), which parses a text IP address (such as "127.0.0.1") into binary form.For local-only practice, use 127.0.0.1 — the loopback address, which always refers to your own machine.
Never scan or connect to third-party hosts without explicit permission.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
/* Send all `len` bytes, looping over short writes. Returns 0 on success. */
static int send_all(int fd, const char *buf, size_t len) {
size_t sent = 0;
while (sent < len) {
ssize_t n = send(fd, buf + sent, len - sent, 0);
if (n < 0) {
if (errno == EINTR) continue; // interrupted by a signal, retry
return -1; // real error
}
sent += (size_t)n; // advance past what was accepted
}
return 0;
}
int main(int argc, char **argv) {
const char *ip = (argc > 1) ? argv[1] : "127.0.0.1";
int port = (argc > 2) ? atoi(argv[2]) : 8080;
/* 1. Create an IPv4 TCP socket. */
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { perror("socket"); return 1; }
/* 2. Describe the destination. */
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons((uint16_t)port); // host -> network byte order
int pr = inet_pton(AF_INET, ip, &addr.sin_addr);
if (pr == 0) { fprintf(stderr, "bad IP: %s\n", ip); close(fd); return 1; }
if (pr < 0) { perror("inet_pton"); close(fd); return 1; }
/* 3. Open the connection (blocks through the TCP handshake). */
if (connect(fd, (struct sockaddr *)&addr, sizeof addr) < 0) {
perror("connect"); // e.g. ECONNREFUSED if nothing is listening
close(fd);
return 1;
}
printf("connected to %s:%d\n", ip, port);
/* 4. Send a request. */
const char *msg = "PING\n";
if (send_all(fd, msg, strlen(msg)) < 0) {
perror("send"); close(fd); return 1;
}
/* 5. Read the reply, looping until EOF. */
char buf[512];
for (;;) {
ssize_t n = recv(fd, buf, sizeof buf - 1, 0); // leave room for '\0'
if (n < 0) {
if (errno == EINTR) continue;
perror("recv"); close(fd); return 1;
}
if (n == 0) break; // peer closed the connection (EOF)
buf[n] = '\0'; // terminate before treating as text
fputs(buf, stdout);
}
/* 6. Clean up. */
close(fd);
return 0;
}
What it does. It parses an optional IP and port from the command line (defaulting to 127.0.0.1:8080), creates a TCP socket, connects, sends "PING\n", prints whatever the server sends back until the server closes the connection, then closes its own socket.
How to try it. In one terminal start a throwaway server: nc -l 8080 (or nc -l -p 8080 on some systems). Compile the client with cc -Wall -Wextra -o client client.c and run ./client. Type a line in the nc window and it appears in the client; close nc and the client prints all it received and exits.
Expected output (server sends hi there then closes):
connected to 127.0.0.1:8080
hi there
Edge cases. If no server is listening you get connect: Connection refused and a non-zero exit. A malformed IP argument prints bad IP: .... A large reply is handled correctly because the recv() loop runs until EOF.
send_all helper — sockets can accept fewer bytes than you offer, so this loop keeps calling send() from the offset buf + sent until every byte is out. EINTR (a signal interrupted the call) is retried rather than treated as an error.ip and port default to the loopback address and 8080 if not supplied, so the program is safe to run with no arguments.socket(AF_INET, SOCK_STREAM, 0) — asks for an IPv4 TCP socket; fd is now a live descriptor. The fd < 0 check catches allocation failures.addr — = {0} clears every field including sin_zero. sin_family marks it IPv4; htons(port) converts the port to network byte order; inet_pton writes the binary form of the IP into sin_addr.inet_pton return check — 0 means the string was not a valid IPv4 address, -1 means an unsupported family. Both are handled before we rely on sin_addr.connect(...) — casts &addr to the generic struct sockaddr * and passes sizeof addr. This blocks through the SYN / SYN-ACK / ACK handshake. On failure perror prints the specific reason (commonly Connection refused).send_all(fd, "PING\n", 5) — pushes the request; the helper guarantees all 5 bytes leave.recv loop — reads up to 511 bytes at a time (one byte reserved for the terminator). n == 0 means the peer closed its side, so we break. Otherwise we write '\0' at buf[n] and print the chunk as text.close(fd) — returns the descriptor to the OS and sends a FIN to the server.A small trace, assuming the server replies "OK" then closes:
| Call | Return | State after |
|---|---|---|
socket(...) |
3 |
fd 3 is an unconnected TCP socket |
inet_pton |
1 |
sin_addr = binary 127.0.0.1 |
connect |
0 |
fd 3 connected to server |
send_all |
0 |
5 bytes PING\n sent |
recv |
2 |
buf = "OK", printed |
recv |
0 |
EOF, loop breaks |
close |
0 |
fd 3 released, FIN sent |
1. Forgetting htons() on the port.
addr.sin_port = 8080; // WRONG on little-endian machines
Why it is wrong: the bytes are stored in host order, so the wire sees a different port number and the connection goes nowhere (or somewhere unexpected). Fix:
addr.sin_port = htons(8080); // correct: network byte order
Recognise it: connect fails with Connection refused even though your server is clearly running on 8080. Always pass the port through htons().
2. Not initializing struct sockaddr_in.
struct sockaddr_in addr; // WRONG: sin_zero and fields are garbage
addr.sin_family = AF_INET;
Why it is wrong: leftover stack bytes can corrupt the address. Fix: struct sockaddr_in addr = {0}; (or memset(&addr, 0, sizeof addr);).
3. Assuming one recv() returns the whole message.
recv(fd, buf, sizeof buf, 0); // WRONG: may return a partial message
printf("%s", buf); // and buf may not be NUL-terminated
Why it is wrong: TCP has no message boundaries, so you can get part of a message, and buf is not a string. Fix: loop until you have a full message per your protocol, and terminate the buffer before printing.
4. Ignoring return values. Calling connect(), send(), or recv() without checking the result hides ECONNREFUSED, short writes, and EOF. Check every one.
5. Treating recv() == 0 as an error. A return of 0 is a normal EOF, not a failure — the peer closed the connection. Only -1 is an error.
Compiler errors
implicit declaration of function 'socket' / 'htons' / 'inet_pton' → a missing header. Include <sys/socket.h>, <netinet/in.h>, <arpa/inet.h>, and <unistd.h>.incompatible pointer type at connect → you forgot the cast (struct sockaddr *)&addr.Runtime errors (read them with perror / errno)
Connection refused (ECONNREFUSED) → nothing is listening on that host and port. Start your server; check the port; check htons().Connection timed out (ETIMEDOUT) or No route to host → wrong address, host down, or a firewall dropping packets.Address family not supported from inet_pton (returns -1) → wrong family constant.inet_pton returns 0 → the IP string is malformed (a typo, or you passed a hostname like "example.com" — use getaddrinfo() for names).Logic errors
\n.recv(); loop until EOF or until you have the whole message, and NUL-terminate before printing.Concrete steps
nc -l 8080. If the client works there, the bug is in the remote peer or the address.tcpdump -i lo port 8080 or Wireshark to see whether the SYN even leaves and whether the port is what you expect.errno right after a failing call.Questions to ask when it will not work: Is a server actually listening on that exact host and port? Did I htons() the port? Did I check inet_pton's return? Am I looping on recv()? Am I confusing 0 (EOF) with an error?
TCP data is untrusted input — the peer can send any bytes, in any sizes, at any time. Treat every read defensively.
recv() that leaves room for a terminator: recv(fd, buf, sizeof buf - 1, 0), then buf[n] = '\0'. Never let received length drive an unchecked copy into a smaller buffer — that is a classic buffer overflow.'\0', or embedded '\0's. Add your own terminator based on the returned length before using string functions.struct sockaddr_in fields (including sin_zero) are undefined behaviour when read; use = {0}.ssize_t handling. recv/send return ssize_t; store the result in a signed type and check < 0 before casting to size_t, or a -1 becomes a huge unsigned number.uint16_t so you do not silently wrap.socket() must be balanced by close() on all exit paths — including error branches — or you leak descriptors. In this lesson's code each early return closes fd first.127.0.0.1 for practice. Do not build port scanners or connect to third-party services without authorization — that can be illegal and is out of scope for learning here.Concrete uses. The TCP client pattern is the engine inside: web browsers and curl (HTTP/HTTPS clients), email clients (SMTP/IMAP), SSH clients, database drivers (PostgreSQL, MySQL, Redis wire protocols), message-queue consumers, monitoring agents that ship metrics to a collector, and embedded devices that phone home to a cloud endpoint. TLS libraries such as OpenSSL sit directly on top of a connected TCP socket.
Beginner best practices
perror so you see the real reason.htons() the port.send() and recv(); never assume one call is enough.Advanced best practices
getaddrinfo() instead of inet_pton() so your client works with hostnames and both IPv4 and IPv6.select/poll, or SO_RCVTIMEO) so a dead peer cannot hang you forever.EINTR and, for non-blocking sockets, EAGAIN/EWOULDBLOCK.TCP_NODELAY for latency-sensitive request/response traffic, and layer TLS for anything crossing an untrusted network.Beginner 1 — Echo one line.
Objective: connect to 127.0.0.1:8080, send a single line you read from stdin, print the reply, and exit. Requirements: use socket, htons, inet_pton, connect, send, recv, close; check each return value. Test against nc -l 8080. Hint: NUL-terminate the received bytes before printing. Concepts: the four core calls, byte order.
Beginner 2 — Command-line endpoint.
Objective: accept the IP and port as argv[1] and argv[2] (default to 127.0.0.1 and 8080). Requirements: validate the port is 0–65535 and that inet_pton returns 1; print a clear error and exit non-zero otherwise. Example: ./client 127.0.0.1 9000. Hint: reject a hostname gracefully (mention getaddrinfo in a comment). Concepts: address setup, input validation.
Intermediate 1 — Robust recv loop.
Objective: read the server's reply until EOF and report the total number of bytes received. Requirements: loop on recv, accumulate the count, handle n == 0 (EOF) and n < 0 with EINTR retry. Input/output: after the server closes, print received N bytes. Constraint: fixed 512-byte buffer, no unbounded growth. Hint: distinguish 0 from -1. Concepts: short reads, EOF vs error.
Intermediate 2 — Reliable send_all.
Objective: send a message larger than the socket buffer (e.g. 1 MB of 'A') and confirm every byte is delivered. Requirements: implement a send_all that loops over short writes and retries EINTR; have the server count bytes with wc -c (nc -l 8080 | wc -c). Constraint: no single send call assumed complete. Hint: advance an offset. Concepts: short writes, looping I/O.
Challenge — Minimal HTTP GET.
Objective: connect to a local test server on port 8080 and send a raw HTTP/1.0 request, then print the response. Requirements: send exactly GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n, read the full response with a recv loop until EOF, and print it. Constraints: use HTTP/1.0 so the server closes after responding (simpler framing); run only against a server you started locally (e.g. python3 -m http.server 8080). Hint: the blank line \r\n\r\n ends the request headers. Concepts: everything above plus a real, delimited protocol. Do not connect to third-party websites.
A TCP client follows one reliable recipe: socket() → fill a struct sockaddr_in → connect() → send()/recv() in a loop → close(). Describe the destination with sin_family = AF_INET, sin_port = htons(port), and sin_addr via inet_pton() — and zero the struct first. The most important syntax is the cast connect(fd, (struct sockaddr *)&addr, sizeof addr) and the four required headers (sys/socket.h, netinet/in.h, arpa/inet.h, unistd.h).
The mistakes that bite everyone: forgetting htons(), not initializing the address struct, assuming one recv() returns a whole message, treating recv() == 0 as an error instead of EOF, and ignoring return values. Remember that TCP is an unframed byte stream, so you must loop on both send and receive, and that incoming bytes are untrusted — bound your buffers and terminate before treating them as text. Practice against 127.0.0.1 and never connect to hosts you do not own. Master this and you understand the foundation beneath HTTP, TLS, SSH, and nearly every networked program.