A basic TCP server in C uses four system calls — socket(), bind(), listen() and accept() — and a client uses two, socket() and connect(). The harder part comes afterwards: TCP delivers a stream of bytes, not a sequence of messages, and send() and recv() are both allowed to transfer fewer bytes than you asked for.
This guide builds an echo server and client using the POSIX.1-2008 socket and thread interfaces, not ISO C alone, with the error handling those two facts require. The programs were compiled with GCC 13.3 and Clang 18.1.3 using -std=c11 -Wall -Wextra -pedantic with no warnings, and run over IPv4 loopback on Ubuntu 24.04, including concurrent clients and messages larger than the server’s buffer. Concurrent clients were also run against the server built with AddressSanitizer, UndefinedBehaviorSanitizer and ThreadSanitizer, with no reports. The code is in a GitHub repository whose build runs these tests on each commit, over IPv6 as well.
How a TCP Connection Works in C
A TCP server creates a socket with socket(), attaches it to a port with bind(), marks it ready for connections with listen(), and then calls accept(), which blocks until a client connects and returns a new socket for that connection. A TCP client creates a socket and calls connect() with the server’s address. Once connected, both sides exchange data with send() and recv(). TCP guarantees that the bytes arrive complete and in order, but not that they arrive in the same groupings they were sent in.
The listening socket and the connected socket are different descriptors. The listening socket only accepts new connections; each accept() returns a separate descriptor for talking to one client, and that is the one you read and write.
TCP Is a Byte Stream
That last sentence of the definition has the most practical consequences. To see it directly, here is a program that makes five separate send() calls on one connection — "one", "two", "three", "four", "five" — and a receiver that reads with a single 4096-byte buffer and reports what each recv() returned. Three runs of the same program on loopback, summarised:
run 1: recv() returned "one" | "two" | "three" | "four" | "five" 5 reads
run 2: recv() returned "onetwothreefourfive" 1 read
run 3: recv() returned "one" | "two" | "threefourfive" 3 reads
send() calls were read back as five, one and three recv() results across three runs of the same program on loopback. A receiver has to decide for itself where one message ends — by reading a known number of bytes, or by framing the data.
The bytes are identical and in the same order in all three runs — TCP guarantees that much. What changes is where the boundaries fall, and that depends on timing the program does not control. A receiver written on the assumption that one recv() returns one message would behave correctly in run 1 and incorrectly in the other two — and whether a given test happens to look like run 1 is a matter of scheduling.
So the receiver has to decide where a message ends, by one of two means:
- Read a known number of bytes. If the receiver already knows the length — because the protocol fixes it, or because it sent the data and is reading an echo — it reads until it has that many.
- Frame the data. Put a length prefix before each message, or end each one with a delimiter such as a newline, and have the receiver parse the stream accordingly.
This echo example uses the first: the client knows how many bytes it sent, so it reads exactly that many back.
Reading and Writing a Byte Stream
Both directions need a loop. These two helpers are shared by the server and the client:
Their declarations, in net.h:
/* net.h - helpers for reading and writing a TCP byte stream. */
#ifndef MYCPLUS_NET_H
#define MYCPLUS_NET_H
#include <stddef.h>
#include <sys/types.h>
/* Send all len bytes, retrying on partial writes and EINTR.
Returns 0 on success, -1 on error (errno is set). */
int send_all(int fd, const void *buf, size_t len);
/* Receive exactly len bytes, retrying on short reads and EINTR.
Returns the number of bytes received: len on success, fewer if the
peer closed the connection first, or -1 on error (errno is set). */
ssize_t recv_exact(int fd, void *buf, size_t len);
#endif /* MYCPLUS_NET_H */
And their definitions, in net.c:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <sys/socket.h>
#include "net.h"
/* MSG_NOSIGNAL stops send() raising SIGPIPE when the peer has closed the
connection, so the error arrives as EPIPE instead of killing the
process. macOS lacks it and uses the SO_NOSIGPIPE socket option. */
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
int send_all(int fd, const void *buf, size_t len)
{
const char *p = buf;
while (len > 0) {
ssize_t n = send(fd, p, len, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR)
continue; /* interrupted before sending */
return -1;
}
p += n; /* send() may write fewer bytes */
len -= (size_t)n; /* than asked; send the rest */
}
return 0;
}
ssize_t recv_exact(int fd, void *buf, size_t len)
{
char *p = buf;
size_t got = 0;
while (got < len) {
ssize_t n = recv(fd, p + got, len - got, 0);
if (n < 0) {
if (errno == EINTR)
continue;
return -1;
}
if (n == 0)
break; /* peer closed: return what we have */
got += (size_t)n;
}
return (ssize_t)got;
}
send() can write fewer bytes than requested. It returns the number actually written, which on a stream socket may be less than len — for example when the send buffer is nearly full. send_all advances past what was written and sends the remainder. The result of send() is an ssize_t; the cast to size_t is safe only after the n < 0 case has been handled.
recv() returning 0 means the peer closed the connection. It is not an error and not “no data yet” — on a blocking socket, recv() waits for data, and 0 is the orderly end of the stream. recv_exact returns what it has so far, so the caller can tell a complete read from a connection that closed part-way through.
EINTR is not a failure. A signal delivered while the call is blocked can interrupt it before any data is transferred; the call is simply retried.
MSG_NOSIGNAL stops send() raising SIGPIPE when the peer has gone. Without it, and without ignoring the signal, writing to a closed connection terminates the process by default. Not every platform defines MSG_NOSIGNAL, so it falls back to 0 there and the programs below also ignore SIGPIPE directly.
The Server
The server accepts connections in a loop and hands each one to its own thread, which echoes every byte back:
/* tcp-server.c - TCP echo server, one thread per client.
*
* tcp-server <port>
*
* Echoes every byte it receives back to the sender, unchanged. The data
* is treated as bytes, not text, so it may contain anything, including
* zero bytes.
*/
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <netdb.h>
#include <netinet/in.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "net.h"
#define BUFFER_SIZE 4096
static void *handle_client(void *arg)
{
const int fd = *(int *)arg;
free(arg);
char buf[BUFFER_SIZE];
for (;;) {
ssize_t n = recv(fd, buf, sizeof buf, 0);
if (n == 0)
break; /* client closed */
if (n < 0) {
if (errno == EINTR)
continue;
perror("recv");
break;
}
if (send_all(fd, buf, (size_t)n) < 0) {
perror("send");
break;
}
}
close(fd);
return NULL;
}
/* Bind a socket of the given family to port on the wildcard address.
Returns the descriptor, or -1. */
static int bind_family(const char *port, int family)
{
struct addrinfo hints;
memset(&hints, 0, sizeof hints);
hints.ai_family = family;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE; /* wildcard address */
struct addrinfo *res;
if (getaddrinfo(NULL, port, &hints, &res) != 0)
return -1;
int fd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0)
continue;
/* Allow an immediate restart while old connections sit in
TIME_WAIT; without this, bind() fails with EADDRINUSE. */
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
/* On an IPv6 socket, ask for dual-stack: accept IPv4 clients too,
as IPv4-mapped addresses. Where the system does not allow this,
the socket stays IPv6-only. */
if (ai->ai_family == AF_INET6) {
int zero = 0;
setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &zero, sizeof zero);
}
if (bind(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break; /* bound */
close(fd);
fd = -1;
}
freeaddrinfo(res);
return fd;
}
/* Prefer an IPv6 dual-stack socket, which serves both IPv4 and IPv6;
fall back to IPv4 alone on a system without IPv6. */
static int open_listener(const char *port)
{
int fd = bind_family(port, AF_INET6);
if (fd < 0)
fd = bind_family(port, AF_INET);
if (fd < 0) {
fprintf(stderr, "could not bind to port %s\n", port);
return -1;
}
if (listen(fd, SOMAXCONN) < 0) {
perror("listen");
close(fd);
return -1;
}
return fd;
}
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
return 2;
}
/* Writing to a socket the peer has closed raises SIGPIPE, which
terminates the process by default. Ignore it and handle EPIPE. */
signal(SIGPIPE, SIG_IGN);
const int listener = open_listener(argv[1]);
if (listener < 0)
return 1;
printf("listening on port %s\n", argv[1]);
fflush(stdout);
for (;;) {
int fd = accept(listener, NULL, NULL);
if (fd < 0) {
if (errno == EINTR)
continue;
perror("accept");
continue; /* keep serving others */
}
/* Pass the descriptor through allocated memory, not by casting
an int to a pointer: each thread gets its own copy. */
int *arg = malloc(sizeof *arg);
if (arg == NULL) {
close(fd);
continue;
}
*arg = fd;
pthread_t tid;
if (pthread_create(&tid, NULL, handle_client, arg) != 0) {
perror("pthread_create");
free(arg);
close(fd);
continue;
}
pthread_detach(tid); /* reclaimed on exit */
}
}
Addresses come from getaddrinfo, not from filling in a struct sockaddr_in by hand. That keeps the address-family details out of the program and lets the same code serve IPv4 and IPv6.
The listener prefers a dual-stack IPv6 socket. Setting IPV6_V6ONLY to 0 asks for an IPv6 socket that also accepts IPv4 clients, which arrive as IPv4-mapped addresses. Where the system does not permit that, the socket stays IPv6-only; where there is no IPv6 at all, bind_family(port, AF_INET6) fails and the server falls back to IPv4. That fallback was exercised directly: the test environment for this article has no IPv6, and the server bound to IPv4 and served every IPv4 client.
SO_REUSEADDR lets the server restart without waiting for old connections to clear. After a connection closes, the side that closed first keeps the address in TIME_WAIT for a period. Without this option, restarting the server during that time makes bind() fail with EADDRINUSE. The code does not check whether setsockopt() succeeds for this or for IPV6_V6ONLY: neither is required for the socket to work, and a failure to set SO_REUSEADDR surfaces anyway as a bind() error.
The descriptor is passed to the thread in allocated memory. pthread_create takes a void *. Converting the int descriptor to a pointer and back relies on implementation-defined conversions; allocating an int, passing its address and freeing it in the thread avoids them and gives each thread its own copy.
Each thread is detached, so its resources are reclaimed when it returns without the main loop having to join it.
The echo loop treats data as bytes. It passes the length recv() returned straight to send_all, never adds a NUL terminator and never prints the buffer as a string, so any byte value — including zero — is echoed unchanged.
The Client
The client connects, sends the message, and reads back exactly as many bytes as it sent before comparing them:
/* tcp-client.c - TCP echo client.
*
* tcp-client <host> <port> <message> [count]
*
* Sends message count times and reads each echo back. Because TCP is a
* byte stream, a reply is not guaranteed to arrive in a single recv();
* the client reads until it has received exactly as many bytes as it
* sent, then checks that they match.
*/
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <netdb.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include "net.h"
/* Connect to host:port, trying each address getaddrinfo returns until
one succeeds. Returns the descriptor, or -1. */
static int connect_to(const char *host, const char *port)
{
struct addrinfo hints;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC; /* IPv4 or IPv6 */
hints.ai_socktype = SOCK_STREAM;
struct addrinfo *res;
int rc = getaddrinfo(host, port, &hints, &res);
if (rc != 0) {
fprintf(stderr, "%s: %s\n", host, gai_strerror(rc));
return -1;
}
int fd = -1;
for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
if (fd < 0)
continue;
if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0)
break; /* connected */
close(fd);
fd = -1;
}
freeaddrinfo(res);
if (fd < 0)
fprintf(stderr, "could not connect to %s:%s\n", host, port);
return fd;
}
int main(int argc, char **argv)
{
if (argc < 4 || argc > 5) {
fprintf(stderr, "usage: %s <host> <port> <message> [count]\n", argv[0]);
return 2;
}
long count = 1;
if (argc == 5) {
char *end;
errno = 0;
count = strtol(argv[4], &end, 10);
if (errno != 0 || *end != '\0' || count < 1 || count > 1000000) {
fprintf(stderr, "count must be between 1 and 1000000\n");
return 2;
}
}
signal(SIGPIPE, SIG_IGN);
/* argv strings are NUL-terminated C strings, so strlen is the right
length here. For arbitrary binary data you would carry the length
separately - strlen stops at the first zero byte. */
const char *msg = argv[3];
const size_t len = strlen(msg);
char *reply = malloc(len ? len : 1);
if (reply == NULL) {
fputs("out of memory\n", stderr);
return 1;
}
const int fd = connect_to(argv[1], argv[2]);
if (fd < 0) {
free(reply);
return 1;
}
int status = 0;
for (long i = 0; i < count; ++i) {
if (send_all(fd, msg, len) < 0) {
perror("send");
status = 1;
break;
}
const ssize_t got = recv_exact(fd, reply, len);
if (got < 0) {
perror("recv");
status = 1;
break;
}
if ((size_t)got != len) {
fprintf(stderr, "server closed after %zd of %zu bytes\n", got, len);
status = 1;
break;
}
if (memcmp(reply, msg, len) != 0) {
fprintf(stderr, "echo did not match on message %ld\n", i + 1);
status = 1;
break;
}
}
if (status == 0)
printf("sent and verified %ld message(s) of %zu bytes\n", count, len);
close(fd);
free(reply);
return status;
}
It tries every address getaddrinfo returns. A name such as localhost can resolve to both an IPv6 and an IPv4 address; if the first connect() fails, the next address is tried.
The message length comes from strlen, and only because of where the message comes from. Command-line arguments are NUL-terminated C strings, so strlen gives their length. That does not make strlen a general way to measure data: for binary data it stops at the first zero byte, and the length has to be carried separately — which is what send_all and recv_exact expect.
The reply buffer is exactly the message length, and recv_exact never writes more than that, so there is no terminator to fit and no room for an off-by-one write. The comparison uses memcmp with the explicit length rather than strcmp.
count is parsed with strtol and range-checked, so a value such as abc, 0, a negative number or anything above 1,000,000 is rejected with a message rather than silently becoming 0. An empty message is allowed, and is sent and verified as zero bytes.
Running It
Build both programs:
cc -std=c11 -Wall -Wextra -pedantic -Iinclude src/net.c src/tcp-server.c -o tcp-server -lpthread
cc -std=c11 -Wall -Wextra -pedantic -Iinclude src/net.c src/tcp-client.c -o tcp-client
Start the server, then run the client from another terminal:
$ ./tcp-server 5150
listening on port 5150
$ ./tcp-client localhost 5150 "Hello, TCP"
sent and verified 1 message(s) of 10 bytes
$ ./tcp-client localhost 5150 "ping" 1000
sent and verified 1000 message(s) of 4 bytes
A 20,000-byte message is larger than the server’s 4096-byte buffer, so the server receives and echoes it in several pieces, and the client reassembles them. In a Unix shell:
$ ./tcp-client localhost 5150 "$(head -c 20000 /dev/zero | tr '\0' 'x')" 5
sent and verified 5 message(s) of 20000 bytes
With no server listening, the client reports the failure and exits with status 1:
$ ./tcp-client localhost 5199 "hello"
could not connect to localhost:5199
Limitations of This Design
Two choices here favour clarity, and both have limits worth knowing before you build on them.
One thread per client. Each connection costs a thread and its stack. That is straightforward to read and adequate for a handful of clients, but it does not scale to thousands of connections — and because nothing limits how many threads are created, a server exposed to untrusted clients can have its resources exhausted simply by being sent many connections. Servers that need to handle many clients typically use a single thread with poll(), or the platform’s scalable interface — epoll on Linux, kqueue on the BSDs and macOS — and keep per-connection state themselves.
The client sends a whole message before reading its echo. That works while the message fits in the socket buffers. Here it was tested up to 120 KiB, and every size echoed correctly — close to the largest single command-line argument this system accepts, 128 KiB, a limit that scales with the page size and so varies between systems. For much larger transfers, a program that writes everything before reading anything can stall: if both ends are blocked in send() waiting for buffer space and neither is reading, neither can proceed. Large transfers should send and receive concurrently, or alternate in bounded chunks.
On Windows
This is POSIX code and does not build on Windows, where sockets come from Winsock. The structure of the program carries over, but several details differ:
| POSIX | Winsock |
|---|---|
| No initialisation | WSAStartup before any socket call, WSACleanup after |
int descriptor | SOCKET, an unsigned type; failure is INVALID_SOCKET |
close() | closesocket() |
errno | WSAGetLastError() |
ssize_t from send() / recv() | int, with SOCKET_ERROR on failure |
pthread_create | CreateThread or _beginthreadex |
SIGPIPE | Not raised; the error is returned instead |
The SOCKET type matters more than it looks. Because it is unsigned, the POSIX habit of testing for failure with if (s < 0) is always false and never detects an error. Compare a new socket against INVALID_SOCKET instead. The Windows sockets programming article covers Winsock in more detail.
Source Code and Tests
The complete example is in the MYCPLUS C examples repository, under networking/tcp-echo:
networking/tcp-echo/
├── CMakeLists.txt
├── README.md
├── include/
│ └── net.h
└── src/
├── net.c
├── tcp-server.c
└── tcp-client.c
Because TCP over loopback needs no special privileges, the build tests behaviour rather than just compiling. It builds with GCC and Clang on Ubuntu and Apple Clang on macOS, with warnings treated as errors, then starts the server and runs the client against it:
| Check | What it tests |
|---|---|
| Basic echo | One message round trip |
| IPv4 and IPv6 loopback | 127.0.0.1 and ::1 against the dual-stack server |
| 1,000 round trips | Repeated send and receive on one connection |
| 20,000-byte message | Reassembly of an echo larger than the server’s buffer |
| Five concurrent clients | One thread per connection |
| No server running | The client fails cleanly with status 1 |
| Usage errors | Bad arguments return status 2 |
Separate jobs run the server and client under AddressSanitizer and UndefinedBehaviorSanitizer, and the server under ThreadSanitizer with six concurrent clients.
Key Takeaways
- TCP preserves byte order, not message boundaries. Five
send()calls came back as five, one and threerecv()results across three runs of the same program. - A receiver has to find message boundaries itself — by reading a known length, or by framing the data with a length prefix or delimiter.
send()andrecv()can both transfer fewer bytes than requested. Loop until the whole amount has moved.recv()returning 0 is an orderly close, not an error and not an empty read.- Retry on
EINTR, and ignoreSIGPIPEor useMSG_NOSIGNAL, so a closed peer is reported asEPIPErather than ending the process. - Use
getaddrinfofor address resolution, so the same code handles IPv4 and IPv6. - Carry lengths explicitly.
strlenmeasures NUL-terminated strings, not binary data.
Frequently Asked Questions
Conclusion
Setting up a TCP connection is the brief part of network programming in C. The work is in what comes after: accepting that the stream has no message boundaries, looping until partial transfers complete, and treating the special return values — 0 from recv(), EINTR, EPIPE — as the normal events they are.
The echo server and client here handle each of those, and the tests exercise them on every build. From here, the UDP sender and receiver example shows the connectionless alternative, where each datagram does keep its boundaries, and the ICMP ping article works a layer lower. More C material is in the C programming guides.



