Introduction
Talking to servers or chat apps exchanging messages — at the core of this communication lies the C socket, a powerful abstraction that lets two programs send and receive data across a network as if they were reading and writing to files.
In this guide, we’ll roll up our sleeves and build a reliable server application in C using sockets. Instead of diving into abstract theory, we’ll take a hands-on approach: writing code step by step, explaining how each piece works, and making sure the server is robust enough for real-world use.
By the end, you’ll understand:
- What sockets are and why they’re essential for network programming.
- How to create and configure sockets in C.
- The exact steps of building a server that binds to an address, listens for connections, and accepts clients.
- How to handle errors gracefully so your server doesn’t crash under pressure.
<<Note>> In this post, we’ll focus only on building the server. In the next post, we’ll build the client application that connects to it and exchanges messages, completing the picture of client–server communication.
Whether you’re a student learning systems programming, a developer curious about how the internet works under the hood, or someone preparing for advanced projects, this tutorial will give you the confidence to write your own server from scratch.
Understanding C Sockets
What is a C Socket?
A socket gives your program a direct line to another program over a network. Picture it as a digital plug: the server plugs in on one side, the client plugs in on the other, and together they create a channel for two-way communication. In C, a socket is just a file descriptor—an integer—but once you open it, the operating system handles the heavy lifting of moving your data across the network.
How C Sockets Drive Client–Server Communication
Here’s how sockets power the client–server model:
- Server opens a socket, binds it to an IP address and port, and starts listening for connections.
- Client opens its own socket and actively connects to the server.
- Both sides send and receive data through simple function calls like
send()andrecv(). - Either side closes the socket when the conversation ends, freeing resources.
With this flow, sockets transform abstract network connections into a straightforward programming model that feels as natural as reading and writing files.
TCP vs UDP — and Why We Choose TCP
You can build sockets on two major protocols:
- TCP (Transmission Control Protocol)
- Guarantees delivery, order, and integrity.
- Requires a connection handshake before sending data.
- Moves slower than UDP, but keeps your data safe and consistent.
- Best for apps where accuracy matters—chat systems, APIs, file transfer.
- UDP (User Datagram Protocol)
- Fires off packets without any guarantee they arrive.
- Skips the handshake, so it runs faster but less reliably.
- Works well for apps where speed beats reliability—gaming, video streaming, real-time telemetry.
In this tutorial, we’ll build on TCP. It ensures every byte you send reaches the other side in the correct order, so you can focus on coding the communication logic instead of patching over lost or out-of-order messages.
Setting Up the Server
A server goes through four key steps before it can talk to a client:
- Create a socket
- Bind it to an IP and port
- Listen for connections
- Accept a connection
- Read data from socket
Let’s walk through each one.
Create a C Socket
The socket() function creates the endpoint. We’ll use IPv4 (AF_INET) and TCP (SOCK_STREAM).
// -----------------------------
// Step 1: Create a socket
// -----------------------------
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return EXIT_FAILURE;
}
printf("Server socket created successfully.\n");
Bind to an IP Address and Port
Next, attach the socket to an address. This address could be the IPv4 address assigned to one of your Network Interface Cards (NIC) or simply use INADDR_ANY to bind the socket to all of the NICs on the machine where the server is running. This means the server would serve client connections coming from any of the NICs (Wifi, ethernet or more).
This process also claim a port number, which is a special service number that represents the server. The client must connect with the same matching port number to access this server.
// -----------------------------
// Step 2: Bind to IP/port
// -----------------------------
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
int opt = 1;
// Allow quick restart on same port
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0) {
perror("setsockopt failed");
close(server_fd);
return EXIT_FAILURE;
}
address.sin_family = AF_INET; // IPv4
address.sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces
address.sin_port = htons(8080); // Port 8080
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server bound to port 8080.\n");
Listen for Incoming Connections
Tell the socket to listen, which basically means to listen for any client connections. At this point the server is up and ready to serve. The argument 3 is the backlog (max number of pending connections). Higher backlog could allow more concurrent and frequent clients to connect.
// -----------------------------
// Step 3: Listen for connections
// -----------------------------
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server listening...\n");
Accept a Connection
A call to accept() attempts to establish a connection with the connecting client. When successful, accept() will assign a new socket identifier that the server can use in data read and write. See next.
<WARNING> The server_fd socket identifier that we used previously on listen() cannot be used toe communicate with the client. We shall use the one returned by accpet() to communicate with the client.
// -----------------------------
// Step 4: Accept a connection
// -----------------------------
socklen_t addrlen = sizeof(address);
int new_socket = accept(server_fd, (struct sockaddr *)&address, &addrlen);
if (new_socket < 0) {
perror("accept failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Connection accepted from client!\n");
Read Data From A C Socket
Once the server has accepted the connection and gotten a accept socket, the application is now able to read any data the client has sent via this socket. The message here could be a string, binary data, encrypted data, or encoded message according to a protocol. It does not matter as long as the server understands how to handle it… (parse, decode, decrypt…etc).
To achieve this, we have to declare a buffer space to hold the data the client has sent, normally as char [].
// -----------------------------
// Step 5: Read data from socket
// -----------------------------
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t valread = read(new_socket, buffer, sizeof(buffer) - 1); // leave room for '\0'
if (valread < 0) {
perror("read failed");
// fall through to cleanup
} else if (valread == 0) {
printf("Client closed connection (no data).\n");
} else {
buffer[valread] = '\0';
printf("Message from client: %s\n", buffer);
}
Send a Response to Client
After processing the client requests, the server an send a response back to the client also via the same accept socket. The response can also be a string, binary data, encrypted data, or encoded message as long as the client understands it.
// -----------------------------
// Step 6: Send a response to client
// -----------------------------
const char *response = "Hello from server!\n";
ssize_t to_send = (ssize_t)strlen(response);
ssize_t sent = 0;
while (sent < to_send) {
ssize_t n = write(new_socket, response + sent, (size_t)(to_send - sent));
if (n < 0) {
// handle EINTR/EAGAIN gracefully
if (errno == EINTR) continue;
perror("write failed");
break;
}
sent += n;
}
if (sent > 0) {
printf("Sent response to client.\n");
}
Put it All Together
By Combining all of the steps above, we will have a very basic server application based on C socket. The code will look something like this. (Click to expand).
server.c
// server.c
// Build: gcc -Wall -Wextra -O2 -o server server.c
// Run: ./server
// Test: In another terminal -> echo "hi" | nc 127.0.0.1 8080
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
int main(void) {
// -----------------------------
// Step 1: Create a socket
// -----------------------------
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket failed");
return EXIT_FAILURE;
}
printf("Server socket created successfully.\n");
// -----------------------------
// Step 2: Bind to IP/port
// -----------------------------
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
int opt = 1;
// Allow quick restart on same port
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0) {
perror("setsockopt failed");
close(server_fd);
return EXIT_FAILURE;
}
address.sin_family = AF_INET; // IPv4
address.sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces
address.sin_port = htons(8080); // Port 8080
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server bound to port 8080.\n");
// -----------------------------
// Step 3: Listen for connections
// -----------------------------
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server listening...\n");
// -----------------------------
// Step 4: Accept a connection
// -----------------------------
socklen_t addrlen = sizeof(address);
int new_socket = accept(server_fd, (struct sockaddr *)&address, &addrlen);
if (new_socket < 0) {
perror("accept failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Connection accepted from client!\n");
// -----------------------------
// Step 5: Read data from socket
// -----------------------------
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
ssize_t valread = read(new_socket, buffer, sizeof(buffer) - 1); // leave room for '\0'
if (valread < 0) {
perror("read failed");
// fall through to cleanup
} else if (valread == 0) {
printf("Client closed connection (no data).\n");
} else {
buffer[valread] = '\0';
printf("Message from client: %s\n", buffer);
}
// -----------------------------
// Step 6: Send a response to client
// -----------------------------
const char *response = "Hello from server!\n";
ssize_t to_send = (ssize_t)strlen(response);
ssize_t sent = 0;
while (sent < to_send) {
ssize_t n = write(new_socket, response + sent, (size_t)(to_send - sent));
if (n < 0) {
// handle EINTR/EAGAIN gracefully
if (errno == EINTR) continue;
perror("write failed");
break;
}
sent += n;
}
if (sent > 0) {
printf("Sent response to client.\n");
}
// Cleanup
close(new_socket);
close(server_fd);
return 0;
}
Let’s Make it Robust and Production Ready!
We have built a simple server application that is able to communicate with a client application via C socket, but it is no where near production ready. For example:
- this server is not able to server more than one client connection and it will just exit after serving its first and only client.
- no timeout mechanism
- blocking socket makes
accept()to wait indefinitely for client to connect, causing the server application not able to do anything before a client connects. - connection detection is absent, so the server is unaware whether the client is still there.
- server reads client request and responds only once, not continuously.
How to improve?
- Use non-blocking socket.
- puts
accept()logic in a loop to be able to server multiple clients. - spawns a thread to process a request upon client connect
- sets a maximum number of client connections.
- calls
select()on a socket with timeout to check if this socket is ready to be read. - adds
errnohandling to determine peer shutdown event or socket should be read again. - use
recv()andsend()in a loop to interact with client instead - set send and receive timeouts.
Non-blocking C Socket
Non-blocking socket is normally a preferred socket mode as it prevents server related functions from blocking indefinitely, allowing the server to do additional tasks. Enable non-blocking socket with a call to setsockopt():
/* Set to non-blocking */
if (fcntl(server_fd, F_SETFL, O_NONBLOCK) < 0)
{
close(server_fd);
perror("set O_NONBLOCK failed");
return EXIT_FAILURE;
}
Accept Loop + Select()
The main server loop relies on select() to efficiently monitor the listening socket for readiness events without blocking indefinitely. Before each call to select(), file descriptor sets (fd_set) are initialized with the server socket included in both the read and exception sets. A read event on the listening socket indicates that a new client is attempting to connect. By using select(), the server can wait on multiple sockets or events simultaneously and handle timeouts gracefully through the accept_tv parameter.
When select() returns, the code checks the return value for errors or interruptions. If a read event is detected on the server socket (FD_ISSET(server_fd, &read_fds)), the server calls accept() to establish the new connection. This design allows the server to scale beyond a simple blocking accept() call by multiplexing readiness events across multiple sockets if needed. Combined with proper error handling and connection setup, this loop forms the backbone of a responsive, event-driven server architecture that can efficiently manage multiple incoming client requests.
fd_set read_fds, write_fds, except_fds;
FD_ZERO(&read_fds);
FD_ZERO(&write_fds);
FD_ZERO(&except_fds);
FD_SET(server_fd, &read_fds);
FD_SET(server_fd, &except_fds);
/* Wait for someone to do something */
int rc = select(maxfd + 1, &read_fds, &write_fds, &except_fds, &accept_tv);
if (rc < 0) {
if (errno == EINTR)
continue;
printf("select error. Exit...\n");
break;
}
/* A read event on the socket is a new connection */
if (FD_ISSET(server_fd, &read_fds))
{
socklen_t addrlen = sizeof(address);
/* Accept the new connection */
new_socket = accept(server_fd, (struct sockaddr *)&address, &addrlen);
if (new_socket > 0)
{
//do something on client connect
}
...
...
}
Spawn a Thread to Handle a Client
Once the server accepts a new client connection, it immediately creates a dedicated thread to handle all communication with that client. This is accomplished using pthread_create(), which takes the thread ID, optional attributes, the function to execute (client_handler), and a pointer to the parameters associated with the client (such as the connected socket). Spawning a separate thread allows the main server loop to return quickly to accept() and remain available for additional client connections instead of being blocked by ongoing message exchanges.
The thread function client_handler() contains the actual logic for interacting with the client—receiving messages, processing them, sending responses, and performing cleanup when the session ends. By isolating client interactions in individual threads, the server can support multiple clients concurrently, improve responsiveness, and scale more effectively. This design also simplifies error handling and resource management, since each thread only needs to focus on its assigned client before terminating gracefully.
pthread_t thread_id;
if(pthread_create(&thread_id, NULL, client_handler, (void*)params) < 0)
{
printf("cannot spawn thread\n");
break;
}
static void * client_handler(void * params)
{
// handling logic here
}
Recv and Send Loop + Errno Handling + Timeout
The core of the client handler is a continuous loop that waits for messages from the connected client, processes them, and optionally sends a response. To prevent the server from blocking indefinitely on slow or unresponsive clients, socket timeouts are applied using setsockopt(). In this example, the receive timeout is set to 15 seconds and the send timeout to 10 seconds. If no data arrives within the specified period, recv() will fail with EAGAIN or EWOULDBLOCK, allowing the loop to either retry or terminate gracefully depending on the context.
Error handling is equally important. The loop checks the return value of recv() and send() and branches according to common conditions:
- Graceful shutdowns (
n == 0) are detected when the client closes the connection with a FIN packet. - Transient errors such as
EINTR(interrupted system call) orEAGAIN/EWOULDBLOCK(temporary unavailability) are handled by retrying. - Connection resets (
ECONNRESET,EPIPE) are logged and treated as client disconnections. - Any other unexpected error leads to immediate cleanup and thread exit.
This structured approach ensures that the server can robustly exchange messages with each client without risking deadlocks or resource leaks, while remaining responsive to new connections.
Final Code Example
View Improved Server Code Here
// server.c
// Build: gcc -Wall -Wextra -O2 -pthread -o server server.c
// Run: ./server
// Test: In another terminal -> echo "hi" | nc 127.0.0.1 8080
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
#include <time.h>
#include <netinet/in.h>
#include <pthread.h>
/* parameters representing a client */
struct commparams
{
int client_sock;
};
static void * client_handler(void * params);
int main(void) {
// -----------------------------
// Step 1: Create a socket
// -----------------------------
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
int maxfd;
if (server_fd == -1) {
perror("socket failed");
return EXIT_FAILURE;
}
printf("Server socket created successfully.\n");
/* Set to non-blocking */
if (fcntl(server_fd, F_SETFL, O_NONBLOCK) < 0)
{
close(server_fd);
perror("set O_NONBLOCK failed");
return EXIT_FAILURE;
}
// -----------------------------
// Step 2: Bind to IP/port
// -----------------------------
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
int opt = 1;
// Allow quick restart on same port
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) != 0) {
perror("setsockopt failed");
close(server_fd);
return EXIT_FAILURE;
}
address.sin_family = AF_INET; // IPv4
address.sin_addr.s_addr = INADDR_ANY; // Listen on all interfaces
address.sin_port = htons(8080); // Port 8080
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server bound to port 8080.\n");
// -----------------------------
// Step 3: Listen for connections
// -----------------------------
if (listen(server_fd, 3) < 0) {
perror("listen failed");
close(server_fd);
return EXIT_FAILURE;
}
printf("Server listening...\n");
// -----------------------------
// Step 4: Accept a connection
// -----------------------------
/* Track the highest active file descriptor number for select */
struct commparams * params = NULL;
fd_set read_fds, write_fds, except_fds;
pthread_t thread_id;
maxfd = (fileno(stdin) > server_fd ? fileno(stdin) : server_fd);
int new_socket;
struct timeval accept_tv = { .tv_sec = 5, .tv_usec = 0 };
for(;;)
{
FD_ZERO(&read_fds);
FD_ZERO(&write_fds);
FD_ZERO(&except_fds);
FD_SET(server_fd, &read_fds);
FD_SET(server_fd, &except_fds);
/* Wait for someone to do something */
int rc = select(maxfd + 1, &read_fds, &write_fds, &except_fds, &accept_tv);
if (rc < 0) {
if (errno == EINTR)
continue;
printf("select error. Exit...\n");
break;
}
/* Process an exception on the socket itself */
if (FD_ISSET(server_fd, &except_fds))
{
printf("socket exception. Exit...\n");
break;
}
/* A read event on the socket is a new connection */
if (FD_ISSET(server_fd, &read_fds))
{
socklen_t addrlen = sizeof(address);
/* Accept the new connection */
new_socket = accept(server_fd, (struct sockaddr *)&address, &addrlen);
if (new_socket < 0)
{
printf("accept failed, will try again");
}
else
{
if (new_socket > maxfd)
maxfd = new_socket;
printf("Connection accepted from client!\n");
// create a copy of param and pass it to thread
params = (struct commparams *) malloc (sizeof(struct commparams));
memset(params, 0, sizeof(struct commparams));
params->client_sock = new_socket;
printf("spawn a thread to process\n");
if(pthread_create(&thread_id, NULL, client_handler, (void*)params) < 0)
{
printf("cannot spawn thread\n");
break;
}
}
}
}
// Cleanup
close(new_socket);
close(server_fd);
return 0;
}
/* deedicated thread to server a client */
static void * client_handler(void * params)
{
// -----------------------------
// Step 5: Read data from socket
// -----------------------------
struct commparams * myparams = (struct commparams *) params;
char buffer[1024];
memset(buffer, 0, sizeof(buffer));
struct timeval rcv_to = { .tv_sec = 15, .tv_usec = 0 };
struct timeval snd_to = { .tv_sec = 10, .tv_usec = 0 };
int total_read = 0;
// set read timeout (e.g., 15 seconds)
setsockopt(myparams->client_sock, SOL_SOCKET, SO_RCVTIMEO, &rcv_to, sizeof(rcv_to));
// set send timeout (e.g., 10 seconds)
setsockopt(myparams->client_sock, SOL_SOCKET, SO_SNDTIMEO, &snd_to, sizeof(snd_to));
for (;;)
{
ssize_t n = recv(myparams->client_sock, buffer + total_read, sizeof(buffer) - 1 - total_read, 0);
if (n > 0) {
total_read += n;
buffer[total_read] = '\0';
// send a response when client sends a hello
printf("Message from client (%zd bytes): %s\n", total_read, buffer);
if (strstr(buffer, "hello"))
{
// client sends hello
const char *response = "Hello from server!\n";
if (send(myparams->client_sock, response, strlen(response), 0) < 0)
{
if (errno == EPIPE || errno == ECONNRESET) {
printf("Client disconnected before reply.\n");
} else {
printf("send failed");
}
} else {
printf("Sent response to client. Exitting thread...\n");
// just a test, exit thread after sending a response
close(myparams->client_sock);
free(myparams);
return NULL;
}
}
} else if (n == 0) {
// Peer performed an graceful shutdown (FIN); no more data.
printf("Client closed connection (graceful shutdown).\n");
close(myparams->client_sock);
free(myparams);
return NULL;
} else {
// n < 0
if (errno == EINTR) {
// Interrupted by signal — retry
continue;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// Nonblocking socket: would block — you could poll() and retry
// For blocking sockets this usually won't happen; we just choose to retry.
continue;
} else {
// real error. Just bail out
close(myparams->client_sock);
free(myparams);
return NULL;
}
}
}
return NULL;
}
Related

Hi, I’m Cary — a tech enthusiast, educator, and author, currently a software architect at Hornetlabs Technology in Canada. I love simplifying complex ideas, tackling coding challenges, and sharing what I learn with others.