How I built a BitTorrent client called GoTorrent for my college final-year project.
This blog is divided into 7 parts:
- Why This Project
- Understanding BitTorrent
- GoTorrent Architecture
- System Workflow: How GoTorrent Operates
- The P2P Engine (Under the Hood)
- Key Features and Big Challenges
- Conclusion and Learnings
1. Why This Project
Before diving into the technical details, here is why I chose this project.
When I was around 12–13 years old, I used to download games and movies using applications like uTorrent. Those early experiences made me curious about how files could be shared among many users without relying on a single central server.
That curiosity eventually led me to build GoTorrent as my final-year project, both to understand the BitTorrent protocol deeply and to challenge myself with a real-world networking system.
2. Understanding BitTorrent
Before explaining GoTorrent, it helps to understand how BitTorrent works.
BitTorrent is a peer-to-peer (P2P) protocol where each user (peer) can both download and upload. Instead of downloading an entire file from one server, the file is split into pieces and shared across peers.
2.1 Torrent File and Tracker
A .torrent file contains metadata such as:
- File name and size
- Piece length and SHA-1 hashes
- Tracker URLs
When a client loads this file, it contacts a tracker to get a list of peers sharing that torrent.
2.2 Peer-to-Peer Communication
After peers are discovered, they connect directly over TCP and exchange protocol messages such as:
handshakefor connection setupinterested/not interestedchoke/unchokerequest,piece,have
2.3 Piece Verification
Each piece is verified using SHA-1 hashes from torrent metadata. If a hash check fails, that piece is downloaded again.
2.4 Seeding and Swarming
Once a peer completes the file, it becomes a seeder and uploads pieces to other peers. This cooperative model is what makes BitTorrent scalable.
In short, BitTorrent is a distributed exchange of pieces among peers, not a one-way client-server download.
3. GoTorrent Architecture
GoTorrent follows a daemon-based architecture built in Go.
3.1 Daemon (gotorrentd): The Engine
The daemon handles core responsibilities:
pkg/daemon: orchestration, lifecycle control, and coordinationpkg/torrent:.torrentparsing and tracker communication (HTTP/UDP)- DHT integration in daemon for additional peer discovery
pkg/p2p,pkg/client,pkg/message: peer protocol engine and piece transferpkg/daemon/store.go+ SQLite: persistence of state, progress, and bitfield
Note: Magnet link support is planned, but current implementation primarily handles .torrent input.
3.2 Client Layer
Users interact with the daemon through:
- CLI client via RPC
- REST endpoints for programmatic control
3.3 Why Go
Go was a strong fit because of:
- Goroutines and channels for concurrency
- Reliable networking primitives for TCP/UDP/HTTP
- Good runtime performance for high-throughput transfers
This architecture keeps core download logic isolated and long-running, while control surfaces remain lightweight.
4. System Workflow: How GoTorrent Operates
4.1 Parsing The .torrent file via bencode-go parser
type bencodeInfo struct {
Pieces string `bencode:"pieces"`
PieceLength int `bencode:"piece length"`
Length int `bencode:"length"`
Name string `bencode:"name"`
}type bencodeTorrent struct {
Announce string `bencode:"announce"`
AnnounceList [][]string `bencode:"announce-list"`
Info bencodeInfo `bencode:"info"`
}func (bto *bencodeTorrent) toTorrentFile() (TorrentFile, error) {}This Function parses the data and splits the hashes and fill up the trackers array with the trackers.
4.2 Contacting the Tracker and Peer Discovery
After parsing, we get a TorrentFile model:
type TorrentFile struct {
Announce string
InfoHash [20]byte
PieceHashes [][20]byte
AnnounceList []string
PieceLength int
Length int
Name string
}The tracker request starts in pkg/torrent/tracker.go. GoTorrent builds a standard tracker URL with query params like:
info_hashpeer_idportuploaded,downloaded,leftcompact=1
Core builder:
func buildTrackerURL(tf *TorrentFile, peerID [20]byte, port uint16) (string, error)The main entry point is:
func RequestPeers(tf *TorrentFile, peerID [20]byte) ([]peers.Peer, error)This function checks tracker scheme and routes to:
- HTTP/HTTPS tracker flow:
requestPeersHTTP(...) - UDP tracker flow:
RequestPeersUDPWithFallback(...)
HTTP tracker response model
type bencodeTrackerResp struct {
Interval int `bencode:"interval"`
Peers string `bencode:"peers"`
FailureReason string `bencode:"failure reason,omitempty"`
WarningMessage string `bencode:"warning message,omitempty"`
Complete int `bencode:"complete,omitempty"`
Incomplete int `bencode:"incomplete,omitempty"`
}The response is decoded and compact peers are converted using:
func UnmarshalPeers(peersBin []byte) ([]Peer, error)where each peer is parsed from 6 bytes (4 for IP + 2 for port).
UDP tracker flow and resilience
pkg/torrent/udp.go implements the UDP tracker protocol in two phases:
- Connect (
actionConnect) - Announce (
actionAnnounce)
Key functions:
func requestPeersUDP(u *url.URL, tf *TorrentFile, peerID [20]byte) ([]peers.Peer, error)
func buildConnectRequest(transactionID uint32) []byte
func parseConnectResponse(data []byte, expectedTransactionID uint32) (uint64, error)
func buildAnnounceRequest(connectionID uint64, transactionID uint32, tf *TorrentFile, peerID [20]byte, port uint16) []byte
func parseAnnounceResponse(data []byte, expectedTransactionID uint32) ([]peers.Peer, error)To improve reliability, sendAndReceive(...) uses retry + exponential backoff, and RequestPeersUDPWithFallback(...) tries known fallback UDP trackers if the primary one fails.
4.3 Hybrid Peer Discovery Inside the Daemon
Tracker contact alone is not enough for all torrents, so GoTorrent adds DHT discovery in the daemon (pkg/daemon/server.go).
Get Yash’s stories in your inbox
Join Medium for free to get updates from this writer.
In Server.AddTorrent(...), peer discovery happens in this order:
- Iterate tracker URLs from
AnnounceList - Query trackers in order via
torrent.RequestPeers(...)until one returns peers - If DHT is enabled, query DHT using
s.dht.Announce(tf.InfoHash, 0, true) - Merge and deduplicate peers using
IP:Portkeys
This gives a hybrid discovery strategy:
- Trackers provide fast bootstrap peers
- DHT improves availability when trackers are down or limited
4.4 End-to-End Workflow (Execution Pipeline)
Once peers are found, the daemon creates the P2P job:
torrentJob := &p2p.Torrent{
Peers: initialPeers,
PeerID: ourPeerID,
InfoHash: tf.InfoHash,
PieceHashes: tf.PieceHashes,
PieceLength: tf.PieceLength,
Length: tf.Length,
Name: tf.Name,
Announce: tf.Announce,
DownloadPath: s.downloadPath,
}Then the workflow is:
- Persist torrent metadata in SQLite (
store.AddTorrent) - Add job to in-memory map (
s.torrents) - Start async download worker (
go torrentJob.DownloadTheFile(s.store)) - Periodically update progress (
store.UpdateTorrentProgress) - Serve status through RPC + REST APIs
So the full data path is:
.torrent -> metadata parse -> peer discovery (Tracker + DHT) -> worker swarm -> piece validation -> disk write -> DB update -> status APIs
5. The P2P Engine (Under the Hood)
This is the core download logic in pkg/p2p/p2p.go and related protocol packages.
5.1 Torrent Runtime Model
type Torrent struct {
Peers []peers.Peer
PeerID [20]byte
InfoHash [20]byte
PieceHashes [][20]byte
PieceLength int
Length int
Name string
Announce string
BytesDownloaded int64
Status string
ActivePeers int
State uint8
OurBitfield bitfield.Bitfield
DownloadPath string
}This struct tracks both static metadata (hashes, piece size, total length) and mutable runtime state (status, speed, active peers, bitfield).
5.2 Peer Connection, Handshake, and Bitfield
Each worker connects through client.New(...):
- TCP connect to peer
IP:Port - Perform BitTorrent handshake
- Receive initial bitfield
Handshake package (pkg/handshake/handshake.go) defines:
type Handshake struct {
Pstr string
InfoHash [20]byte
PeerID [20]byte
}completeHandshake(...) verifies returned InfoHash matches the target torrent, preventing cross-torrent contamination.
5.3 Piece Scheduling and Parallel Download
The engine uses a producer-consumer model:
workQueuecontains pending pieces (pieceWork)- Each peer goroutine runs
startDownloadWorker(...) - Successful pieces are sent through
results
Per-piece download uses pipelining with backlog:
const MaxBlockSize = 16384
const MaxBacklog = 5attemptDownloadPiece(...) keeps requesting blocks while unchoked and reads protocol messages (Unchoke, Have, Piece, etc.) via readMessage().
5.4 Integrity and Safe Write Path
Before writing piece data, GoTorrent validates SHA-1:
func checkIntegrity(pw *pieceWork, buf []byte) errorIf hash check fails, piece is re-queued. If it passes, piece is written at exact file offset with WriteAt(...), and local bitfield is updated.
5.5 Pause, Resume, Stop, and Speed Tracking
Torrent control uses a state channel:
func (t *Torrent) Pause()
func (t *Torrent) Resume()
func (t *Torrent) Stop()Download speed is calculated every 2 seconds from byte deltas. Progress is persisted periodically so restarts can continue from saved bitfield state.
6. Key Features and Big Challenges
6.1 Key Features Implemented (Go Backend)
- Hybrid peer discovery
- HTTP and UDP tracker support
- Multi-peer concurrent piece download using goroutines
- Piece-level SHA-1 integrity validation
- Persistent progress with SQLite (including bitfield)
- Pause/resume/remove lifecycle management
- Daemon architecture with RPC and REST interfaces
- Real-time status broadcasting pipeline
6.2 Big Technical Challenges
- Unreliable tracker ecosystem Many trackers fail, timeout, or return empty peer lists. I solved this using multi-tracker attempts, UDP retries, and fallback tracker logic.
- Peer instability and choking behavior Not all peers cooperate. Some choke, disconnect, or have partial data. I handled this using per-worker timeouts, piece re-queueing, and concurrent workers.
- Correct piece boundary math Last piece is often smaller than normal piece length. I added
calculateBoundsForPiece(...)andcalculatePieceSize(...)to avoid overflow and corruption. - Data integrity under concurrency Concurrent downloads can cause races if state updates are unsafe. I used mutexes for shared state (
BytesDownloaded, bitfield, status) and only mark piece complete after successful hash + disk write. - Crash/restart resilience Download clients should not lose progress after restart. I persisted status + bitfield in SQLite and load managed torrents from DB during daemon startup.
- Lifecycle transitions are tricky Pausing, resuming, stopping, and restarting workers can create invalid states. I made explicit state transitions (
StatePaused,StateDownloading,StateSeeding, etc.) and guarded resume behavior based on current status.
7. Conclusion and Learnings
Building GoTorrent as my final year project taught me far more than just “how torrents work”. It gave me hands-on experience with distributed systems, binary protocols, concurrency, fault tolerance, and long-running backend service design in Go.
The biggest learning was this: A BitTorrent client is not just about downloading files. It is a coordination problem across unreliable networks, inconsistent peers, and asynchronous state updates.
From parsing bencoded metadata to tracker communication, DHT lookup, handshake negotiation, piece scheduling, integrity verification, and persistence, every stage has failure modes that must be handled explicitly.
Go’s goroutines, channels, and standard library made it practical to build this as a real production-style system, not just a classroom prototype.
You can explore the complete source code and implementation details here: https://github.com/Yashh56/GoTorrent
If you found the project interesting or helpful, feel free to leave a star on the repository ⭐






