C socket programming TCP UDP client server network apps guide

C Socket Programming: Build Your Own Network Apps From Scratch in 2026

Back to C RoadmapC Programming Course • 50 Lessons

Table of Contents

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_REUSEADDR to 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 SIGPIPE or use MSG_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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *