Reading the spec is not the same as feeling the choke
The BitTorrent protocol document explains choking in about two paragraphs. Each peer limits uploads. Unchoke the peers who upload to you fastest. Every 30 seconds, optimistically unchoke a random peer to probe for better uploaders. Tit-for-tat. Simple on paper.
Then you implement it in Go and watch your download rate flatline because every peer choked you within 60 seconds.
That failure is the lesson. Choking is not a courtesy flag. It is a bandwidth allocator running on every node in the swarm. If your client uploads too little, or uploads to the wrong peers, the network deprioritizes you. No central coordinator enforces this. Each peer makes the same local decision independently, and the aggregate behavior still produces a working swarm.
Rebuilding a minimal client from scratch is one of the few ways to internalize that.
What a minimal client actually has to do
Strip BitTorrent down to the parts that matter for choking:
- Parse a
.torrentfile (info hash, piece length, tracker URL) - Connect to a tracker, get a peer list
- Open TCP connections to peers (usually tens, not thousands, in a toy client)
- Complete the BitTorrent handshake and exchange bitfields
- Run the choking loop on a timer
- Request 16 KB blocks from unchoked peers who have pieces you need
- Write completed pieces to disk
Everything else (DHT, magnet links, uTP, encryption) can wait. The choking algorithm lives entirely in the peer wire protocol layer.
flowchart TB TOR[".torrent file"] --> TRK["Tracker announce"] TRK --> PEERS["Peer list"] PEERS --> TCP["TCP connections"] TCP --> HS["Handshake + bitfield"] HS --> CHOKE["Choking loop"] CHOKE --> REQ["Request 16 KB blocks"] REQ --> DISK["Write 256 KB piece to disk"]
The four states every peer connection lives in
Each TCP connection to a remote peer carries two independent booleans:
- Choked / unchoked (can you request data from them?)
- Interested / not interested (do you want data they have?)
You can be interested and choked. That is the painful state. You know they have a piece you need. They will not send it because their upload slots are full and you are not in the top set.
The remote peer tracks the same flags about you. Your upload behavior determines whether they keep you unchoked.
flowchart TB
subgraph upload_gate["Upload gate (can you request data?)"]
CHOKED[Choked]
UNCHOKED[Unchoked]
CHOKED -->|UNCHOKE message| UNCHOKED
UNCHOKED -->|CHOKE message| CHOKED
end
subgraph demand["Demand signal (do you want their data?)"]
NI[Not interested]
INT[Interested]
NI -->|peer has wanted piece| INT
INT -->|peer has nothing new| NI
end
BLOCKED["Interested and Choked<br/>blocked download"]
INT -.-> BLOCKED
CHOKED -.-> BLOCKED
Four bytes of state per peer. The entire fairness policy of the swarm hangs off those bits and a timer.
Why choking exists: upload is the scarce resource
Download bandwidth is rarely the bottleneck in a home or small VPS setup. Upload is.
A typical asymmetric link might offer 500 Mbps down and 40 Mbps up. A BitTorrent client connected to 30 peers could theoretically request data from all of them at once. But it can only upload to a handful before the uplink saturates.
Without choking, a peer would accept upload requests from everyone who connected. Each outbound block costs uplink bytes and kernel TCP buffer space. Saturate the upload pipe and latency on every connection spikes. ACKs delay. Throughput collapses for everyone, including you.
Choking is weighted fair queuing implemented in application logic. Each peer grants upload capacity to the connections that recently gave it the best download rate. Roughly four slots. Everyone else waits.
The network cost is concrete:
- Each unchoked peer you upload to consumes outbound bandwidth and a send buffer in the kernel
- Each
REQUESTyou serve triggers aPIECEmessage of up to 16 KB plus TCP/IP overhead - Serving too many peers at once fills the SO_SNDBUF queue; the kernel blocks your goroutine on write
This is the same class of problem as an API gateway deciding which tenants get priority when egress is capped. BitTorrent just solved it in 2001 with a timer and a sort.
The tit-for-tat loop
Every 10 seconds, a well-behaved client recalculates who to unchoke:
- Measure upload rate received from each peer over the window (bytes they sent you)
- Sort peers by that rate
- Unchoke the top N (typically 3 or 4)
- Choke everyone else
If peer A sent you 2 MB in the last 10 seconds and peer B sent 200 KB, peer A keeps an upload slot on your side, and you fight to stay in peer A’s top set by uploading back to them.
That reciprocity is the tit-for-tat part. The swarm has no global view. Local measurements produce global cooperation.
Every 30 seconds, add one more rule: optimistic unchoke. Pick a random choked peer and unchoke them temporarily, even if their recent upload rate was low. This probes the swarm for hidden good uploaders. A new peer might have the rare piece you need and a fat uplink you have not discovered yet.
Without optimistic unchoke, the system gets stuck in local optima. Everyone trades with the same partners. Rare pieces stall.
flowchart TB START([10s timer fires]) --> MEASURE["Measure download rate per peer"] MEASURE --> SORT["Sort by rate descending"] SORT --> TOP["Unchoke top 4"] TOP --> CHOKE_REST["Choke all others"] OPT([30s timer fires]) --> PICK["Pick random choked peer"] PICK --> OPT_UNCHOKE["Optimistically unchoke for one period"] OPT_UNCHOKE --> REVERT["Revert unless rate improves"]
Why Go and not something else
The goal was to understand choking, not to benchmark languages. Go won because the problem shape matches what the runtime is good at.
BitTorrent is a pile of long-lived TCP connections doing small, independent reads and writes. A minimal client might hold 20 to 50 peer sockets open for minutes. Each connection needs its own read loop, rate counter, and choke state. That is concurrent I/O with shared mutable state, which is exactly where goroutines plus channels (or a mutex and a map) feel natural. One goroutine per peer. One timer goroutine for the 10s and 30s recalculation. No thread pool sizing guesswork.
The standard library helps more than it should for a toy project. net.Dial for TCP, bufio.Reader for length-prefixed wire messages, encoding/binary for big-endian integers, sync/atomic for byte counters the choking loop reads without locking every field. You stay inside the protocol logic instead of fighting infrastructure.
Compare that to the alternatives people usually reach for:
- Rust gives you memory safety and speed, but the borrow checker fights you the moment a read loop and a choking loop both touch the same peer struct. Fine for production. Heavy for a weekend protocol experiment.
- Python gets a working prototype fast, but dozens of blocking socket threads (or asyncio complexity) make the TCP backpressure story harder to feel. When a
PIECEwrite blocks because SO_SNDBUF is full, you want that blocking to be obvious in the call stack. - Java works, but the ceremony around NIO or thread pools pushes the interesting code further away. You end up debugging executor configuration instead of tit-for-tat.
- C is how the original clients were written. You see every byte. You also own every leak and every race. For learning choking policy, that is the wrong layer to spend time on.
Go sits in the middle: low enough that you see TCP buffers and blocking writes, high enough that a peer map and a timer loop fit in one file. The static binary is a nice bonus for a side project you will run on a laptop and a cheap VPS without installing a runtime.
None of this means Go is the “best” language for BitTorrent. Production clients use Rust, C++, and Python in different places. It means Go was the shortest path from “read the spec” to “watch the swarm choke me because I stopped uploading.”
What the Go implementation teaches you about concurrency
A minimal Go client usually spawns one goroutine per peer connection. Each goroutine reads length-prefixed messages from a bufio.Reader, dispatches on message type, and writes responses to a channel or mutex-protected conn.
The choking loop runs separately. On tick, it locks a shared peer map, recalculates rates from counters incremented in the read goroutines, sends CHOKE and UNCHOKE messages, and unlocks.
The bugs show up at the boundaries:
- Rate measurement drift. If you count bytes in the read loop but reset counters in the choking loop without synchronization, you get races. A
sync/atomiccounter per peer is enough. - Blocking writes. Sending a
PIECEmessage while the TCP send buffer is full blocks the goroutine. One slow peer stalls your upload pipeline unless writes are bounded or async. - Timer skew. Running choking recalculation inside the peer read loop ties fairness to network I/O. Keep the 10s and 30s timers on a dedicated goroutine.
Memory is modest but not free. A bitfield for a 4 GB file with 256 KB pieces is about 2 KB. Thirty peers means thirty bitfields plus thirty TCP buffers. The kernel holds more than your process heap.
The failure mode that teaches the most
Connect to a swarm as a leech-only client. Download pieces. Never upload. Watch what happens:
- First 30 to 60 seconds: download works. Optimistic unchoke gives you a free slot somewhere.
- After one or two recalculation windows: every peer chokes you. Download rate drops to zero even though peers still have the data.
Nothing crashed. No error log. The protocol worked exactly as designed. You violated the implicit contract (reciprocate upload bandwidth) and the distributed scheduler cut you off.
That behavior maps directly to production systems:
- A Kafka consumer that never commits offsets eventually gets kicked from the group
- An API client that hammers GET without caching or backoff gets rate limited
- A microservice that only calls downstream services but never serves requests loses priority in a shared connection pool during congestion
Choking is backpressure with a 10-second window and no admin panel.
Piece picking matters more than expected
Choking controls who you trade with. Piece picking controls what you ask for. A naive client requests pieces in order from the first unchoked peer. That creates a rarest-first failure mode in reverse: everyone grabs the same early pieces, the tail of the file stalls, and peers with rare end pieces have no one interested in them.
The standard fix is rarest-first piece selection: among pieces you need, request the one held by the fewest peers. That spreads load and keeps the swarm balanced.
In the minimal client, this adds one pass over peer bitfields before each REQUEST. CPU cost is tiny. The effect on swarm throughput is disproportionate. Skip it and the choking algorithm looks broken even when it is working.
Disk I/O: the part the protocol spec ignores
Each completed piece is 256 KB (typical). Blocks arrive as 16 KB chunks out of order. The client buffers blocks in memory until the full piece arrives, verifies the SHA-1 hash against the .torrent metadata, then writes to disk.
Random write placement across the file means spinning disks suffer. SSDs barely notice. On a VPS with a network-attached volume, piece verification plus write latency can become the bottleneck after download rates improve.
Mechanical sympathy shows up here too. You are trading network scheduling for disk seek patterns. Production CDNs and object stores solve the same tension: parallel fetch, sequential or batched write.
What carries over to enterprise backend work
BitTorrent looks nothing like a retail checkout API. The design problems rhyme:
| BitTorrent | Enterprise backend |
|---|---|
| Choking / unchoking | Rate limiting and fair queuing under egress caps |
| Optimistic unchoke | Canary traffic to probe new endpoints |
| Tit-for-tat upload slots | Priority lanes for tenants that reciprocate resources |
| Per-peer TCP connection | Connection pool slots held during slow responses |
| Rarest-first piece pick | Work stealing toward underloaded shards |
The choking algorithm is a distributed admission control policy with no central coordinator. That is worth understanding on its own. It also explains why “just add more peers” does not fix a slow download any more than “just add more connections” fixes a saturated database pool without changing who gets served first.
The rebuild was worth it
A full BitTorrent client is thousands of lines. A minimal one that speaks the wire protocol, implements choking, and completes a small legal test torrent is a few hundred. Enough to watch your upload counters directly affect whether peers keep you unchoked.
The spec describes tit-for-tat in abstract terms. The Go rebuild shows it as goroutines, timers, atomic counters, and TCP buffers filling up. Once you have been choked by the entire swarm because you forgot to serve PIECE messages, you do not forget what upload fairness means.
That intuition transfers. Any system where nodes share a scarce resource and make local scheduling decisions faces the same shape. BitTorrent just makes the consequences arrive in 10 seconds instead of after a quarterly capacity review.
// SPONSORSHIP
If this research saved you time or improved your architecture, consider sponsoring my work on GitHub. All sponsorships go directly toward infrastructure and further technical research.
[ Become a Sponsor ]