NEWS

Building a Simple Chat Application in C (c_chat) — Step by Step Guide

When I first started programming networked applications, writing a chat program in C was one of my favorite “toy projects.” It’s simple enough to be manageable, yet it touches many important systems concepts: sockets, concurrency, parsing, error handling, memory management, and more. If you can build a chat app from scratch in C, you understand many of the underpinnings of network software.

This article shows you exactly how to build a basic chat application in C (let’s call it c_chat). By the end, you’ll have:

  • A server program that multiple clients can connect to

  • A client program that sends and receives messages

  • Messaging protocol to manage chat data

  • Handling for multiple clients, using select() or threads

  • Tips on debugging, scaling, and extending

I’ll share pitfalls I ran into, advice, and example code. If you’re a beginner to C, some parts may seem advanced, but I’ll explain carefully. (If you want a shorter version or more beginner-level guide, I can also do that.)

Let’s dive in.

Overview & Architecture

Before coding, you need a mental picture (or diagram) of how things will work. Here’s the architecture for c_chat:

  • A server program runs, listening on a TCP port.

  • Many clients (user programs) connect to the server.

  • When a client sends a message, the server receives it and broadcasts it to all or specific clients.

  • The server keeps track of clients (e.g. sockets, identifiers).

  • Clients read incoming messages asynchronously, and send messages entered by the user.

  • Optionally, the server or client can apply rules like private messages, rooms, etc.

This is the classic client-server chat model. You could instead do peer-to-peer, but that adds complexity (NAT traversal, discovery, etc). For learning, client-server is fine.

Key design decisions:

  • Will you use blocking I/O with threads, or nonblocking I/O with select() / poll()?

  • How will you delimit or structure messages? (fixed size, length prefix, delimiter)

  • How do you handle disconnects, errors, malformed data?

  • Do you allow private messaging, rooms, or just broadcast?

  • (Advanced) Will you add encryption or authentication?

From my experience, using select() or poll() tends to scale better for moderate numbers of clients and is simpler to manage than threads for beginners. But threads are more intuitive sometimes.

Many open source C chat projects use a minimal design. For example, the GitHub “c-chat” project provides a simple terminal chat program in C. We will borrow similar ideas but explain in detail.

Read Also: Do frigidaire a/c’s use styrofoam anymore

Basics of Socket Programming in C

To build c_chat, you must understand sockets in C. Here’s a quick primer (on Linux/Unix). Windows has similar but with Winsock differences.

1. Creating a socket

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket");
exit(1);
}
  • AF_INET = IPv4

  • SOCK_STREAM = TCP

  • 0 = default protocol

Return is a file descriptor; negative means error.

2. Binding (for server)

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY; // listen on all interfaces
serv_addr.sin_port = htons(PORT);

if (bind(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind");
exit(1);
}

htons(port) converts host-order to network-order.

3. Listen (server)

if (listen(sockfd, backlog) < 0) {
perror("listen");
exit(1);
}

backlog is number of pending connections allowed.

4. Accept (server gets client)

struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int newsockfd = accept(sockfd, (struct sockaddr*)&cli_addr, &cli_len);
if (newsockfd < 0) {
perror("accept");
// handle or continue
}

accept blocks until a client connects (unless socket is non-blocking).

5. Connect (client side)

int sockfd = socket(...);
struct sockaddr_in serv_addr;
// fill serv_addr with server IP and port
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect");
exit(1);
}

Once connected, client and server can send and recv.

6. Sending & receiving data

You can use read()/write() or recv()/send():

ssize_t n = send(sockfd, buffer, len, 0);
if (n < 0) { perror("send"); }

ssize_t m = recv(sockfd, buffer, bufsize, 0);
if (m < 0) { perror("recv"); }
if (m == 0) {
// connection closed by peer
}

Be careful: send/recv may not transfer the full buffer in one call (partial send). You may need to loop until all data is sent/received.

Also, recv returns 0 when connection is gracefully closed.

Handling Multiple Clients

A central challenge is that your server must manage multiple client connections. If you naively accept() one, then recv() on it, you block and can’t handle others. Here are two common strategies.

Strategy 1: select() / poll() (I/O multiplexing)

This is common for many C network programs. The idea:

  • Use select() or poll() to wait for activity on any socket (server listening socket or client sockets).

  • When select() indicates readability, you accept() (if it’s the listening socket) or recv() (if it’s a client socket).

  • You can loop and broadcast messages.

Example sketch:

fd_set read_fds, master_fds;
int fdmax = server_sock;

// Initialize
FD_ZERO(&master_fds);
FD_ZERO(&read_fds);
FD_SET(server_sock, &master_fds);

while (1) {
read_fds = master_fds;
if (select(fdmax+1, &read_fds, NULL, NULL, NULL) < 0) {
perror("select");
exit(1);
}
for (int i = 0; i <= fdmax; i++) {
if (FD_ISSET(i, &read_fds)) {
if (i == server_sock) {
// new connection
int newfd = accept(...);
FD_SET(newfd, &master_fds);
if (newfd > fdmax) fdmax = newfd;
} else {
// data from client
int n = recv(i, buf, sizeof(buf), 0);
if (n <= 0) {
// disconnect
close(i);
FD_CLR(i, &master_fds);
} else {
// broadcast to others
for (int j = 0; j <= fdmax; j++) {
if (FD_ISSET(j, &master_fds) && j != server_sock && j != i) {
send(j, buf, n, 0);
}
}
}
}
}
}
}

This approach keeps your program in a single thread. It scales decently for a moderate number of clients (hundreds, even). It’s more efficient than spinning up threads per client for many connections.

Strategy 2: Multi-threading (or fork)

Another way is: each time you accept(), you spawn a thread (or process via fork()) to handle that client. That thread listens to that client, and either communicates with the main thread or touches a shared list of clients to broadcast.

Example:

void *client_handler(void *arg) {
int client_fd = *(int *)arg;
free(arg);

while (1) {
int n = recv(client_fd, buf, sizeof(buf), 0);
if (n <= 0) break;
broadcast_to_others(client_fd, buf, n);
}
close(client_fd);
return NULL;
}

// in main:
while (1) {
int newfd = accept(...);
int *pclient = malloc(sizeof(int));
*pclient = newfd;
pthread_t tid;
pthread_create(&tid, NULL, client_handler, pclient);
}

This is conceptually simpler (each client is independent), but:

  • You must manage thread safety (shared data), e.g. list of clients, broadcasting. Use mutexes.

  • Threads consume memory; many threads may not scale well.

  • You must clean up threads / resources.

In my experience, for simple chat, select() is more robust for small to medium scale, and threads are good if you expect few clients but more independence.

Message Protocol & Format

You can’t just send() raw data and hope things work. You need a scheme so the receiver knows where messages begin and end, especially because TCP is a stream.

Common approaches:

  1. Fixed-size messages: always send N bytes. Easy, but not flexible.

  2. Length-prefixed: send an integer length first, then the payload.

  3. Delimiter-based: append a special character or sequence (e.g. \n) to mark end.

I prefer length-prefixed, because it works cleanly even if payload contains newlines. For instance:

struct {
uint32_t len; // in network byte order (htonl)
char msg[];
} packet;

Sender does:

  • Compute len = payload_length

  • Set packet.len = htonl(len)

  • send(sockfd, &packet, sizeof(len) + len, 0)

Receiver does:

  • First read 4 bytes (the length)

  • Convert with ntohl()

  • Then read exactly that many bytes into buffer

  • Process

Be sure to loop reading until full length is received (because partial reads occur).

Parsing:

uint32_t net_len;
recv_all(sockfd, (char*)&net_len, 4);
uint32_t len = ntohl(net_len);
char *buf = malloc(len+1);
recv_all(sockfd, buf, len);
buf[len] = '\0';

recv_all is a helper that loops until it has read exactly the amount or returns error.

Using this pattern helps avoid boundary issues.

Error Handling & Memory Management

In C, small mistakes often cause crashes or subtle bugs. Here are rules I adopt:

  • Always check the return values of socket(), bind(), listen(), accept(), recv(), send(), malloc(), etc.

  • If malloc() returns NULL, you must exit or recover gracefully.

  • Use perror() or fprintf(stderr, ...) to print debugging messages.

  • Free memory after use. Clean up on client disconnect.

  • Handle disconnect properly: when recv() returns 0, the peer closed the connection.

  • Prevent buffer overruns: always keep track of lengths and boundaries.

  • In threaded models, protect shared data with pthread_mutex_t.

  • Use close() on sockets.

  • Use setsockopt() to allow reuse of address (SO_REUSEADDR), so you can restart quickly.

One common error: passing NULL to thread handler leads to segfault. For example, in a pthread_create call passing NULL as argument, and the handler dereferences it.

Also, always consider what happens if a client disconnects unexpectedly—your server must detect that and remove the client.

Security & Encryption (Optional / Advanced)

For a basic chat app, encryption isn’t usually included, but in real usage you’d want:

  • TLS encryption (SSL) so that messages are encrypted over the wire.

  • User authentication (so only known users chat).

  • Input sanitization to prevent injection-like issues.

  • Optional: end-to-end encryption (E2EE), though that’s quite advanced.

If you use OpenSSL in C, you can wrap your sockets with SSL objects. The handshake will encrypt traffic. But integrating OpenSSL is a whole topic. If you want, I can help with sample code.

Also, for production, you’d want to limit message size, rate-limit clients, prevent denial-of-service attacks, etc.

Compiling, Running & Deployment

Makefile example

CC = gcc
CFLAGS = -Wall -g

all: server client

server: server.o common.o
$(CC) $(CFLAGS) -o server server.o common.o

client: client.o common.o
$(CC) $(CFLAGS) -o client client.o common.o

%.o: %.c common.h
$(CC) $(CFLAGS) -c $<

clean:
rm -f *.o server client

You might factor common routines (packet read/write, error helpers) into common.c.

Running

  1. On a Linux box:

./server 12345
./client 127.0.0.1 12345
  1. Clients connect to server IP and port.

  2. If firewall blocks port, open it (on Linux iptables or UFW).

  3. For remote deployment, you could run server on a cloud VM, and connect clients from anywhere (if your server has a public IP).

A tip: I often test locally, then in two terminal windows (or two machines) to see behavior.

Full Example Walk-through

Below is a simplified example (sketch) combining server and client components. (In practice you’d add more error checks, modular design, etc.)

common.h / common.c (helpers)

// common.h
#ifndef COMMON_H
#define COMMON_H

#include <stdint.h>
ssize_t recv_all(int sockfd, void *buf, size_t len);
ssize_t send_all(int sockfd, const void *buf, size_t len);

#endif

// common.c
#include "common.h"
#include <unistd.h>
#include <errno.h>

ssize_t recv_all(int sockfd, void *buf, size_t len) {
size_t total = 0;
char *cbuf = buf;
while (total < len) {
ssize_t n = recv(sockfd, cbuf + total, len - total, 0);
if (n <= 0) return n;
total += n;
}
return total;
}

ssize_t send_all(int sockfd, const void *buf, size_t len) {
size_t total = 0;
const char *cbuf = buf;
while (total < len) {
ssize_t n = send(sockfd, cbuf + total, len - total, 0);
if (n < 0) return n;
total += n;
}
return total;
}

server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/select.h>
#include "common.h"

#define PORT 12345
#define MAXBUFLEN 1024

int main() {
int listener = socket(AF_INET, SOCK_STREAM, 0);
if (listener < 0) { perror("socket"); exit(1); }

int yes = 1;
setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = INADDR_ANY;
serv_addr.sin_port = htons(PORT);

if (bind(listener, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("bind"); exit(1);
}

if (listen(listener, 10) < 0) {
perror("listen"); exit(1);
}

fd_set master, read_fds;
FD_ZERO(&master);
FD_ZERO(&read_fds);
FD_SET(listener, &master);
int fdmax = listener;

while (1) {
read_fds = master;
if (select(fdmax + 1, &read_fds, NULL, NULL, NULL) < 0) {
perror("select"); exit(1);
}
for (int i = 0; i <= fdmax; i++) {
if (FD_ISSET(i, &read_fds)) {
if (i == listener) {
// new client
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int newfd = accept(listener, (struct sockaddr*)&cli_addr, &cli_len);
if (newfd < 0) {
perror("accept");
} else {
FD_SET(newfd, &master);
if (newfd > fdmax) fdmax = newfd;
printf("New connection on socket %d\n", newfd);
}
} else {
// data from a client
uint32_t net_len;
ssize_t n = recv_all(i, &net_len, 4);
if (n <= 0) {
close(i);
FD_CLR(i, &master);
printf("Connection %d closed\n", i);
} else {
uint32_t msg_len = ntohl(net_len);
if (msg_len > MAXBUFLEN) msg_len = MAXBUFLEN;
char *buf = malloc(msg_len + 1);
recv_all(i, buf, msg_len);
buf[msg_len] = '\0';
printf("Received from %d: %s\n", i, buf);

// broadcast to others
uint32_t send_len = htonl(msg_len);
for (int j = 0; j <= fdmax; j++) {
if (FD_ISSET(j, &master) && j != listener && j != i) {
send_all(j, &send_len, 4);
send_all(j, buf, msg_len);
}
}
free(buf);
}
}
}
}
}

return 0;
}

client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <pthread.h>
#include "common.h"

#define MAXBUFLEN 1024

int sockfd;

void *recv_thread(void *arg) {
(void)arg;
while (1) {
uint32_t net_len;
ssize_t n = recv_all(sockfd, &net_len, 4);
if (n <= 0) break;
uint32_t msg_len = ntohl(net_len);
char *buf = malloc(msg_len + 1);
recv_all(sockfd, buf, msg_len);
buf[msg_len] = '\0';
printf("\n[Peer] %s\n> ", buf);
fflush(stdout);
free(buf);
}
return NULL;
}

int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <server_ip> <port>\n", argv[0]);
exit(1);
}
char *server_ip = argv[1];
int port = atoi(argv[2]);

sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) { perror("socket"); exit(1); }

struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
inet_pton(AF_INET, server_ip, &(serv_addr.sin_addr));
serv_addr.sin_port = htons(port);

if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("connect"); exit(1);
}
printf("Connected to server.\n");

pthread_t tid;
pthread_create(&tid, NULL, recv_thread, NULL);

char buf[MAXBUFLEN];
while (1) {
printf("> ");
fflush(stdout);
if (fgets(buf, sizeof(buf), stdin) == NULL) break;
size_t len = strlen(buf);
if (buf[len-1] == '\n') {
buf[len-1] = '\0';
len--;
}
uint32_t net_len = htonl(len);
send_all(sockfd, &net_len, 4);
send_all(sockfd, buf, len);
}

close(sockfd);
return 0;
}

This is a working minimal chat system. You’ll want to modularize, add error handling, user names, etc.

Extensions & Improvements

Once you have this working, you can build features:

  • Usernames / nicknames: clients identify themselves (send join message)

  • Private messages / direct chat: allow /msg user message

  • Rooms / channels: clients join rooms and broadcast inside room

  • File transfer / attachments

  • Logging / persistence: store chat history in a file or database

  • GUI / Web interface: build a front-end (GTK, Qt, or web sockets)

  • Encryption / TLS: wrap sockets via OpenSSL

  • Authentication / login: so clients must login to talk

  • Rate limiting / spam protection

  • Heartbeat / ping / keep-alive

  • Scalability: cluster servers, load balancing

Each extension is a mini project in itself.

Troubleshooting & Common Pitfalls

Here are problems I faced (and you probably will) and how to avoid them:

  • Segfaults (NULL pointers): especially in thread handlers when argument is missing.

  • Partial sends / receives: never assume a single send() or recv() transfers full buffer. Use loops (like recv_all, send_all).

  • Client disconnects: if a client disconnects, recv() returns 0. Detect this, close(), and remove from your set.

  • Select’s fdmax mismanagement: if you close the highest fd, you must recompute fdmax.

  • Message boundary issues: forgetting to use length prefix or delimiter causes interleaved messages.

  • Memory leaks: forgetting free(), or allocating in loops without free.

  • Thread-safety: if using threads, shared client list must be protected by mutex.

  • Port in use: if you try to restart server immediately, bind may fail unless you set SO_REUSEADDR.

  • Buffer overflow / too large messages: limit maximum length and validate input.

  • Blocking I/O deadlock: mixing blocking I/O and threads can stall your program.

Conclusion & Next Steps

Building a chat program in C (c_chat) is a great way to learn systems programming, networking, and concurrency. The example above gives you a strong base:

  • A server that accepts multiple clients

  • Clients that send and receive messages

  • A clean message protocol

  • Error handling, memory safety, and modularity

From here, you can add features (rooms, encryption, GUI) or scale it further. If you run into bugs, step through with gdb, print debug logs, and think of edge cases (disconnects, partial reads, invalid input).

If you like, I can provide a polished, full repository skeleton or variants (e.g. with TLS) to help you. Just let me know.

FAQ

Q: Why use length prefix rather than delimiter?
A: Because the payload may contain the delimiter, and TCP segments can split messages arbitrarily. Length prefix gives you clear boundaries.

Q: When should I use select() vs threads?
A: select() is better for moderate clients when you want efficiency in a single thread. Threads are nicer when you want per-client logic, but require synchronization.

Q: How to debug segmentation faults?
A: Use gdb or valgrind. Print pointer checks, add logging, and check arguments passed to threads (NULL pointers are common issues).

Q: Can this run on Windows?
A: Yes, but you’d need Winsock APIs (WSAStartup, closesocket, etc.) and minor adjustments.

Q: Can I add encryption?
A: Yes. Use OpenSSL or other TLS libraries in C. Wrap your sockets and use SSL_read / SSL_write.

Q: How many clients can this support?
A: With select(), hundreds. With threads, you’re limited by OS resources and memory overhead.

Related Articles

Leave a Reply

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

Back to top button