How TCP works
By Flavio Copes
Learn how TCP works by building a small Node.js server and client, sending data between them, and watching the connection from your terminal.
Every time you open a website, a small conversation starts between your computer and a server.
Your browser sends a request. The server sends back HTML, CSS, images, and other data.
TCP is often the protocol carrying that conversation.
Many application protocols are built on top of it. HTTP/1.1 and HTTP/2 use TCP for the web. SSH uses it for remote shells. SMTP and IMAP use it for email. FTP uses it for file transfers, and databases such as PostgreSQL and MySQL use their own protocols over TCP.
With HTTP/1.1 and HTTP/2, HTTPS adds TLS between HTTP and TCP so the conversation is encrypted.
We normally do not see it. We write an HTTP request, call an API, or connect to a database, and the operating system handles TCP for us.
But once a connection becomes slow, stops halfway through, or returns incomplete data, TCP suddenly matters.
In this tutorial we are going to make it visible.
We will build a tiny TCP server and client with Node.js. Then we will watch the connection open, exchange data, and close.
Along the way, we will see what TCP guarantees and what our application still has to handle.
The problem TCP solves
Imagine sending a long message across the Internet.
The message cannot travel as one giant piece. It is split into smaller packets. Those packets can take different routes, arrive in the wrong order, arrive twice, or never arrive at all.
IP moves packets between computers, but it does not promise that they will arrive correctly.
TCP builds a reliable connection on top of IP.
It gives an application an ordered stream of bytes. If part of the stream is lost, TCP sends it again. If data arrives out of order, TCP puts it back in order before giving it to the application.
The application sees a connection:
browser → TCP → IP → Internet → IP → TCP → web server
TCP takes care of moving the bytes correctly. The application can focus on what those bytes mean.
Let’s open a TCP connection
We will start with the smallest useful experiment.
Create a file called server.mjs:
import net from 'node:net'
const server = net.createServer((socket) => {
console.log('A client connected')
socket.write('Hello from the server!\n')
socket.on('data', (data) => {
console.log(`Client says: ${data}`)
})
socket.on('end', () => {
console.log('The client disconnected')
})
})
server.listen(8080, () => {
console.log('Server listening on port 8080')
})
Start it:
node server.mjs
The server now waits for TCP connections on port 8080.
Open another terminal and connect with Netcat:
nc localhost 8080
You should see:
Hello from the server!
Type a message and press Enter. The server prints it.
We now have a real TCP connection between two programs on the same computer.
Netcat is the client. Our Node.js program is the server. Both programs have a socket, which is the operating system interface to the connection.
Press ctrl+c in the Netcat terminal to close the client. The server prints:
The client disconnected
That small experiment already shows the complete lifecycle of a TCP connection:
- The server listens.
- The client connects.
- Both sides exchange bytes.
- The client closes the connection.
Now let’s look at what happened behind the scenes.
Addresses and ports bring the programs together
Our client connected to this address:
localhost:8080
localhost points to our own computer. 8080 is the port where the server is listening.
The client also has a port, even though we did not choose it. The operating system picks a temporary port automatically.
While the client is connected, open a third terminal and run this on macOS:
lsof -nP -iTCP:8080
You will see the listening server and the active connection. It will look similar to this:
node 50123 TCP 127.0.0.1:8080 (LISTEN)
node 50123 TCP 127.0.0.1:8080->127.0.0.1:53144 (ESTABLISHED)
nc 50145 TCP 127.0.0.1:53144->127.0.0.1:8080 (ESTABLISHED)
Port 8080 belongs to the listening server. Port 53144 is an example temporary client port. You will probably see a different number.
TCP identifies this connection using four values:
client IP + client port + server IP + server port
This is why many clients can connect to the same server port at once. Each connection has a different client address or client port.
The connection begins with a handshake
Before Netcat could send our message, the two operating systems had to agree to start a connection.
They did this with the three-way handshake:
client server
| -------- SYN ------------> |
| <----- SYN + ACK ---------- |
| -------- ACK ------------> |
The client first sends SYN, which means “I want to start a connection.”
The server replies with SYN + ACK: “I received your request, and I am ready too.”
The client sends the final ACK: “I received your reply.”
The connection is now established.
Why three steps?
Because TCP is full-duplex. Both sides can send data, so both sides must confirm that they can reach each other and agree on where their byte streams begin.
The handshake takes one network round trip. On localhost this is almost instant. Across a distant network, the travel time becomes noticeable.
HTTPS usually adds a TLS handshake after the TCP handshake. Only then can the browser send an encrypted HTTP request.
Let’s build the client too
Netcat is convenient, but a Node.js client lets us see both sides of the connection.
Create client.mjs:
import net from 'node:net'
const socket = net.createConnection({ port: 8080 }, () => {
console.log('Connected to the server')
socket.write('Hello from the client!\n')
})
socket.on('data', (data) => {
console.log(`Server says: ${data}`)
socket.end()
})
socket.on('end', () => {
console.log('Disconnected from the server')
})
Leave the server running and start the client:
node client.mjs
The client prints:
Connected to the server
Server says: Hello from the server!
Disconnected from the server
The server receives Hello from the client!, then sees the client disconnect.
Notice that neither program creates packets or manages sequence numbers. Node.js gives the bytes to the operating system. The operating system’s TCP implementation does the transport work.
This is how most applications use TCP.
TCP gives us a stream, not messages
Our first program contains a subtle trap.
The client calls:
socket.write('Hello from the client!\n')
The server receives a data event. It is tempting to assume that one write() produces one data event.
TCP does not promise that.
Suppose the client writes twice:
socket.write('hello')
socket.write('world')
The server might receive one chunk:
helloworld
It might receive two:
hello
world
Or it might receive several smaller chunks:
hel
lowor
ld
All three results are valid.
TCP preserves the order of the bytes. It does not preserve the boundaries between calls to write().
Our application must decide where one message ends and the next begins.
In the first example we used a newline. This is a simple protocol: each line is one message.
The server must collect incoming data until it finds a complete line:
let pending = ''
socket.on('data', (data) => {
pending += data.toString()
let newline = pending.indexOf('\n')
while (newline !== -1) {
const message = pending.slice(0, newline)
pending = pending.slice(newline + 1)
console.log(`Client says: ${message}`)
newline = pending.indexOf('\n')
}
})
Now the server handles both possibilities: half a message arriving in one chunk, or several messages arriving together.
HTTP solves the same problem with its own rules. Headers, content lengths, and chunked encoding tell the receiver how to separate data inside the TCP stream.
This is the most important TCP lesson for application developers:
A chunk of data is not necessarily a complete message.
How TCP keeps the stream reliable
Let’s leave localhost for a moment and imagine our client is on another continent.
The data crosses routers and networks we do not control. A packet might disappear or arrive after a later packet.
TCP keeps track of the stream using sequence numbers.
Think of every byte as having a position:
1000 1001 1002 1003 1004 1005
H e l l o \n
When the receiver sends an acknowledgment, it tells the sender the next byte it expects.
If it acknowledges 1006, every byte through 1005 arrived.
If a part of the stream is missing, the sender sends it again. TCP detects this using acknowledgments and timers.
The receiving TCP stack also removes duplicates and puts out-of-order data back in order before our Node.js program reads it.
This work is invisible in our application. We still read the same ordered stream.
There is one consequence worth knowing.
If an early packet is missing, later bytes must wait even if they already arrived. The application cannot receive byte 2000 while byte 1500 is still missing.
This is called head-of-line blocking.
HTTP/2 can carry many requests inside one TCP connection, so one lost packet can briefly hold up all of them. HTTP/3 uses QUIC instead of TCP so its independent streams can recover separately.
Reliability stops at the application
An acknowledgment does not mean the server finished its work.
It only means the receiving TCP stack accepted the bytes.
Imagine that our client sends an order. TCP delivers it successfully, but the server crashes before saving it to the database.
From TCP’s point of view, delivery worked. From the application’s point of view, the order failed.
This is why applications still need responses, transaction rules, timeouts, and safe retry behavior.
TCP can reliably deliver bytes. It cannot decide what those bytes mean.
A fast sender must know when to slow down
Reliable delivery is not enough.
A fast sender could overwhelm a slow receiver. It could also send more data than the network can carry.
TCP handles these as two different problems.
Flow control protects the receiver.
The receiver advertises how much data it can accept. If its buffers fill up, the sender slows down and waits for more space.
Congestion control protects the network.
The sender watches acknowledgments, delays, loss, and other signals. It increases its sending rate when the path is healthy and reduces it when the path appears congested.
The application normally does not implement those algorithms. The operating system does.
But the pressure eventually reaches our code.
In Node.js, socket.write() returns false when data is buffering faster than it can be sent. We should wait for the drain event before writing more:
if (!socket.write(data)) {
socket.once('drain', sendMore)
}
This is called backpressure. You can read more about the same idea in the Node.js streams guide.
Ignoring backpressure can make an application use more and more memory while the network or receiver struggles to keep up.
Watch the packets yourself
The easiest way to understand TCP is to watch our small conversation.
Wireshark gives you a graphical interface. On macOS and Linux, you can also use tcpdump from the terminal.
Start the server, then capture traffic on port 8080:
sudo tcpdump -i lo0 -n 'tcp port 8080'
On Linux, the loopback interface is normally called lo:
sudo tcpdump -i lo -n 'tcp port 8080'
Now run the client again.
You should see the opening SYN, SYN-ACK, and ACK. Then you will see packets carrying data and acknowledgments. Finally, you will see the connection close.
The exact output is noisy. Focus on the flags first:
Flags [S] SYN
Flags [S.] SYN + ACK
Flags [.] ACK
Flags [P.] data + ACK
Flags [F.] FIN + ACK
This is our program’s short story written as packets.
Do this only on systems and networks you are authorized to inspect.
How a TCP connection closes
TCP can carry data in both directions at the same time. Each direction also closes independently.
When the client calls:
socket.end()
it says, “I have no more data to send.”
TCP normally communicates this with a FIN flag. The other side acknowledges it, finishes sending any remaining data, and closes its own direction.
A simplified close looks like this:
client server
| -------- FIN ------------> |
| <------- ACK ------------- |
| <------- FIN ------------- |
| -------- ACK ------------> |
The side that closes first often enters a state called TIME_WAIT for a short period.
This is normal. It gives delayed packets time to disappear and lets the final acknowledgment be sent again if needed.
A connection can also end abruptly with RST, which means reset. You might see this when no program is listening on the destination port or when a process terminates a socket without a normal close.
A connection can look alive when it is not
Suppose the client and server stop sending data. Then somebody unplugs a cable.
Neither computer may notice immediately.
TCP does not constantly ask whether a quiet connection is still alive. The broken path usually becomes visible when one side tries to send again, a timeout expires, or keepalive probes fail.
Applications should set timeouts based on what they can tolerate.
A user-facing HTTP request might need a short deadline. A database connection pool may allow a longer idle period. A chat application can send its own ping messages.
TCP keepalive can help find dead peers, but operating system defaults are often too slow for application-level decisions.
What TCP does not do
Our experiment showed that TCP gives us a connection and an ordered, reliable byte stream.
It does not give us:
- message boundaries
- encryption
- proof of the peer’s identity
- proof that the application processed the data
- a connection that lasts forever
- fixed speed or latency
TLS adds encryption and authentication. HTTP defines requests and responses. Our newline rule defined messages for the small protocol we built.
Each layer solves a different problem.
TCP compared to UDP
UDP is the other transport protocol you will often encounter.
TCP gives applications an ordered byte stream. UDP gives applications separate datagrams.
UDP does not perform a TCP-style handshake. It also does not promise delivery, ordering, duplicate removal, or retransmission.
That makes UDP useful when message boundaries and low setup cost matter, or when an application wants to choose its own reliability behavior.
QUIC is a good example. It runs over UDP, then adds secure connections, reliability, congestion control, and independent streams. HTTP/3 runs on top of QUIC.
TCP remains a great default when an application needs a reliable stream and does not need to invent its own transport.
How I use this mental model
I rarely change TCP settings in a normal web application. The operating system already has decades of work in its TCP implementation.
I use this mental model to avoid bugs and debug problems.
When I read from a socket, I do not assume one chunk is one message.
When I send a lot of data, I respect backpressure.
When an operation matters, I require an application-level response. A TCP acknowledgment is not enough.
When a request is slow, I separate the stages: DNS lookup, TCP connection, TLS handshake, server work, and response transfer.
And when a connection can wait forever, I add a timeout.
You do not need to memorize the TCP header or every state in its state machine to use TCP well.
Remember the story:
- A server listens on an address and port.
- A client opens a connection with a three-way handshake.
- Both sides exchange an ordered stream of bytes.
- TCP acknowledges data and resends anything lost.
- The application defines messages, timeouts, and what success means.
- Each side closes its direction when it is done.
That is the practical mental model I carry when I work with TCP.
Related posts about network: