TCP Client and Server in C: A Working Echo Example

Setting up a TCP connection takes six system calls. Using it correctly means handling a byte stream with no message boundaries and partial transfers.

Illustration of a TCP connection between a client laptop and a server, showing bidirectional data flow with blue and green packets and arrows.

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
TCP delivers bytes, not messages One program sends five messages. Three runs of it on loopback read them back three ways. 5 send() calls one two three four five what recv() returned run 1 — 5 reads one two three four five run 2 — 1 read onetwothreefourfive run 3 — 3 reads one two threefourfive Same bytes, same order — the boundaries changed from run to run Code that expects one recv() per send() works on run 1 and fails on runs 2 and 3. Read until you have the number of bytes you expect, or frame the messages. Captured from three runs of one program on Ubuntu 24.04. How the bytes are split depends on timing that the program does not control, so the receiver has to handle any split.
TCP preserves the order of bytes, not the boundaries between writes. Five 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:

POSIXWinsock
No initialisationWSAStartup before any socket call, WSACleanup after
int descriptorSOCKET, an unsigned type; failure is INVALID_SOCKET
close()closesocket()
errnoWSAGetLastError()
ssize_t from send() / recv()int, with SOCKET_ERROR on failure
pthread_createCreateThread or _beginthreadex
SIGPIPENot 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:

TCP Echo build
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:

CheckWhat it tests
Basic echoOne message round trip
IPv4 and IPv6 loopback127.0.0.1 and ::1 against the dual-stack server
1,000 round tripsRepeated send and receive on one connection
20,000-byte messageReassembly of an echo larger than the server’s buffer
Five concurrent clientsOne thread per connection
No server runningThe client fails cleanly with status 1
Usage errorsBad 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 three recv() 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() and recv() 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 ignore SIGPIPE or use MSG_NOSIGNAL, so a closed peer is reported as EPIPE rather than ending the process.
  • Use getaddrinfo for address resolution, so the same code handles IPv4 and IPv6.
  • Carry lengths explicitly. strlen measures 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.

Scroll to Top