C Socket Programming: Build Your Own Network Apps From Scratch in 2026
Table of Contents
- What Are Sockets?
- TCP vs UDP: Choosing Your Protocol
- The Socket API Functions
- Network Byte Order
- The sockaddr_in Structure
- Building a TCP Server
- Building a TCP Client
- UDP Server and Client
- Handling Multiple Clients
- Error Handling in Network Programming
- Practical Project: Multi-Client Chat Server
- Summary
Every time you open a website, send a message, or stream a video, sockets are doing the heavy lifting underneath. They are the fundamental interface between your program and the network — and learning to use them in C gives you direct, unfiltered control over how data moves between machines. This is where programming stops being theoretical and starts feeling powerful.
In this lesson, you’ll go from zero socket knowledge to building a working multi-client chat server. We’ll cover both TCP and UDP, walk through every core API function, and handle the real-world problems that trip up most beginners. If you’ve been following along with C Structs and File I/O, you already have the foundations. Sockets are just file descriptors on steroids.
What Are Sockets?
A socket is an endpoint for communication. Think of it like a phone — one side dials (the client), the other side picks up (the server), and once connected, both sides can talk. In C, sockets are represented as file descriptors, which means you can use many of the same concepts you learned in File I/O.
Sockets operate at the transport layer of the network stack. Your program creates a socket, binds it to an address and port, and then either listens for incoming connections or connects to a remote host. The operating system handles the low-level packet routing — you just read and write data.
The socket API was originally developed for BSD Unix in the early 1980s, and it has become the universal standard for network programming across virtually every operating system. The Linux socket(2) man page documents the system call interface we’ll be using throughout this lesson.
TCP vs UDP: Choosing Your Protocol
Before writing a single line of socket code, you need to choose between the two main transport protocols. This decision shapes everything that follows.
| Feature | TCP (SOCK_STREAM) | UDP (SOCK_DGRAM) |
|---|---|---|
| Connection | Connection-oriented (3-way handshake) | Connectionless |
| Reliability | Guaranteed delivery, in-order | No guarantees, may arrive out of order |
| Speed | Slower due to overhead | Faster, minimal overhead |
| Data Boundary | Byte stream (no message boundaries) | Preserves message boundaries |
| Use Cases | Web, email, file transfer, chat | Gaming, DNS, video streaming, VoIP |
Rule of thumb: If you need every byte to arrive in order and can’t afford data loss, use TCP. If speed matters more than perfection (like real-time gaming or live video), use UDP. For this lesson, we’ll build with both, starting with TCP since it’s what most applications need.
The Socket API Functions
The POSIX socket API gives you a small set of functions that combine to create any network application. Here’s the complete lifecycle:
socket() — Create the Endpoint
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
// domain: AF_INET (IPv4) or AF_INET6 (IPv6)
// type: SOCK_STREAM (TCP) or SOCK_DGRAM (UDP)
// protocol: 0 (auto-select based on type)
// Returns: file descriptor on success, -1 on error
bind() — Assign an Address
Tells the OS which IP address and port your socket should listen on. Servers always bind; clients usually don’t need to.
listen() — Mark as Passive (TCP only)
Converts the socket into a listening socket that accepts incoming connections. The second argument is the backlog — how many pending connections the kernel will queue.
accept() — Accept a Connection (TCP only)
Blocks until a client connects, then returns a new file descriptor for that specific connection. The original socket keeps listening for more clients.
connect() — Initiate a Connection
Used by clients to reach a server. For TCP, this triggers the three-way handshake. For UDP, it simply sets a default destination.
send() / recv() — Transfer Data
Send and receive data on a connected socket. These work like write() and read() but with extra flags for network-specific behavior.
close() — Shut It Down
Closes the socket file descriptor and releases resources. Always close your sockets — leaked file descriptors are a common source of server crashes.
The flow looks different for servers and clients. Here’s the pattern:
// TCP Server Flow: TCP Client Flow:
// socket() socket()
// bind() connect()
// listen() send() / recv()
// accept() close()
// send() / recv()
// close()
Network Byte Order
Different CPUs store multi-byte numbers differently. Intel processors use little-endian (least significant byte first), while network protocols use big-endian (most significant byte first). If you don’t convert, a port number like 8080 will look like garbage to the other side.
C provides four conversion functions defined in <arpa/inet.h>:
| Function | Purpose | Use For |
|---|---|---|
htons() |
Host to Network Short (16-bit) | Port numbers |
ntohs() |
Network to Host Short (16-bit) | Reading port numbers |
htonl() |
Host to Network Long (32-bit) | IP addresses |
ntohl() |
Network to Host Long (32-bit) | Reading IP addresses |
Golden rule: Always use these functions when setting port numbers and IP addresses in socket structures. Even if your machine happens to be big-endian, the conversion functions are no-ops on those platforms — so your code stays portable. The RFC 1700 defines big-endian as the official network byte order.
The sockaddr_in Structure
Every socket function that deals with addresses uses the sockaddr family of structs. For IPv4, you’ll use sockaddr_in:
#include <netinet/in.h>
struct sockaddr_in {
sa_family_t sin_family; // Always AF_INET for IPv4
in_port_t sin_port; // Port number (network byte order!)
struct in_addr sin_addr; // IP address
};
struct in_addr {
uint32_t s_addr; // IP address (network byte order!)
};
// Example setup:
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080); // Port 8080
server_addr.sin_addr.s_addr = INADDR_ANY; // Bind to all interfaces
// Or for a specific IP:
// inet_pton(AF_INET, "192.168.1.10", &server_addr.sin_addr);
Use inet_pton() to convert a human-readable IP string to binary form, and inet_ntop() to convert back. The older inet_addr() and inet_ntoa() functions still work but don’t support IPv6.
Building a TCP Server
Let’s build a complete TCP echo server — it receives data from a client and sends it right back. This is the “Hello World” of network programming:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main(void) {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// Step 1: Create socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// Allow port reuse (prevents "Address already in use" errors)
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// Step 2: Bind to address
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind failed");
close(server_fd);
exit(EXIT_FAILURE);
}
// Step 3: Listen for connections
if (listen(server_fd, 5) == -1) {
perror("listen failed");
close(server_fd);
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// Step 4: Accept a client connection
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd == -1) {
perror("accept failed");
close(server_fd);
exit(EXIT_FAILURE);
}
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("Client connected from %s:%d\n", client_ip, ntohs(client_addr.sin_port));
// Step 5: Echo loop
ssize_t bytes_received;
while ((bytes_received = recv(client_fd, buffer, BUFFER_SIZE - 1, 0)) > 0) {
buffer[bytes_received] = '\0';
printf("Received: %s", buffer);
send(client_fd, buffer, bytes_received, 0); // Echo back
}
printf("Client disconnected.\n");
close(client_fd);
close(server_fd);
return 0;
}
A few critical details to notice: the SO_REUSEADDR option prevents the annoying “Address already in use” error when restarting your server. The cast to (struct sockaddr *) is necessary because bind() and accept() use the generic sockaddr type. And we always null-terminate the buffer after recv() because network data has no trailing null.
Building a TCP Client
The client is simpler — no binding or listening, just connect and communicate:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main(void) {
int sock_fd;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
// Create socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd == -1) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// Set up server address
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(PORT);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
// Connect to server
if (connect(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connect failed");
close(sock_fd);
exit(EXIT_FAILURE);
}
printf("Connected to server!\n");
// Send and receive loop
while (fgets(buffer, BUFFER_SIZE, stdin) != NULL) {
send(sock_fd, buffer, strlen(buffer), 0);
ssize_t bytes = recv(sock_fd, buffer, BUFFER_SIZE - 1, 0);
if (bytes <= 0) break;
buffer[bytes] = '\0';
printf("Echo: %s", buffer);
}
close(sock_fd);
return 0;
}
To test, compile both programs, run the server in one terminal and the client in another. Everything you type in the client will bounce back from the server. Compile with: gcc -o server server.c and gcc -o client client.c.
UDP Server and Client
UDP is connectionless — no handshake, no listen(), no accept(). You just send and receive individual datagrams using sendto() and recvfrom():
// UDP Echo Server
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#define PORT 9090
#define BUFFER_SIZE 1024
int main(void) {
int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // SOCK_DGRAM for UDP
if (sock_fd == -1) { perror("socket"); exit(1); }
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind"); close(sock_fd); exit(1);
}
printf("UDP server listening on port %d...\n", PORT);
while (1) {
ssize_t n = recvfrom(sock_fd, buffer, BUFFER_SIZE - 1, 0,
(struct sockaddr *)&client_addr, &client_len);
if (n == -1) { perror("recvfrom"); continue; }
buffer[n] = '\0';
printf("Received: %s", buffer);
// Echo back to the same client
sendto(sock_fd, buffer, n, 0,
(struct sockaddr *)&client_addr, client_len);
}
close(sock_fd);
return 0;
}
The key difference: UDP uses recvfrom() and sendto() because each datagram might come from a different client. There’s no persistent connection, so the server extracts the sender’s address from every received packet and uses it to reply. Refer to the sendto(2) man page for the full function signature and flag options.
Handling Multiple Clients
Our TCP server above can only handle one client at a time. That’s useless for anything real. There are three main approaches to handling multiple clients simultaneously.
Approach 1: fork() — One Process Per Client
The classic Unix approach. Each time accept() returns a new client, fork a child process to handle it. This ties into what you learned about concurrency and process management:
while (1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd == -1) { perror("accept"); continue; }
pid_t pid = fork();
if (pid == 0) {
// Child process: handle this client
close(server_fd); // Child doesn't need the listener
handle_client(client_fd);
close(client_fd);
exit(0);
} else if (pid > 0) {
// Parent: go back to accepting
close(client_fd); // Parent doesn't need the client socket
} else {
perror("fork");
}
}
Simple and effective, but each process consumes memory. For thousands of clients, this doesn’t scale.
Approach 2: select() — I/O Multiplexing
Instead of one process per client, select() lets a single process monitor multiple file descriptors at once. It blocks until one or more sockets have data ready:
fd_set master_set, read_set;
FD_ZERO(&master_set);
FD_SET(server_fd, &master_set);
int max_fd = server_fd;
while (1) {
read_set = master_set; // select() modifies the set, so copy it
if (select(max_fd + 1, &read_set, NULL, NULL, NULL) == -1) {
perror("select");
break;
}
for (int fd = 0; fd <= max_fd; fd++) {
if (!FD_ISSET(fd, &read_set)) continue;
if (fd == server_fd) {
// New connection incoming
int new_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
FD_SET(new_fd, &master_set);
if (new_fd > max_fd) max_fd = new_fd;
printf("New client connected (fd=%d)\n", new_fd);
} else {
// Data from existing client
ssize_t n = recv(fd, buffer, BUFFER_SIZE - 1, 0);
if (n <= 0) {
close(fd);
FD_CLR(fd, &master_set);
printf("Client disconnected (fd=%d)\n", fd);
} else {
buffer[n] = '\0';
// Broadcast or process the message
}
}
}
}
The select() approach is portable and works everywhere, but it has a limit of FD_SETSIZE (typically 1024) file descriptors. For higher-scale servers, poll() or Linux’s epoll are better choices. The select(2) man page covers the details and limitations.
Approach 3: poll() — Better Scaling
poll() removes the FD_SETSIZE limit and uses a cleaner interface with an array of struct pollfd. For production servers on Linux, epoll is the ultimate weapon — it handles millions of connections using an event-driven model.
Error Handling in Network Programming
Network code fails in ways that local programs don’t. Connections drop, packets get lost, DNS lookups time out. Robust error handling isn’t optional — it’s survival.
Every socket function that can fail returns -1 and sets errno. Here are the errors you’ll hit most often:
- ECONNREFUSED — No one is listening on the target port. The server isn’t running or the port is wrong.
- EADDRINUSE — The port is already taken. Use
SO_REUSEADDRto fix this. - ETIMEDOUT — The connection attempt timed out. The server might be behind a firewall.
- ECONNRESET — The remote side abruptly closed the connection.
- EPIPE — You tried to write to a broken connection. Handle
SIGPIPEor useMSG_NOSIGNAL. - EINTR — A signal interrupted the system call. Retry the operation.
A defensive pattern for recv() that handles partial reads and interrupts:
ssize_t recv_all(int fd, void *buf, size_t len) {
size_t total = 0;
char *ptr = (char *)buf;
while (total < len) {
ssize_t n = recv(fd, ptr + total, len - total, 0);
if (n == -1) {
if (errno == EINTR) continue; // Interrupted, retry
return -1; // Real error
}
if (n == 0) return total; // Connection closed
total += n;
}
return total;
}
This pattern accounts for the fact that recv() might return fewer bytes than you asked for — a critical reality of TCP streaming that many beginners miss. TCP is a byte stream, not a message stream, so you must handle partial reads yourself. The same applies to send() — always check the return value and send remaining bytes in a loop.
Practical Project: Multi-Client Chat Server
Let’s combine everything into a working chat server where multiple clients can send messages that get broadcast to everyone else. This uses select() for I/O multiplexing and proper dynamic memory management:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#define PORT 4444
#define MAX_CLIENTS 100
#define BUFFER_SIZE 2048
int clients[MAX_CLIENTS];
int client_count = 0;
void broadcast(int sender_fd, const char *msg, int len) {
for (int i = 0; i < client_count; i++) {
if (clients[i] != sender_fd) {
send(clients[i], msg, len, 0);
}
}
}
void remove_client(int fd) {
for (int i = 0; i < client_count; i++) {
if (clients[i] == fd) {
clients[i] = clients[client_count - 1];
client_count--;
break;
}
}
close(fd);
}
int main(void) {
int server_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
fd_set read_fds;
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) { perror("socket"); exit(1); }
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind"); exit(1);
}
if (listen(server_fd, 10) == -1) { perror("listen"); exit(1); }
printf("Chat server running on port %d\n", PORT);
printf("Waiting for connections...\n");
while (1) {
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
int max_fd = server_fd;
for (int i = 0; i < client_count; i++) {
FD_SET(clients[i], &read_fds);
if (clients[i] > max_fd) max_fd = clients[i];
}
if (select(max_fd + 1, &read_fds, NULL, NULL, NULL) == -1) {
perror("select");
break;
}
// Check for new connections
if (FD_ISSET(server_fd, &read_fds)) {
int new_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
if (new_fd == -1) { perror("accept"); continue; }
if (client_count >= MAX_CLIENTS) {
printf("Max clients reached. Rejecting connection.\n");
close(new_fd);
continue;
}
clients[client_count++] = new_fd;
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
printf("[+] %s:%d connected (fd=%d, total=%d)\n",
client_ip, ntohs(client_addr.sin_port), new_fd, client_count);
char welcome[] = "Welcome to the chat! Type your messages:\n";
send(new_fd, welcome, strlen(welcome), 0);
snprintf(buffer, BUFFER_SIZE, "[Server] A new user joined! (%d online)\n", client_count);
broadcast(new_fd, buffer, strlen(buffer));
}
// Check existing clients for data
for (int i = 0; i < client_count; i++) {
if (FD_ISSET(clients[i], &read_fds)) {
ssize_t n = recv(clients[i], buffer, BUFFER_SIZE - 1, 0);
if (n <= 0) {
printf("[-] Client fd=%d disconnected\n", clients[i]);
int fd = clients[i];
remove_client(fd);
snprintf(buffer, BUFFER_SIZE,
"[Server] A user left. (%d online)\n", client_count);
broadcast(-1, buffer, strlen(buffer));
i--; // Adjust index after removal
} else {
buffer[n] = '\0';
printf("[fd=%d] %s", clients[i], buffer);
// Prefix message with sender info and broadcast
char msg[BUFFER_SIZE + 32];
snprintf(msg, sizeof(msg), "[User-%d] %s", clients[i], buffer);
broadcast(clients[i], msg, strlen(msg));
}
}
}
}
for (int i = 0; i < client_count; i++) close(clients[i]);
close(server_fd);
return 0;
}
To test this chat server, compile it and run it in one terminal. Then open multiple terminals and connect with nc localhost 4444 (netcat acts as a simple TCP client). Type in any connected terminal and watch the message appear in all the others. This is a real, working chat system built from scratch.
For more on building production-grade servers, Beej’s Guide to Network Programming is the most widely recommended resource in the C community — it covers everything from basic sockets to advanced techniques with humor and clarity.
Summary
Socket programming is where C flexes its true strength — direct access to the operating system’s networking capabilities with zero abstraction overhead. Here’s what you’ve mastered in this lesson:
- Sockets are file descriptors that enable network communication between processes
- TCP provides reliable, ordered byte streams; UDP provides fast, connectionless datagrams
- The socket API lifecycle:
socket()→bind()→listen()→accept()→recv()/send()→close() - Network byte order functions (
htons,htonl, etc.) are mandatory for portable code - select() enables a single process to handle hundreds of simultaneous connections
- Network error handling requires checking every return value and handling partial reads/writes
You’ve built three complete programs: a TCP echo server, a TCP client, and a multi-client chat server. These patterns form the foundation for web servers, APIs, game servers, and any application that communicates over a network.
For deeper exploration, read the RFC 793 (TCP specification) and the RFC 768 (UDP specification) to understand exactly what happens at the protocol level. The Berkeley sockets Wikipedia article provides excellent historical context on how this API evolved.
In the next lessons, we’ll build on these networking fundamentals as we explore more advanced system programming topics. The ability to write network code in C is one of the most valuable skills you can have — every high-performance server, from Nginx to Redis, is built on exactly the concepts you just learned.