Networking in C · advanced · ~12 min

TCP client

**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.

Overview

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.

Why it matters

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.

Core concepts

1. The socket file descriptor

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?

2. Describing the destination: struct sockaddr_in

Definition. 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.

3. Byte order: 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?

4. Establishing the connection: 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.

5. Moving bytes: send() / recv() and short transfers

Definition. 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 one recv() on the client?

Syntax notes

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.

Lesson

The four core calls

A TCP client follows the same basic sequence every time:

  1. socket() — create the socket (the endpoint you will communicate through).
  2. connect() — open a connection to the server.
  3. send() / recv() (or write() / read()) — exchange bytes.
  4. close() — shut the socket down when you are done.

Building the destination address

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:

  • family — set sin_family to AF_INET (IPv4).
  • port — set sin_port using htons(), which converts the port number to network byte order (the byte ordering used on the wire).
  • address — set sin_addr using inet_pton(), which parses a text IP address (such as "127.0.0.1") into binary form.

Practice safely

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.

Code examples

#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.

Line by line

  1. 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.
  2. Argument parsingip and port default to the loopback address and 8080 if not supplied, so the program is safe to run with no arguments.
  3. 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.
  4. Zeroing and filling 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.
  5. inet_pton return check0 means the string was not a valid IPv4 address, -1 means an unsupported family. Both are handled before we rely on sin_addr.
  6. 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).
  7. send_all(fd, "PING\n", 5) — pushes the request; the helper guarantees all 5 bytes leave.
  8. 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.
  9. 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

Common mistakes

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.

Debugging tips

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

  • Nothing prints even though you connected → you sent a request the server does not answer, or the server waits for a full line and you never sent a \n.
  • Output is truncated or garbled → you assumed one recv(); loop until EOF or until you have the whole message, and NUL-terminate before printing.

Concrete steps

  1. Reproduce against a simple local server: nc -l 8080. If the client works there, the bug is in the remote peer or the address.
  2. Print the fd and every return value while debugging.
  3. Watch the actual bytes with tcpdump -i lo port 8080 or Wireshark to see whether the SYN even leaves and whether the port is what you expect.
  4. Run under a debugger and inspect 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?

Memory safety

TCP data is untrusted input — the peer can send any bytes, in any sizes, at any time. Treat every read defensively.

  • Bounds on receive. Always pass a capacity to 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.
  • Do not treat received bytes as a C string automatically. They may contain no '\0', or embedded '\0's. Add your own terminator based on the returned length before using string functions.
  • Initialize the address struct. Uninitialized struct sockaddr_in fields (including sin_zero) are undefined behaviour when read; use = {0}.
  • Signed/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.
  • Integer/port range. A port must fit in 16 bits (0–65535). Validate any user-supplied port before casting to uint16_t so you do not silently wrap.
  • Resource lifetime. Every successful 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.
  • Defensive practice. Connect only to hosts you own or have explicit permission to test. Use the loopback address 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.

Real-world uses

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

  • Check every return value; print failures with perror so you see the real reason.
  • Zero-initialize address structs; always htons() the port.
  • Loop your send() and recv(); never assume one call is enough.
  • Close the socket on every exit path.
  • Practice only against loopback or servers you control.

Advanced best practices

  • Use getaddrinfo() instead of inet_pton() so your client works with hostnames and both IPv4 and IPv6.
  • Set connect/read timeouts (non-blocking sockets + select/poll, or SO_RCVTIMEO) so a dead peer cannot hang you forever.
  • Handle EINTR and, for non-blocking sockets, EAGAIN/EWOULDBLOCK.
  • Define an explicit application protocol (length-prefixed or delimited messages) so framing is unambiguous.
  • Consider TCP_NODELAY for latency-sensitive request/response traffic, and layer TLS for anything crossing an untrusted network.

Practice tasks

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.

Summary

A TCP client follows one reliable recipe: socket() → fill a struct sockaddr_inconnect()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.

Practice with these exercises