Skip to content
Software Atlas

TCP vs UDP

The two ways programs send data across the internet: TCP's reliable, ordered byte stream (handshakes, retransmission, flow and congestion control) and UDP's fire-and-forget datagrams. Plus what that means for your Go and Node code: framing, timeouts, Nagle, TIME_WAIT and head-of-line blocking.

⏱ ~45 min read14 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceTCP is a phone call: you connect first, everything you say arrives in order, and if the line drops you know. UDP is a stack of postcards: you just send, and each one may arrive, arrive late, or never arrive.

When a Go program calls net.Dial("tcp", …) or a browser loads a web page, the data is chopped into small packets that travel across the internet on their own. The internet makes no promises about them: packets get lost, arrive out of order, or arrive twice. TCP and UDP are two different answers to "what should we do about that?"

The same three messages, two ways
TCP: a phone call
client
dial (SYN…)
msg 1
msg 2
msg 3
hang up
server
pick up
got 1 ✓
got 2 ✓
got 3 ✓
bye
UDP: postcards
client
card 1
card 2
card 3
server
got 3
got 1
2: lost
TCP spends one round trip setting up, then delivers every message, in order, or tells you the connection broke. UDP sends immediately, and whatever happens to each postcard happens silently.
1

TCP gives you

A connection, every byte delivered in order, lost data resent automatically, and speed control so you don't flood the receiver or the network. HTTP/1.1, HTTP/2, databases, SSH and email all use it.
2

UDP gives you

Just send this one message to that address. No connection, no ordering, no resending, no speed control. Fast and simple. DNS lookups, video calls, online games and HTTP/3 use it.
3

The trade-off

TCP's guarantees cost time: a setup round trip, and waiting for lost data before anything after it can be used. UDP never waits, so you build exactly the guarantees you need on top, or none.

What you'll learn

How a TCP connection is opened (§03) and closed (§09); how it recovers lost data (§04); why one Write doesn't equal one Read and how to fix it (§05); how TCP decides how fast to send (§06); why one lost packet can stall a whole web page (§07); how to use UDP from Go and Node (§08); and the production traps: a mysterious 40 ms delay (§10), connection costs, timeouts and port exhaustion (§11).

Foundations§02

What the network really gives you

In one sentenceThe internet only promises to try to deliver each packet. Everything else, like order, completeness and "did it arrive?", is somebody else's job: TCP's, or yours.

Packets, not files

When your program sends 1 MB, it doesn't travel as one piece. It's cut into packets of about 1,500 bytes each (the MTU of a normal network). Each packet carries a small header with the sender's and receiver's IP addresses, and travels on its own through a chain of routers, each one passing it one hop closer to the destination.

That design is cheap and robust, but it has consequences. Press the button and watch what can happen to five packets:

What the network does to your packets
Your laptop sends five packets, numbered 1 to 5, to a server. Nothing on top fixes anything yet: this is the raw network.
laptopthe internet: many routers, many pathsserver
server received, in this order: —
Press send and watch the right-hand edge.

Here's why each of those things happens:

  • Lost. A router's queue is full (too much traffic at once), so it simply throws the packet away. This is normal, not an error: on a busy link, a small percentage of packets is dropped all the time.
  • Reordered. Two packets can take different routes, or sit in different queues, so a later one can overtake an earlier one.
  • Duplicated. Rare, but it happens: a piece of network equipment resends a packet it wrongly thought was lost, and both copies arrive.
  • Damaged. Bits can flip on the way. Each packet carries a Checksum, and a damaged packet is dropped, which turns "damaged" into "lost".

The network doesn't tell anyone about any of this. This "do your best, promise nothing" service is called best effort.

Ports: which program gets the packet?

An IP address gets a packet to the right machine. But one machine runs many programs: a web server, a database, your browser. A Port number (0–65535) picks the program, like an apartment number after the street address. A web server usually listens on port 443; Postgres on 5432.

Both TCP and UDP put the source port and destination port at the start of each packet's Payload. That's the one thing they have in common. Everything after that is different.

Sockets: your program's end of it

Your code never touches packets directly. It asks the operating system (the Operating system (OS) / kernel) for a Socket: a handle you write bytes into and read bytes out of. The OS kernel then does all the TCP or UDP work (numbering, resending, waiting) behind that handle.

the two kinds of socket in Go
tcpConn, err := net.Dial("tcp", "example.com:80")   // a TCP connection
udpConn, err := net.Dial("udp", "8.8.8.8:53")       // a UDP "connection" (just a remembered address)
Foundations§03

Opening a connection: the three-way handshake

In one sentenceBefore any data flows, TCP spends one round trip on a Handshake: "can we talk?" → "yes, can we?" → "yes". Both sides learn the other is alive, and agree on the numbers they'll use to keep bytes in order.

Segments and sequence numbers, first

TCP calls each chunk it sends a segment: a small header plus some of your data, sent inside one packet. Two header fields matter most:

  • The sequence number says where this segment's first byte sits in the stream. If I start at 1000 and send 100 bytes, they're bytes 1000–1099, and my next segment starts at 1100.
  • The ACK number is the receiver's reply: "ack=1100" means "I have everything before 1100; send 1100 next."

There are also a few flags, on/off bits that say what a segment is for. The handshake uses two: SYN ("let's synchronize our numbers") and ACK ("my ack number is meaningful").

The handshake, step by step

Step through it. Watch the numbers: each side picks its own starting number, and the other side acknowledges it by replying with that number plus one.

Opening a connection: the three-way handshake
Time flows downwards. Each arrow is one TCP segment crossing the network; a sloped arrow because it takes time to arrive.
client state
CLOSED
server state
LISTEN
clientserver
The server has called Listen, so its OS is waiting for connections on a port. Its state is LISTEN. The client hasn't done anything yet: CLOSED.
step 0 / 5

Why three messages, not two? Each side needs to hear "I got your starting number." The client's SYN gets acknowledged in step 2, the server's SYN in step 3. Step 2 does two jobs at once (the server's ACK of the client and the server's own SYN), which is why it's three messages and not four.

The cost: one round trip

The client can't send data until the SYN-ACK comes back. That's one round-trip time, the time for a message to go there and the reply to come back. Some real numbers (approximate):

PathRTTHandshake cost
Two servers in the same data center~0.5 msnegligible
Your laptop to a nearby cloud region~20 ms20 ms
Europe to US west coast~150 ms150 ms, before a single byte of your request

Add TLS for HTTPS and you pay another round trip or more on top (see TLS). This is the main reason production systems reuse connections instead of opening a new one per request (§11).

In code

The OS keeps track of each connection's state (LISTEN, ESTABLISHED, …), like the labels in the widget above; ss shows them (end of this section).

From your program, the whole handshake is one call. On the server, the OS completes handshakes on its own and queues the finished connections; Accept just takes the next one.

These are two separate programs: run the server in one terminal, the client in another.

server and client: separate programs
ln, err := net.Listen("tcp", ":8080") // state: LISTEN
if err != nil {
    log.Fatal(err)
}
for {
    conn, err := ln.Accept() // returns a connection whose handshake is already done
    if err != nil {
        continue
    }
    go handle(conn) // one goroutine per connection
}

If nobody listens on that port, the server's OS answers the SYN with an RST segment ("no such connection") and your Dial fails immediately with connection refused. If the machine doesn't answer at all (it's down, or a Firewall silently drops the SYN), the client resends its SYN with growing gaps, and on Linux gives up only after about 2 minutes. Always set a connect timeout (§11).

See it for real

tcpdump prints every packet. Run it in one terminal, then curl http://example.com in another:

sudo tcpdump -n -i any 'tcp port 80'
# client > server: Flags [S],  seq 1983547913                       ← SYN
# server > client: Flags [S.], seq 2477113491, ack 1983547914       ← SYN-ACK ("." = ACK)
# client > server: Flags [.],  ack 1                                ← ACK (tcpdump now shows relative numbers)
# client > server: Flags [P.], seq 1:76, ack 1, length 75           ← the HTTP request (P = push: hand it to the app now)

And ss lists your machine's connections with their state:

ss -tan            # -t TCP, -a all (incl. listening), -n numeric ports
# State    Local Address:Port    Peer Address:Port
# LISTEN   0.0.0.0:8080          0.0.0.0:*
# ESTAB    10.0.0.5:51234        93.184.216.34:80
Practitioner§04

Reliability: ACKs, timers and retransmission

In one sentenceThe receiver keeps saying "I have everything up to byte N." If the sender doesn't hear about some bytes in time, or hears the same N three times, it sends them again. The receiver holds back anything that arrives early, so your program always reads bytes in order.

The two pieces

To keep things readable, from here on we number whole segments 1, 2, 3… instead of bytes. Real TCP numbers bytes (§03), but the logic is identical.

Cumulative ACKs. An ACK doesn't say "I got segment 5". It says "I have every byte before N; the next one I expect is N." So if segments 1, 2 and 4 arrived, the receiver keeps answering "next I expect 3", even after 4 arrives. Those repeats are duplicate ACKs.

A receive buffer. Data that arrives early (segment 4 while 3 is missing) waits in the OS's socket buffer. Your program's Read never sees it until the gap is filled. This is how TCP turns "1, 2, 4, 5, 3" back into "1, 2, 3, 4, 5".

Two ways to notice a loss

  1. The timer. When the sender sends a segment, it starts a retransmission timeout (RTO). If no ACK covers it before the timer fires, it resends. The RTO is based on measured round trips, so it adapts: about 200 ms is the Linux minimum, and it doubles on every failed retry.
  2. Three duplicate ACKs. If the sender keeps getting "next I expect 3" while it has already sent 4, 5 and 6, then 3 is almost certainly lost. It resends 3 right away, without waiting for the timer. That's fast retransmit. Why three and not one? Packets are sometimes just reordered; one or two duplicates might only mean 3 is a little late.

Try it. Start with the defaults (lose #3, fast retransmit on), then switch to timeout only, then lose #6:

Lossy link: watch TCP recover
Pick which segment gets lost and how the sender may react, then step through. Segments are numbered 1–6 here; real TCP numbers bytes. (Simplified: one fixed timer for the lost segment.)
lose segment
Senderdup ACKs: 0 · timer: off
1
2
3
4
5
6
purple = sent, waiting for ACK · amber = resent · green = acknowledged
Receiver bufferapp has read: —
·
·
·
·
·
·
amber = arrived early, held back · green = handed to the app, in order
t = 0 msThe sender has 6 segments to send. Segment 3 will be lost on the way. Each one takes 50 ms to cross; so does each ACK coming back. Press Next.
step 0 / 19

What you should have seen, with numbers (one-way trip 50 ms, RTO 300 ms in this sim):

ScenarioAll 6 delivered atWhy
No loss150 mslast segment sent at 50, plus 50 there, plus 50 for the ACK back
Lose #3, fast retransmit251 ms3 duplicate ACKs arrive quickly, so 3 is resent at 151 ms
Lose #3, timeout only421 msthe sender sits idle until the 300 ms timer fires
Lose #6 (the last one)451 ms, even with fast retransmitno segments come after it, so no duplicate ACKs: only the timer can help (#5 is almost as bad: just one duplicate)

That last row is a real production effect: losing the last packet of a response (a "tail loss") is much slower to repair than losing one in the middle. Modern Linux sends a small probe early to cut this down, but the shape stays the same.

Sending without waiting

Notice the sender didn't wait for ACK 1 before sending 2. If it did ("stop and wait"), a 100 ms RTT would limit it to one segment per 100 ms: about 15 KB/s, whatever the link speed. Instead, TCP keeps many segments in flight at once: each returning ACK lets it send another. Compare:

Waiting for every ACK vs keeping a window in flight
RTT = 100 ms (50 ms each way). Data segments travel right on the top track; ACKs travel back on the bottom one. Each ACK lets the sender send one more.
senderreceiverdata →← ACKs
t = 0 ms · delivered 0 segments ≈ 0.0 KB
One segment = 1,460 bytes. Press send and compare the delivered count for each window size.

How many segments to keep in flight is the subject of §06.

Practitioner§05

TCP is a byte stream: the framing problem

In one sentenceTCP delivers bytes in order, not messages. Two Write calls can come out as one Read, and one Write can come out as three. If your protocol has messages, you must mark where each one ends.

The surprise

TCP is a byte stream: think of a water pipe, not a conveyor belt of boxes. You pour bytes in at one end; they come out at the other in the same order, but the pipe doesn't remember how you poured them. Here is a client that writes "hello" and then "world", and a server that reads once a moment later. Both programs were run on Go 1.24 and Node 22:

coalesce.go
ln, err := net.Listen("tcp", "127.0.0.1:0") // port 0 = "pick any free port"
if err != nil {
    log.Fatal(err)
}

go func() { // client, in its own goroutine
    conn, _ := net.Dial("tcp", ln.Addr().String())
    conn.Write([]byte("hello"))
    conn.Write([]byte("world"))
    conn.Close()
}()

conn, _ := ln.Accept()             // server
time.Sleep(100 * time.Millisecond) // let both writes arrive first
buf := make([]byte, 1024)
n, _ := conn.Read(buf)             // ← one read…
fmt.Printf("read %d bytes: %q\n", n, buf[:n])
// read 10 bytes: "helloworld"     ← …gets both writes, glued together

It also goes the other way: a 100 KB write arrives as many reads of whatever size the network and the OS felt like (often 1–64 KB). The splits depend on timing, packet sizes and buffer sizes, so code that assumes one write = one read passes every test on your laptop (loopback is fast and tidy) and then breaks in production, randomly.

The fix: framing

Framing means putting markers in the stream so the receiver can cut it back into messages. The two common ways:

  • Length prefix. Before each message, write its length as a fixed-size number (say 4 bytes). The receiver reads 4 bytes, learns "the next message is 5 bytes", reads exactly 5 more. Used by gRPC, Postgres, Kafka, HTTP/2.
  • Delimiter. End each message with a special byte sequence that never appears inside a message, usually a newline (\n, or \r\n in many older protocols). Simple and human-readable. Used by SMTP, Redis (partly), line-delimited JSON, and HTTP/1.1's headers.

Try all three readers against different chunkings:

Writes are not reads
The sender calls write() three times. TCP may deliver those bytes in any chunking it likes. Choose how the receiver splits the stream back into messages.
Sender: three write() calls
write("hello")write("world")write("hi!")
The byte stream, cut into reads by the network3 reads
helloworldhi!
network chunking:
Receiver: what your code gets
read #1:"hell"→message "hell"
read #2:"oworl"→message "oworl"
read #3:"dhi!"→message "dhi!"
Wrong messages. Treating each read as one message only works when reads happen to line up with writes, and TCP never promises that.

Length prefix in Go and Node

frame.go
// writeMsg sends one message: a 4-byte length, then the bytes.
func writeMsg(w io.Writer, msg []byte) error {
    var hdr [4]byte
    binary.BigEndian.PutUint32(hdr[:], uint32(len(msg))) // length → 4 bytes
    if _, err := w.Write(hdr[:]); err != nil {
        return err
    }
    _, err := w.Write(msg)
    return err
}

// readMsg reads exactly one message, however the bytes were split up.
func readMsg(r io.Reader) ([]byte, error) {
    var hdr [4]byte
    if _, err := io.ReadFull(r, hdr[:]); err != nil { // ← waits for all 4 bytes
        return nil, err
    }
    n := binary.BigEndian.Uint32(hdr[:])
    if n > 1<<20 { // refuse anything over 1 MiB: never trust a length from the network
        return nil, fmt.Errorf("message too big: %d bytes", n)
    }
    msg := make([]byte, n)
    _, err := io.ReadFull(r, msg) // ← waits for all n bytes
    return msg, err
}

// usage: wrap the connection in a buffered reader, then loop
r := bufio.NewReader(conn)
for {
    msg, err := readMsg(r)
    if err != nil {
        return // io.EOF = the other side closed cleanly
    }
    fmt.Printf("message: %q\n", msg)
}

Delimiter framing in Go

For newline-separated messages, Go's bufio.Scanner does all of it for you:

lines.go
sc := bufio.NewScanner(conn) // splits on '\n' by default
for sc.Scan() {
    fmt.Printf("line: %q\n", sc.Text()) // one complete line, without the '\n'
}
if err := sc.Err(); err != nil { // nil at a clean end of stream
    log.Println("read error:", err)
}
// even if the client sent "hello\nwor" then "ld\n", this prints "hello" then "world"

In Node, readline.createInterface({ input: sock }) gives you a "line" event per line. A Scanner refuses lines over 64 KB by default (bufio.Scanner: token too long); call sc.Buffer(…) to raise it.

UDP doesn't have this problem at all: every read returns exactly one whole message (§08). It has other problems instead.

Practitioner§06

How fast to send: flow control and congestion control

In one sentenceTCP sends as much as it can without waiting for ACKs, but never more than the smaller of two limits: what the receiver has room for, and what TCP guesses the network can carry.

§04 showed that TCP keeps several segments in flight at once. Too few, and a fast link sits idle. Too many, and something overflows. There are two different things that can overflow, so there are two limits.

Flow control: don't drown the receiver

The receiving program may be slow: busy, or just reading slowly. Unread bytes pile up in its socket buffer. So every ACK also carries the receive window: "I have room for 48 KB more." The sender never has more unacknowledged data out than that.

Flow control: a slow reader slows the writer
The sending program writes as fast as it can. Change how fast the receiving program reads and watch the receive window that every ACK advertises.
Sender's send buffer0 / 8
the program's writes are accepted
Receiver's receive buffer0 / 16
ACKs advertise window = 16 · app has read 0
tick 0 · the sender sends at most 4 units per tick
Everything flows: the reader keeps up, the buffer stays nearly empty and the advertised window stays large. Try reader slow or reader stopped.

If the receiver stops reading entirely, the window shrinks to zero and the sender stops. In Go, the sender's Write then blocks once its own send buffer is full; in Node, write() starts returning false. That's TCP's built-in Backpressure, and it's why a slow consumer slows the producer down instead of crashing it. Worker Pools §06 shows how to pass that signal through your own code.

Congestion control: don't drown the network

Between the two machines are routers shared with everyone else. If every sender blasts at full speed, router queues overflow and packets get dropped, which causes retransmissions, which add more traffic. In 1986 this actually happened: parts of the early internet slowed to about 1/1000th of their capacity.

The fix (by Van Jacobson, 1988) is that each sender keeps a congestion window (cwnd): its own guess at how much the path can take. Nobody tells it the right number; it probes:

  1. Slow start: start small (10 segments, about 14 KB, on Linux) and double every round trip: 10, 20, 40, 80…
  2. When a loss shows up, the guess was too high. Remember half of it as the slow-start threshold.
  3. From then on, grow by just +1 segment per round trip, and halve on each loss. This phase is called congestion avoidance; the grow-slowly, cut-sharply rule is called AIMD, and it draws a sawtooth. This classic version of the algorithm is known as Reno.

Run it with the real path (it drops packets above 60 segments), then try the buttons:

The congestion window over time
One point per round trip. Run it, then press the loss buttons yourself. The dotted line is what the network path can actually carry; the sender can't see it.
0306090round trips →cwnd (segments)what the path can carry (60)
cwnd 10 segments
phase slow start
≈ 2.3 Mbit/s at 50 ms RTT
A new connection starts with a congestion window of 10 segments (Linux's default).

Two kinds of "loss"

  • 3 duplicate ACKs (§04): one segment went missing, but later ones are still arriving, so the network is mostly working. Halve the window and carry on.
  • A retransmission timeout: nothing came back at all. That could be heavy congestion, so start over from 1 segment and slow-start again. One timeout can cost a connection seconds of throughput.

Put both together: the sender may have at most min(cwnd, rwnd) bytes in flight. Usually rwnd is large and cwnd is the real limit.

Why this matters for your code

A brand-new connection is slow. It starts at 10 segments and needs several round trips to ramp up. A 1 MB download (about 700 segments) over a fresh connection with 100 ms RTT sends 10, 20, 40, 80, 160, 320… segments per round trip, so it needs about 7 round trips, ≈ 0.7 s. A warmed-up connection whose window is already large could do it in one or two. A warmed-up, reused connection skips all of that. This, plus the handshake, is why connection pools matter (§11).

Practitioner§07

Head-of-line blocking: one loss stalls everything

In one sentenceTCP's "everything in order" promise has a price: if one segment is lost, nothing behind it can be handed to your program until it's resent, even data that has already arrived and has nothing to do with the lost bit.

That's Head-of-line blocking: the item at the front of the line is stuck, so everyone behind it waits. With TCP it doesn't matter much when the connection carries one thing (one file download): you need all the bytes anyway. It matters a lot when one connection carries several independent things.

The HTTP/2 problem

HTTP/2 does exactly that. To avoid opening many connections (each costing a handshake and slow start), it sends all of a page's requests over one TCP connection at the same time, chopping each response into small pieces and interleaving them. That's multiplexing, and it's a great idea, until a packet is lost.

TCP doesn't know about HTTP/2's streams. It sees one long byte stream. Play it, then switch to QUIC:

Head-of-line blocking
Three files download over one connection, their packets interleaved. Packet A2 is lost and only arrives again at tick 12. Who has to wait?
Packets on the wire (send order)tick 0
A1B1C1A2B2C2A3B3C3
Arrived but held back
nothing waiting
Delivered to the browser
A · style.css
B · app.js
C · logo.png
Press play. Packets arrive one per tick.
step 0 / 12

On a clean network this rarely matters. On a lossy one (mobile, crowded Wi-Fi, 1–2% loss), HTTP/2 can actually do worse than HTTP/1.1, which used ~6 separate TCP connections per site: a loss on one of them stalled only that one.

The fix: move the streams below the ordering

You can't fix this inside TCP: in-order delivery of one byte stream is the whole definition. So the fix was to build a new transport, QUIC, that knows about streams. Each stream has its own ordering: a lost packet for stream A only holds up stream A.

Why build it on UDP? Because UDP is the thinnest thing that gets packets through today's internet. Every IP packet carries a small label saying what's inside it (TCP, UDP, …), and a brand-new label would be blocked by countless firewalls, NAT boxes and routers that only understand TCP and UDP. So QUIC sends UDP datagrams and does everything TCP does (handshake, ACKs, retransmission, congestion control) itself, in the application instead of the kernel, plus TLS encryption built in. HTTP/3 is HTTP on top of QUIC.

HTTP/2 over TCPHTTP/3 over QUIC (over UDP)
Orderingone ordered byte stream for all requestsindependent streams, each in order
One lost packet stallsevery streamonly its own stream
Setup before the first requestTCP handshake + TLS handshake: 2+ round tripscombined handshake: 1 round trip (0 when reconnecting to a server you've talked to recently)
Lives inthe OS kernela library inside your program

The details of HTTP/2 and HTTP/3 (frames, header compression, connection migration) are in the HTTP topic.

Practitioner§08

UDP: datagrams, and when postcards win

In one sentenceUDP sends one datagram to an address and forgets about it. No handshake, no ACKs, no resending, no ordering, no speed control. What you get in exchange: zero setup, no waiting on lost data, and message boundaries kept.

A UDP datagram is a postcard: one message, sent on its own, delivered whole or not at all. UDP's whole header is 8 bytes: source port, destination port, length, Checksum. That's all it adds to a raw IP packet.

An echo server in Go and Node

There's no Listen + Accept and no per-client connection. The server has one socket and every datagram arrives on it, tagged with who sent it:

udp_echo.go
conn, err := net.ListenUDP("udp", &net.UDPAddr{Port: 9000}) // no Accept: one socket for everyone
if err != nil {
    log.Fatal(err)
}
defer conn.Close()

buf := make([]byte, 1500)
for {
    n, from, err := conn.ReadFromUDP(buf) // one call = one whole datagram
    if err != nil {
        return
    }
    conn.WriteToUDP(buf[:n], from) // reply to whoever sent it
}

And a client. A Go "UDP dial" sends nothing; it just remembers the address, so later Writes know where to go:

udp_client.go
c, err := net.Dial("udp", "127.0.0.1:9000") // no handshake: nothing is sent yet
if err != nil {
    log.Fatal(err)
}
defer c.Close()

c.Write([]byte("hello"))
c.Write([]byte("world"))

c.SetReadDeadline(time.Now().Add(time.Second)) // a reply may never come: always set a deadline
buf := make([]byte, 1500)
for i := 0; i < 2; i++ {
    n, err := c.Read(buf)
    if err != nil {
        log.Println("gave up:", err) // read udp …: i/o timeout
        return
    }
    fmt.Printf("got %q\n", buf[:n]) // got "hello", then got "world": never "helloworld"
}

Notice the difference from §05: two sends arrive as two reads. UDP keeps message boundaries, so you don't need framing. The trade is that any of those messages can silently vanish, arrive twice, or arrive out of order (§02), and there's no error when that happens.

Things that surprise people

  • A small buffer silently cuts the message. If a datagram is bigger than your read buffer, you get the first part and the rest is thrown away, with no error in Go. Use a buffer at least as big as your largest datagram.
  • Big datagrams are a bad idea. The protocol allows up to about 64 KB (Node: send EMSGSIZE above that), but anything over the path's MTU (~1,500 bytes) gets split by IP into several packets called fragments, and if one fragment is lost the whole datagram is gone. Keep datagrams under ~1,200 bytes; that's what QUIC does.
  • "Connection refused" can show up on a read. If nobody listens on the port, the other machine may send back an error packet, and Go reports it on your next Read as read: connection refused. Often nothing comes back at all.
  • No speed control. UDP will happily send faster than the network or receiver can take; the excess is just dropped. If you send a lot, you need your own pacing.

When to choose UDP

Use caseWhy UDP fits
DNS lookupsOne small question, one small answer. A TCP handshake would double the time; if the reply is lost, just ask again.
Voice and video callsA packet that arrives 300 ms late is useless: you'd rather skip 20 ms of audio than freeze. TCP would stall everything to resend it (§07).
Online gamesOnly the latest position matters. A lost update is replaced by the next one 16 ms later.
Metrics (StatsD) and logs over a LANFire and forget; losing 0.1% of samples is fine, blocking the app is not.
QUIC / HTTP/3, the WireGuard VPNThey build their own reliability and encryption on top, exactly the way they want it.

If you need every byte, in order, use TCP. Rebuilding reliability on UDP well (ACKs, timers, congestion control, §04 and §06) is a lot of hard work; use a library like QUIC rather than inventing your own.

Practitioner§09

Closing a connection: FIN, RST and TIME_WAIT

In one sentenceEach direction of a TCP connection is closed separately with a FIN. The side that closes first then waits in TIME_WAIT for a minute (on Linux), so a late packet from the old connection can't confuse a new one.

The polite goodbye

A TCP connection is really two one-way streams: client → server and server → client. Close() sends a FIN on your direction, meaning "I'm done sending." The other side acknowledges it, and later sends its own FIN when it's done. Four segments in all. Step through it:

Closing a connection: FIN, ACK, TIME_WAIT
Time flows downwards. Each arrow is one TCP segment crossing the network; a sloped arrow because it takes time to arrive.
client state
ESTABLISHED
server state
ESTABLISHED
clientserver
The request is done and the client decides to hang up. Each direction of a TCP connection is closed separately, so this takes four segments.
step 0 / 5

On the receiving side, a FIN shows up as end of stream: Go's Read returns io.EOF, Node emits "end". That's how a reader knows "there will be no more data" as opposed to "no data yet".

The rude goodbye: RST

An RST (reset) ends a connection instantly, with no FIN dance. You'll see it when:

  • a packet arrives for a connection the other machine doesn't know about (e.g. it rebooted) → connection reset by peer (ECONNRESET in Node);
  • a program closes a socket while unread data is still sitting in its receive buffer: the kernel sends RST instead of FIN to signal "data was thrown away";
  • a Load balancer or Firewall forgets an idle connection and rejects the next packet.

ECONNRESET in your logs usually means "the other side, or something in between, gave up on this connection". It's common with idle pooled connections (§11).

TIME_WAIT: why it exists

After the final ACK, the side that closed first doesn't forget the connection straight away. It stays in TIME_WAIT for twice the maximum segment lifetime; on Linux that's fixed at 60 seconds. Two reasons:

  1. Late packets. A segment from this connection might still be wandering around the network, delayed in some queue. If a brand-new connection with the exact same 4-tuple (both IPs, both ports) started right away, that old segment could land in it as if it were new data. TIME_WAIT keeps that 4-tuple reserved until any stray packets have died out.
  2. A lost final ACK. If the last ACK is lost, the other side resends its FIN. The side in TIME_WAIT is still around to answer it, instead of replying with an RST that would turn a clean close into an error.

Why servers see thousands of them

TIME_WAIT is held by whichever side closes first. Each one costs only a little kernel memory, so thousands are harmless in themselves. You'll see them in ss:

ss -tan state time-wait | wc -l       # how many sockets are in TIME_WAIT right now
ss -tan state time-wait | head -3
# Recv-Q Send-Q  Local Address:Port   Peer Address:Port
# 0      0       10.0.0.5:44312       10.0.0.9:5432
# 0      0       10.0.0.5:44318       10.0.0.9:5432

The real trouble is on the client side of busy services, for example an API server that opens a new connection to the same database or upstream service for every request. Each of those connections uses a fresh ephemeral port for its own end, and every closed one keeps its port tied up for 60 s. Linux has about 28,000 of those ports by default, so:

28,000 ports ÷ 60 s ≈ 470 new connections per second to the same destination, and then connect starts failing with cannot assign requested address (EADDRNOTAVAIL).

Watch it happen, then turn on connection reuse:

Running out of ephemeral ports
Each square is 100 of Linux's ~28,000 ephemeral ports. Every connection you open and close to the same server keeps its port in TIME_WAIT for 60 seconds. Time runs 9× faster here.
new connections per second
t = 0 s · ports in use 0 · failed connects 0
Press run. For the first 60 seconds nothing expires, so the grid only fills up.

That's ephemeral port exhaustion, and §11 covers the real fix: reuse connections instead of closing them.

Staff-level§10

The 40 ms mystery: Nagle and delayed ACKs

In one sentenceTwo old TCP optimizations, each sensible alone, can make a small request wait about 40 ms for nothing. The fix is one socket option, TCP_NODELAY, which Go turns on for you and Node's net module doesn't.

Two good ideas

  • Nagle's algorithm (1984), on the sender: if you write tiny pieces (one keystroke over a remote terminal = 1 byte of data + 40 bytes of headers), the network fills with mostly-header packets. So: while any sent data is still unacknowledged, hold new small writes and glue them together until the ACK arrives.
  • Delayed ACK, on the receiver: don't send an ACK for every segment right away. Wait a little, in case you can ACK two segments at once or put the ACK on your reply for free. Linux waits at least 40 ms; other systems up to 200 ms.

The trap

Now a client writes one request in two small pieces (a header, then a body) and waits for the reply:

  1. Piece 1 goes out immediately (nothing is unacknowledged yet).
  2. Piece 2 is small and piece 1 isn't acknowledged, so Nagle holds it.
  3. The server got piece 1, but can't reply until it has the whole request. With no reply to piggy-back on, delayed ACK waits for its timer.
  4. Each side waits for the other until the delayed-ACK timer fires (≈40 ms). Then the ACK goes, piece 2 goes, the reply comes.
The 40 ms trap: Nagle + delayed ACK
Nagle on (default for a plain Node net.Socket)
client
piece 2 held back: piece 1 not ACKed yet
2
server
has piece 1; delayed-ACK timer (≈40 ms) running
ACK
reply
TCP_NODELAY on (Go's default; setNoDelay(true) in Node)
client
server
reply
The client writes a request in two small pieces. Nagle holds piece 2 until piece 1 is acknowledged; the server delays that ACK hoping to piggy-back it on a reply, but it can't reply until it has piece 2. Both wait for the other until the delayed-ACK timer gives up. With TCP_NODELAY the whole thing takes well under a millisecond on the same machine.

This isn't theory. Here's a measurement from a Linux machine (Node 22, client and server on the same host, the client writes 5 bytes, then another 5 bytes, then waits for a 2-byte reply; median of 20 rounds):

Client socketTime per request
default net.connect (Nagle on)44 ms
net.connect({ noDelay: true })0.04 ms

A thousand times slower, on a machine where the network round trip is ~0.03 ms. On a real network the stall is the same 40 ms (or 200 ms with a Windows peer) on top of the normal round trip.

The fix

Go: already on
conn, err := net.Dial("tcp", "db.internal:5432")
if err != nil {
    log.Fatal(err)
}
// Go sets TCP_NODELAY on every TCP connection it creates (dialed or accepted),
// so Nagle is already off. You'd only call this to turn Nagle back ON:
conn.(*net.TCPConn).SetNoDelay(false)

We checked these defaults with strace, which shows every System call a program makes: Go 1.24 calls setsockopt(…, TCP_NODELAY, [1]) on both dialed and accepted connections; a plain Node 22 net.connect never does, while Node's http server and fetch do.

Staff-level§11

In production: pools, timeouts, floods and ports

In one sentenceIn production, most TCP problems are not about TCP's algorithms. They're about how many connections you open, how long you're willing to wait, and how you find out the other side is gone.

Reuse connections

Everything so far adds up: a new connection costs a handshake round trip (§03), a TLS handshake on top for HTTPS, then slow start (§06), and finally a TIME_WAIT slot and an ephemeral port for a minute after it closes (§09).

Why new connections are expensive (RTT = 50 ms)
new connection, 300 KB response
client
TCP 1 RTT
TLS 1 RTT
request
slow start: ~4 more RTTs
reused connection (keep-alive), window already large
client
request
data
A brand-new HTTPS connection pays a TCP handshake, a TLS handshake (TLS 1.3 shown), and then slow start for a large response. A reused, warmed-up connection pays only for the request itself. Approximate; real numbers depend on the response size and the network.

So keep connections open and reuse them: HTTP Keep-alive, database connection pools, one long-lived gRPC connection. Both standard HTTP clients do this already, if you let them:

Go: http.Client
// One client (and so one Transport, which holds the pool) for the whole program.
// Clone the default Transport rather than writing &http.Transport{}: a blank one
// loses the defaults (HTTPS_PROXY support, 30 s dial timeout, TLS timeout, HTTP/2).
func newClient() *http.Client {
    t := http.DefaultTransport.(*http.Transport).Clone()
    t.MaxIdleConnsPerHost = 32             // default is only 2!
    t.IdleConnTimeout = 90 * time.Second   // close idle connections after this
    return &http.Client{
        Timeout:   10 * time.Second, // whole request: connect + send + read body
        Transport: t,
    }
}

var client = newClient()

resp, err := client.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()        // ← without this, the connection is never returned
io.Copy(io.Discard, resp.Body) // ← read to the end, or it can't be reused

Set a timeout on everything

Without a Timeout, a connect to a dead machine takes ~2 minutes to fail on Linux (§03), and a read from a peer that vanished without a FIN can wait forever: TCP has nothing to send, so it never notices. Every network call needs a limit.

Go: Dialer + deadlines
d := net.Dialer{
    Timeout:   3 * time.Second,  // give up on the handshake after 3 s
    KeepAlive: 30 * time.Second, // TCP keepalive probes (see below; default 15 s)
}
conn, err := d.DialContext(ctx, "tcp", "db.internal:5432") // ctx can cancel it too
if err != nil {
    return err
}
defer conn.Close()

conn.SetDeadline(time.Now().Add(5 * time.Second)) // an absolute time, not a duration!
_, err = conn.Read(buf)
if errors.Is(err, os.ErrDeadlineExceeded) {
    // err.Error() == "read tcp 10.0.0.5:42134->10.0.0.9:5432: i/o timeout"
}

How long? Base it on the real p99 latency of the thing you're calling, plus a margin, and keep each inner timeout shorter than the caller's. Retry rules and budgets are in Worker Pools.

Notice dead peers: keepalive vs heartbeats

If the other machine loses power or a NAT box in between forgets your connection, no FIN or RST ever arrives. An idle connection looks perfectly healthy until you write to it.

  • TCP keepalive asks the kernel to send empty probes after some idle time and close the connection if nobody answers. Go enables it on every TCP connection (we measured 15 s idle, 15 s between probes, 9 probes on Go 1.24); a plain Node socket doesn't: call sock.setKeepAlive(true, 30_000). Its main real-world value is keeping NAT and load-balancer mappings alive and eventually cleaning up dead connections.
  • An application heartbeat (a ping message in your own protocol, like WebSocket ping/pong or gRPC keepalive pings) proves more: that the program on the other side is alive and responding, not just its kernel. If the peer process is stuck, TCP keepalive still succeeds; a heartbeat fails.

Rule of thumb: keep TCP keepalive on, and for long-lived connections where you need to detect failure in seconds, add a heartbeat with a deadline.

SYN floods and the accept queue

During a handshake, the server stores a little state for each half-open connection (it got a SYN, sent SYN-ACK, is waiting for the ACK). A SYN flood sends millions of SYNs from fake addresses and never completes them, trying to fill that table.

Linux defends with SYN cookies (on by default): when the table is full, the server stores nothing and instead encodes the connection's details into its own starting sequence number. A real client's ACK echoes that number back, and the server can rebuild the state from it. Large floods are still usually absorbed by the cloud provider's or CDN's edge servers before they reach you.

The related everyday limit is the listen backlog: connections that finished the handshake but your program hasn't called Accept on yet. Go asks for the system maximum (4096 on modern Linux, net.core.somaxconn); Node uses 511 by default. If your process stalls (a long garbage-collection pause, or a blocked Event loop), the queue fills and new clients see timeouts even though "the server is up".

Ephemeral port exhaustion

§09's arithmetic: closing ~470 connections per second to one destination uses up Linux's ~28,000 ephemeral ports, and connect fails with cannot assign requested address. Fixes, best first:

  1. Reuse connections (the Reuse connections part above). This fixes it outright.
  2. Spread load across more destination IPs or ports (each destination has its own set of 4-tuples).
  3. Widen the range: sysctl net.ipv4.ip_local_port_range="1024 65000".
  4. Let the server close first (so TIME_WAIT lands on the server), or enable net.ipv4.tcp_tw_reuse=1, which lets new outgoing connections reuse TIME_WAIT ports safely using timestamps.

Seeing what's happening

  • ss -tan state established '( dport = :5432 )' | wc -l: how many connections to Postgres right now?
  • ss -ti: per connection cwnd, RTT and retransmit counters (§04, §06).
  • tcpdump -n host 10.0.0.9 and port 5432: the actual packets, including SYNs that get no answer and RSTs.
  • Go's net/http/httptrace reports, per request, whether a connection was reused and how long DNS, connect, TLS and the first byte took:
trace.go
trace := &httptrace.ClientTrace{
    GotConn: func(info httptrace.GotConnInfo) {
        log.Printf("reused connection: %v", info.Reused) // false every time = no pooling
    },
    ConnectDone: func(network, addr string, err error) {
        log.Printf("new TCP connection to %s (err=%v)", addr, err)
    },
}
req, _ := http.NewRequest("GET", url, nil)
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := client.Do(req)
Practitioner§12

Pitfalls gallery

Eight mistakes that show up again and again in code reviews and incident reports, each with the fix.

#1 · Go & JS

Assuming one write = one read

TCP is a byte stream (§05). It works on your laptop and breaks under load or across a real network.

✕ Don't
n, _ := conn.Read(buf)
handleMessage(buf[:n]) // might be half a message, or two
✓ Do
r := bufio.NewReader(conn)
msg, err := readMsg(r) // length prefix + io.ReadFull (§05)
#2 · Go

No timeout anywhere

http.Get uses http.DefaultClient, which has no overall timeout. A server that accepts the connection and then never answers holds your goroutine forever.

✕ Don't
resp, err := http.Get(url) // can hang forever
✓ Do
var client = &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
#3 · JS

Thinking setTimeout closes the socket

socket.setTimeout(ms) only emits an event (§11). Without a handler that destroys the socket, nothing happens.

✕ Don't
sock.setTimeout(5000); // and then… nothing
✓ Do
sock.setTimeout(5000, () => sock.destroy(new Error("idle")));
#4 · Go

A new client (and pool) per request, or an unclosed body

Each new http.Transport has its own empty connection pool, so every request pays a handshake and leaves a TIME_WAIT behind. An unclosed body never returns its connection at all.

✕ Don't
func call() {
    c := &http.Client{Transport: &http.Transport{}} // fresh pool every call
    resp, _ := c.Get(url)
    data, _ := io.ReadAll(resp.Body) // Body never closed
    use(data)
}
✓ Do
var client = &http.Client{Timeout: 10 * time.Second} // shared

func call() error {
    resp, err := client.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return err
    }
    use(data)
    return nil
}
#5 · JS

Writing small pieces with Nagle on

A plain net socket in Node has Nagle on. Writing a request as several small writes, then waiting for a reply, can add ~40 ms per request (§10).

✕ Don't
sock.write(header);
sock.write(body); // held back until header is ACKed
✓ Do
sock.setNoDelay(true);
sock.write(Buffer.concat([header, body])); // one write, sent now
#6 · Go & JS

A UDP buffer that's too small, and no deadline

A short buffer silently cuts datagrams, and a read with no deadline waits forever for a reply that was lost (§08).

✕ Don't
buf := make([]byte, 64)
n, _ := conn.Read(buf) // longer datagrams cut off; may block forever
✓ Do
buf := make([]byte, 1500)
conn.SetReadDeadline(time.Now().Add(time.Second))
n, err := conn.Read(buf) // then retry or give up on timeout
#7 · Ops

Testing only on localhost

Loopback has ~0.03 ms RTT, no loss and huge packets. Handshake costs, slow start, Nagle stalls, framing bugs and head-of-line blocking are all invisible there. Test against a real network, or simulate one on Linux: sudo tc qdisc add dev lo root netem delay 50ms loss 1% (and sudo tc qdisc del dev lo root to undo). On loopback the delay applies to packets in both directions, so the round trip becomes 100 ms.

#8 · Ops

"Fixing" TIME_WAIT with sysctls

Thousands of TIME_WAIT sockets are a symptom of opening and closing too many connections. Lowering tcp_fin_timeout doesn't affect them, and tcp_tw_recycle no longer exists (§11). Reuse connections instead; if CLOSE_WAIT is what's piling up, it's a missing Close() in your code (§09).

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Two writes, one read
// client
conn.Write([]byte("ping"))
conn.Write([]byte("ping"))

// server, a moment later
n, _ := conn.Read(buf)
fmt.Printf("%q\n", buf[:n])
QUESTION 2 / 7
A small buffer for UDP
// client sends two datagrams: "hello", then "world"
buf := make([]byte, 3)
for i := 0; i < 2; i++ {
    n, _, err := srv.ReadFromUDP(buf)
    fmt.Printf("%q %v\n", buf[:n], err)
}
QUESTION 3 / 7
Time to first byte
// RTT between client and server: 80 ms. No TLS.
// The server answers instantly. How long from Dial()
// until the first byte of the response arrives?
conn, _ := net.Dial("tcp", "api.internal:8080")
conn.Write(request)
conn.Read(buf)
QUESTION 4 / 7
Timeout vs duplicate ACKs
// A Reno-style TCP sender has cwnd = 40 segments.
// Instead of 3 duplicate ACKs, the retransmission
// timer fires. What is cwnd right afterwards?
QUESTION 5 / 7
Node's socket timeout
const sock = net.connect(5432, "db.internal");
sock.setTimeout(5000);
// the server goes silent for 10 seconds…
QUESTION 6 / 7
Who gets TIME_WAIT?
QUESTION 7 / 7
One lost packet, three streams
// An HTTP/2 connection is downloading streams A, B and C.
// One TCP packet carrying part of stream A is lost.
// Until it's retransmitted, which data is stuck in the
// receiver's buffer, unable to reach the browser?
Answered 0 / 7
Foundations§14

Cheat sheet

📞
TCP = phone callConnection, every byte delivered once and in order, lost data resent, speed control. Costs a round trip to set up.
✉️
UDP = postcardsOne datagram at a time. No connection, no order, no resending. Message boundaries kept.
🤝
HandshakeSYN → SYN-ACK → ACK. 1 RTT before data; TLS adds more. Reuse connections.
🔁
ReliabilityCumulative ACKs. Resend after a timeout, or after 3 duplicate ACKs (fast retransmit).
🧵
Byte streamOne write ≠ one read. Frame messages: length prefix or delimiter.
📈
Two windowsIn flight ≤ min(rwnd, cwnd). Slow start doubles; loss halves (AIMD).
🚧
Head-of-line blockingOne lost segment stalls everything after it. QUIC gives each stream its own order.
⏱
Timeouts everywhereGo: Dialer.Timeout, SetDeadline, Client.Timeout. Node: signal, and destroy() in the "timeout" handler.

TCP or UDP?

You need…PickBecause
Every byte, in order (APIs, files, databases)TCPReliability for free, in the kernel
Request/response over the webTCP (HTTP/1.1, HTTP/2) or QUIC (HTTP/3)Use a library; don't build it yourself
One tiny question, one tiny answer (DNS)UDPNo handshake; just ask again if lost
Live audio/video, game stateUDP (usually with a media protocol on top)Late data is useless; never stall for a resend
Many independent streams on lossy networksQUICNo cross-stream head-of-line blocking
Fire-and-forget metrics on a LANUDPLosing a sample beats blocking the app
Broadcast one message to every machine on the local network (e.g. device discovery)UDPTCP connections are strictly one-to-one

Defaults worth knowing (Linux, Go 1.24, Node 22)

ThingValue
Initial congestion window10 segments (~14 KB)
Minimum retransmission timeout200 ms
Fast retransmit trigger3 duplicate ACKs
Delayed ACK≥ 40 ms on Linux, up to 200 ms elsewhere
TIME_WAIT60 s, held by the side that closes first
Ephemeral ports32768–60999 (~28,000)
Connect to a silent host gives up after~2 min (6 SYN retries)
Go TCP_NODELAY / TCP keepaliveon / on (15 s)
Node net noDelay / keepaliveoff / off (the http client agent and fetch turn both on; http servers turn on only noDelay)
Go http.Transport idle conns per host2 (raise MaxIdleConnsPerHost)
Listen backlogGo: somaxconn (4096), Node: 511

Commands

CommandShows
ss -tanAll TCP sockets and their states
ss -tan state time-wait | wc -lHow many TIME_WAIT sockets
ss -ticwnd, RTT and retransmits per connection
sudo tcpdump -n -i any 'tcp port 443'The actual packets: SYN [S], ACK [.], FIN [F], RST [R]
sysctl net.ipv4.tcp_congestion_controlWhich congestion algorithm is in use
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in NetworkingDNS
Read →