esp32 advanced 40 min

ESP32: TCP sockets in depth, the protocol your MQTT rides on

Every packet your ESP32 sends over WiFi goes through a TCP socket. Three-way handshake, WiFiClient, WiFiServer, the stuck connection gotcha, TCP_NODELAY.

Code available for: ESP32 ArduinoArduino C
Published Aug 26, 2026

Every IoT protocol you have ever used (MQTT, HTTP, WebSockets, even raw serial-over-network tools) runs on top of TCP. The “client connects, sends data, gets response, disconnects” pattern is the same shape every time. Most tutorials treat the connection as magic: you call client.connect(), it works, you call client.read(), data appears. The tutorial does not say what happens between those calls.

This tutorial does. It covers what TCP actually is, the three-way handshake that happens before any byte moves, the ESP32 APIs (WiFiClient and WiFiServer) you build on top of it, the gotchas that bite when you forget TCP is a stream not a request-response protocol, and the performance knobs (Nagle, window size, keep-alive) that you should set deliberately rather than leaving at defaults.

What TCP is vs UDP

Two protocols sit on top of IP:

  • TCP: connection-oriented. Two endpoints establish a session (the three-way handshake), exchange data, close cleanly. Delivery is guaranteed (in order, no duplicates). The cost is a few extra round trips for setup and some overhead per packet.
  • UDP: connectionless. The sender just sends a packet; the receiver might never see it. No retransmission, no ordering, no setup. Fast and simple, but no guarantees.

For MQTT, HTTP, file transfer, anything where the data must arrive intact, use TCP. For DNS queries, video streaming, sensor readings where the latest value matters more than every value, use UDP.

On the ESP32 the TCP path is WiFiClient (client role) and WiFiServer (server role). The UDP path is WiFiUDP. Both sit on top of the same lwIP stack the ESP-IDF networking layer provides.

The three-way handshake

Before any byte of actual data moves, the client and server exchange three packets:

Client -> Server: SYN (sequence = X)
Server -> Client: SYN, ACK (sequence = Y, ack = X+1)
Client -> Server: ACK (sequence = X+1, ack = Y+1)

This is the “three-way handshake.” It negotiates the initial sequence numbers and confirms that both sides are ready to send and receive. After the third packet, the connection is “established” and data can flow in either direction.

This is what client.connect(host, port) is doing under the hood. The call returns 1 when the handshake completes, 0 when it fails. A typical failure is a TCP RST (the server is not listening on that port) or a timeout (the server is unreachable, or a firewall is silently dropping the SYN).

The handshake takes 1-3 round trips. On a local WiFi network, each round trip is a few milliseconds. On a wide-area network, each round trip is 50-200 ms. That is why “is this server up” checks can feel slow; the handshake alone is half a second across the public internet.

The WiFiClient API

WiFiClient is the TCP client on the ESP32. The basic lifecycle:

#include <WiFi.h>

WiFiClient client;

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Connect to a TCP server on port 1234
  if (client.connect("192.168.1.50", 1234)) {
    Serial.println("Connected");

    // Send a message (note: \n or \r\n is your responsibility)
    client.println("hello from esp32");

    // Wait for a response (with a timeout)
    unsigned long start = millis();
    while (client.available() == 0) {
      if (millis() - start > 5000) {
        Serial.println("Timeout waiting for response");
        client.stop();
        return;
      }
      delay(10);
    }

    // Read the response
    while (client.available()) {
      char c = client.read();
      Serial.print(c);
    }

    // Close the connection
    client.stop();
  } else {
    Serial.println("Connection failed");
  }
}

void loop() {}

The key calls:

  • connect(host, port): initiates the three-way handshake. Returns 1 on success, 0 on failure.
  • connected(): returns true if the connection is still open. The library detects when the remote side closes the connection (the server sends FIN, which is a TCP close packet) and updates this flag.
  • available(): returns the number of bytes buffered locally that have not been read yet. Zero means “no data right now, try later.” The TCP stream has no “end of message” marker; you have to define your own protocol on top.
  • read(): reads one byte. Returns -1 if no data is available.
  • write(buf, len) or print() / println(): sends data.
  • stop(): closes the connection. Sends FIN, waits for ACK, frees the local resources.

The WiFiServer API

WiFiServer listens on a port and accepts incoming connections:

#include <WiFi.h>

WiFiServer server(1234);

void setup() {
  Serial.begin(115200);
  WiFi.begin("ssid", "password");
  while (WiFi.status() != WL_CONNECTED) delay(500);
  server.begin();
  Serial.print("Listening on ");
  Serial.println(WiFi.localIP());
}

void loop() {
  WiFiClient client = server.available();
  if (!client) return;

  while (client.connected()) {
    if (client.available()) {
      char c = client.read();
      Serial.print(c);
      client.write(c);   // echo back
    }
  }
  client.stop();
}

server.available() is non-blocking: returns a WiFiClient if a new connection is pending (or data is available), or an “empty” client (false) otherwise. The pattern of “accept, handle until disconnect, repeat” is how every TCP server works. The ESP32 accepts multiple simultaneous connections up to the lwIP limit (5 default, see below).

When to use TCP vs HTTP vs MQTT

You have three main options for device-to-server communication:

  • Raw TCP (WiFiClient): smallest overhead, you define the protocol. MQTT, custom industrial protocols, anything with its own wire format.
  • HTTP (HTTPClient or WebServer): standard, easy to debug with curl. Adds request/response framing on top of TCP. Good for one-shot operations; worse for high-frequency traffic.
  • MQTT (PubSubClient): persistent connection, pub/sub topics, broker manages fanout. The protocol most home automation stacks already speak.

Most projects end up using a combination: HTTP for occasional operations, MQTT for regular telemetry, raw TCP for custom protocols.

The keep-alive pattern

Each TCP connection has overhead: a TCP control block in memory (about 1 KB on lwIP), a socket in the OS, a slot in the server’s connection table. Opening and closing connections is expensive. For high-frequency comms, you keep one connection open and reuse it.

WiFiClient mqttClient;
unsigned long lastConnectAttempt = 0;
const unsigned long reconnectInterval = 5000;

void ensureConnected() {
  if (mqttClient.connected()) return;

  // Don't try to reconnect too often
  if (millis() - lastConnectAttempt < reconnectInterval) return;
  lastConnectAttempt = millis();

  if (mqttClient.connect("192.168.1.50", 1883)) {
    Serial.println("Connected");
  } else {
    Serial.println("Connection failed");
  }
}

void loop() {
  ensureConnected();

  if (mqttClient.connected() && mqttClient.available()) {
    // Handle incoming data
    char c = mqttClient.read();
    // ...
  }
}

The ensureConnected() pattern is the standard. Check the state, reconnect if needed, do not busy-loop on reconnect. The reconnectInterval is there because if the server is down, spamming connect() calls floods the network with SYN packets and makes the outage worse.

Buffer management

The ESP32 has a 4 KB TCP send buffer per connection. When you call client.write(), the data goes into the send buffer; lwIP drains it onto the wire as the receiver ACKs.

If the receiver is slow, the buffer fills. When full, client.write() returns 0 (non-blocking). If the receiver goes silent for the TCP timeout (default 75 seconds on lwIP), the connection is reset. For high-rate data sources, batch into 100-500 byte chunks and call delay(0) between writes.

The “stuck connection” gotcha

This is the bug that eats a day every time someone hits it.

The symptom: client.connected() returns true, client.available() returns 0, and the connection never closes. The remote server might be hung, or might have crashed without sending FIN.

The cause: TCP does not have a heartbeat by default. If the remote side crashes in a way that does not send FIN (power loss, kernel panic, network cable yanked), the local side has no way to know. The connection sits there in the “established” state forever.

The fix: TCP keep-alive. Enable it on the socket, and the local side will send a probe packet after a period of inactivity:

client.connect("192.168.1.50", 1234);
client.setKeepAlive(60,    // idle seconds before first probe
                    10,    // interval between probes
                    3);    // number of probes before declaring dead

After 60 seconds of no activity, the ESP32 sends a probe. If the probe is not ACKed within 10 seconds, it sends another. If 3 probes in a row fail, the connection is declared dead and client.connected() returns false. Your code then triggers a reconnect.

MQTT’s keep-alive (the PINGREQ/PINGRESP cycle) is the same pattern at the application layer. For raw TCP you need to enable it yourself.

Performance tuning (TCP_NODELAY, window size)

Two knobs you might want to set explicitly:

TCP_NODELAY (disable Nagle’s algorithm):

By default, TCP waits 40 ms after a small write before sending it, hoping more data will arrive that can be coalesced into one packet. This is Nagle’s algorithm. It is good for throughput, bad for latency.

For interactive protocols (chat, telnet, MQTT command responses), disable Nagle:

client.connect("192.168.1.50", 1234);
client.setNoDelay(true);   // disable Nagle, send each write immediately

For bulk transfer (file upload, sensor data dump), leave Nagle enabled.

TCP window size:

The TCP window controls how many bytes can be in flight before the sender must wait for an ACK. Larger window = more throughput on high-bandwidth, high-latency links. lwIP’s default window is small (about 4 KB) which is fine for LANs and slow WANs but can bottleneck a 10 Mbps satellite link.

For ESP32 specifically, the default window is fine for almost every use case. Do not tune this unless you have profiled and know it is the bottleneck.

Connecting to non-HTTP services (raw TCP)

This is the actual workflow for most IoT: you have a server that is not HTTP. Maybe it is a custom binary protocol, maybe it is a serial-over-TCP bridge, maybe it is a SCADA system.

The pattern is the same as the HTTP example: connect, write your message in the format the server expects, read the response in the format the server returns it. There is no parser library; you write the byte handling yourself.

For binary protocols, use client.write() with explicit byte arrays and client.read() to consume one byte at a time. For text protocols, client.println() and client.readStringUntil('\n') work fine.

The limit on simultaneous connections (5 default)

The lwIP stack on the ESP32 supports up to 5 simultaneous TCP connections by default. A 6th connection (from a new client or from WiFiClient::connect()) is refused or causes the oldest to drop, depending on which side is initiating.

If you need more, edit lwipopts.h:

#define MEMP_NUM_TCP_PCB 10

Each PCB is about 1 KB plus per-connection buffers. For a server that accepts many simultaneous clients (a dashboard with multiple websockets), the 5-connection limit is real. Either run multiple ESP32s, use UDP, or accept the limit.

When something breaks

  • “Connection refused.” The server is not listening on that port. Check the server is running. Check the port number. Check any firewall.
  • “Connection times out.” The server is unreachable. Check the IP address. Check routing. Check that the ESP32 is on the same network as the server.
  • “Connection drops after a few minutes.” TCP keep-alive is probably off and the server or network is killing idle connections. Enable keep-alive, or send application-layer pings, or both.
  • “Data gets corrupted on long messages.” You are reading before the entire message has arrived. Buffer until you have the whole thing (a length prefix, a terminator, a fixed size, whatever your protocol defines). TCP is a stream, not a message protocol; you have to frame your messages yourself.

What to build next

  • A simple TCP echo server on your laptop (socat TCP-LISTEN:1234 EXEC:cat) and an ESP32 client that connects, sends “hello,” and prints the response.
  • A line-based chat protocol between two ESP32s over your WiFi. Each is both client and server (alternating connection initiation). Adds the reconnect logic.
  • The MQTT tutorial if you have not done it yet. MQTT is just TCP with a defined application-layer protocol; understanding TCP first makes MQTT much clearer.
  • The HTTP server tutorial (esp32-http-server-in-depth) for the request-response pattern over TCP.