Implement GossipSub v1.0 from Scratch
Build a working GossipSub v1.0 pubsub protocol in pure Go with zero dependencies.
Introduction
30 Second Summary
Think about how rumours spread. You don't announce news to every person in the building. You tell a handful of people, and they each tell a handful more. The message reaches everyone without anyone shouting into a megaphone.
In this project, you will implement the GossipSub v1.0 pubsub protocol from scratch in pure Go. You will spin up multiple nodes on localhost, watch them form bounded-degree mesh overlays, publish messages that propagate without infinite loops, and observe gossip recovering messages that nodes missed.
What You'll Build
You will run a cluster of 6 GossipSub nodes in your terminal, publish a message from one node, and watch it hop through the mesh to arrive at every subscriber within milliseconds.
By the end of this project, you'll have:
- A running mesh-based pubsub system where you publish a message on one node and see it arrive at all other subscribed nodes, forwarded only through bounded-degree mesh links.
- A self-healing heartbeat that you can trigger by killing a node and watching remaining nodes detect the loss and GRAFT new peers to maintain connectivity.
- A gossip recovery layer (IHAVE/IWANT) where you can simulate a missed message and watch a node request and receive it from a non-mesh peer.
- Secret Mission: Add fanout mechanics so a node can publish to a topic it is not subscribed to.
Are there any prerequisites?
This project assumes basic Go proficiency (goroutines, channels, maps) and familiarity with TCP networking in Go. Some understanding of distributed systems concepts will help, but the protocol logic is explained as you build it.
Before We Start
GossipSub is the pubsub protocol that powers peer-to-peer networks like libp2p. Building it from scratch gives you a working mental model of mesh-based message forwarding, gossip dissemination, and self-healing distributed systems.
In this step, you will confirm your Go installation and set up the project scaffolding so you can start implementing the protocol in the next step.
In this step, get ready to:
- Verify Go 1.22+ is installed on your machine.
- Create a project directory and initialize a Go module.
- Set up an empty main.go scaffold.
Verify your Go installation
This project uses only Go's standard library. You need Go version 1.22 or higher installed on your machine.
- Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
- Check your Go version by running this command:
go version
You should see output like go version go1.26.5 followed by your operating system.
✔️ I see version 1.22 or higher
Your Go installation is ready. Move on to the next substep.
ⓧ I see an older version
You need Go 1.22 or higher for this project. Your current version is too old.
- Visit the official Go downloads page and download the latest installer for your OS.
- Run the installer. It will replace your existing Go version.
- Close and reopen your terminal, then run go version again to confirm.
ⓧ Command not found
Go is not installed on your machine yet.
- Visit the official Go downloads page and download the installer for your OS.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run go version to verify the installation shows 1.22 or higher.
Create your project directory
Every Go project lives inside a module. You will create a folder on your Desktop and initialize it as a Go module.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir gossipsub
cd gossipsub
- Initialize the Go module by running:
go mod init gossipsub
You should see output confirming the module was created: go: creating new go.mod: module gossipsub.
What is a Go module?
A Go module is a collection of related Go packages tracked by a go.mod file at the root of your project. The go.mod file records your module name and the Go version.
Since this project uses only the standard library, you will not need any external dependencies. The module structure keeps your code organized and buildable.
- Confirm the file was created by running:
ls
You should see go.mod listed in the output.
Not seeing go.mod?
- Make sure you ran cd gossipsub before running go mod init. The command creates go.mod in your current directory.
- Run pwd to confirm you are inside ~/Desktop/gossipsub.
Help me troubleshoot my Go module initialization.
Set up main.go
With the module ready, you need an entry point file where all of your protocol code will live.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the gossipsub folder, and click Open.
- In the Explorer sidebar, click the new file icon and name it main.go.
- Add the following code to main.go:
package main
func main() {
}
What does this code do?
- package main declares this file as the entry point of an executable Go program.
- func main() is the function Go calls when you run the program. You will fill it in as you build the protocol.
- Save main.go.
- In your terminal, verify the project compiles by running:
go run main.go
The command exits silently with no output and no errors. This confirms your module structure and code are valid Go.
Seeing an error?
- If you see cannot find module, make sure your terminal is inside the gossipsub folder where go.mod lives. Run pwd to check.
- If you see a syntax error, check that main.go contains exactly the code above with no extra characters.
Help me fix my Go compilation error.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and confirmed the command ran without errors.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
go.mod (the Go version number will match your installed version):
module gossipsub
go 1.22
main.go:
package main
func main() {
}
Your workspace is ready. Next up, you will define the message types and data structures that form the vocabulary of the GossipSub protocol.
Define Message Types and Data Structures
Before your nodes can exchange a single byte, they need a shared vocabulary. Every protocol starts here: defining what kinds of messages exist, what data they carry, and where that data lives in memory.
In this step, you will define the GossipSub v1.0 protocol constants, the message type enum, the wire envelope, and the two caching layers that prevent infinite message loops.
In this step, get ready to:
- Define protocol parameters and message types that form the GossipSub vocabulary.
- Build a message cache with sliding windows for gossip emission.
- Build a seen cache for deduplication and a Router struct that holds all node state.
Add protocol constants and the message type enum
The GossipSub spec defines a set of parameters that control mesh size and cache behaviour. These are the knobs that keep message forwarding bounded instead of flooding.
- Open main.go in VS Code.
- Replace the entire file contents with the following imports and protocol constants:
package main
import (
"fmt"
"net"
"sync"
"time"
)
// Protocol parameters (GossipSub v1.0 defaults)
const (
D = 6
DLow = 4
DHigh = 12
HeartbeatInterval = 1 * time.Second
FanoutTTL = 60 * time.Second
MCacheLen = 5
MCacheGossip = 3
SeenTTL = 2 * time.Minute
)
What do these constants control?
- D (6) is the target number of mesh peers per topic. Each node forwards messages to exactly this many peers, not everyone.
- DLow (4) and DHigh (12) are the bounds. The heartbeat grafts peers if below 4, prunes if above 12.
- MCacheLen (5) is how many heartbeat windows of messages the cache keeps. MCacheGossip (3) is how many recent windows are eligible for gossip (IHAVE).
- SeenTTL (2 minutes) is how long a node remembers a message ID to prevent re-forwarding it.
- Below the constants block, add the message type enum and RPC wire envelope:
// Message types
type MsgType int
const (
MsgTypeData MsgType = iota
MsgTypeGraft
MsgTypePrune
MsgTypeIHave
MsgTypeIWant
MsgTypeSubscribe
)
// RPC is the wire envelope
type RPC struct {
Type MsgType `json:"type"`
From string `json:"from"`
Topic string `json:"topic,omitempty"`
MessageID string `json:"message_id,omitempty"`
Data string `json:"data,omitempty"`
MessageIDs []string `json:"message_ids,omitempty"`
Topics []string `json:"topics,omitempty"`
}
What does this code do?
MsgType is an enum built with iota. Each message a node sends is one of these six types: a data message, a mesh control (GRAFT/PRUNE), a gossip metadata exchange (IHAVE/IWANT), or a subscription announcement.
The RPC struct is the single wire envelope for everything. Every field is optional except Type and From. A data message fills Data and MessageID. An IHAVE fills MessageIDs. This keeps the protocol simple: one struct, many purposes.
- At the bottom of the file, add a temporary main function so the file compiles:
func main() {
fmt.Println("Types defined")
}
- Save main.go and verify the types compile by running:
go run main.go
You should see Types defined printed in the terminal.
Seeing a compile error?
- Check that the import block has exactly fmt, net, sync, and time. Go rejects unused imports, so only include what the current code references.
- Make sure the const block uses time.Second and time.Minute (not string literals).
help me fix a Go compile error in my GossipSub constants
Build the message cache
The message cache stores recently received messages in sliding time windows. Gossip emission (IHAVE) draws from this cache to tell other peers what messages are available.
- In main.go, add the MessageCache struct and its constructor above the main function:
// MessageCache stores messages in sliding windows
type MessageCache struct {
mu sync.RWMutex
msgs map[string]*RPC
windows [][]cacheEntry
gossipW int
}
type cacheEntry struct {
id string
topic string
}
func NewMessageCache(length, gossip int) *MessageCache {
windows := make([][]cacheEntry, length)
for i := range windows {
windows[i] = []cacheEntry{}
}
return &MessageCache{
msgs: make(map[string]*RPC),
windows: windows,
gossipW: gossip,
}
}
What does this code do?
MessageCache holds a flat map of message ID to message (msgs) for fast lookup, plus a ring of time windows (windows) that track which messages arrived recently.
The gossipW field controls how many windows are eligible for gossip. With MCacheGossip = 3, only the 3 most recent windows provide IHAVE message IDs. Older messages expire out of gossip but remain retrievable via IWANT until they fall off the end.
- Below the constructor, add the Put and Get methods:
func (mc *MessageCache) Put(id string, msg *RPC) {
mc.mu.Lock()
defer mc.mu.Unlock()
mc.msgs[id] = msg
mc.windows[0] = append(mc.windows[0], cacheEntry{id: id, topic: msg.Topic})
}
func (mc *MessageCache) Get(id string) (*RPC, bool) {
mc.mu.RLock()
defer mc.mu.RUnlock()
m, ok := mc.msgs[id]
return m, ok
}
What does this code do?
Put stores a message and records its ID in window 0 (the current window). Get retrieves a message by ID for IWANT responses. Both use a read-write mutex so multiple goroutines can read concurrently but writes are exclusive.
- Below Get, add the GetGossipIDs and Shift methods:
func (mc *MessageCache) GetGossipIDs(topic string) []string {
mc.mu.RLock()
defer mc.mu.RUnlock()
var ids []string
limit := mc.gossipW
if limit > len(mc.windows) {
limit = len(mc.windows)
}
for i := 0; i < limit; i++ {
for _, e := range mc.windows[i] {
if e.topic == topic {
ids = append(ids, e.id)
}
}
}
return ids
}
func (mc *MessageCache) Shift() {
mc.mu.Lock()
defer mc.mu.Unlock()
last := len(mc.windows) - 1
for _, e := range mc.windows[last] {
delete(mc.msgs, e.id)
}
copy(mc.windows[1:], mc.windows[:last])
mc.windows[0] = []cacheEntry{}
}
What does this code do?
GetGossipIDs scans the most recent gossipW windows and collects message IDs for a given topic. These IDs become the payload of IHAVE messages during gossip emission.
Shift rotates the windows: the oldest window's messages are deleted from the map, all windows slide forward, and a fresh empty window takes position 0. The heartbeat calls this once per tick to age out old messages.
- Save main.go and run go run main.go to confirm it still compiles.
You should still see Types defined printed. The new code compiles but nothing calls it yet.
Seeing "declared and not used" error?
Go also rejects unused variables and types. Make sure sync is still in your import block. If VS Code's Go extension removed it automatically, re-add it. The MessageCache struct uses sync.RWMutex so the import is needed.
help me fix unused import or variable errors in my GossipSub message cache code
Build the seen cache and Router struct
The seen cache is the key anti-loop mechanism. Without it, a message forwarded through the mesh would bounce back and forth forever. Every node checks this cache before forwarding.
- Below the Shift method, add the SeenCache struct with its constructor and methods:
// SeenCache tracks recently seen message IDs
type SeenCache struct {
mu sync.RWMutex
entries map[string]time.Time
ttl time.Duration
}
func NewSeenCache(ttl time.Duration) *SeenCache {
sc := &SeenCache{entries: make(map[string]time.Time), ttl: ttl}
go sc.cleanup()
return sc
}
func (sc *SeenCache) Has(id string) bool {
sc.mu.RLock()
defer sc.mu.RUnlock()
_, ok := sc.entries[id]
return ok
}
func (sc *SeenCache) Add(id string) {
sc.mu.Lock()
defer sc.mu.Unlock()
sc.entries[id] = time.Now()
}
What does this code do?
SeenCache maps message IDs to the time they were first seen. Has returns true if a message was already seen (skip forwarding). Add records a new message ID with the current timestamp.
The constructor launches a background goroutine (cleanup) to periodically evict expired entries. This prevents unbounded memory growth.
- Below Add, add the cleanup goroutine method:
func (sc *SeenCache) cleanup() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
sc.mu.Lock()
now := time.Now()
for id, t := range sc.entries {
if now.Sub(t) > sc.ttl {
delete(sc.entries, id)
}
}
sc.mu.Unlock()
}
}
What does this code do?
Every 30 seconds, cleanup walks the entries map and deletes any message ID older than SeenTTL (2 minutes). This means a message can only be re-forwarded if it reappears more than 2 minutes after the node first saw it.
- Below cleanup, add the Router struct and its constructor:
// Router is the GossipSub node
type Router struct {
mu sync.RWMutex
addr string
topics map[string]bool
mesh map[string]map[string]bool
fanout map[string]map[string]bool
lastPub map[string]time.Time
peers map[string]net.Conn
peerTopics map[string]map[string]bool
mcache *MessageCache
seen *SeenCache
}
func NewRouter(addr string) *Router {
return &Router{
addr: addr,
topics: make(map[string]bool),
mesh: make(map[string]map[string]bool),
fanout: make(map[string]map[string]bool),
lastPub: make(map[string]time.Time),
peers: make(map[string]net.Conn),
peerTopics: make(map[string]map[string]bool),
mcache: NewMessageCache(MCacheLen, MCacheGossip),
seen: NewSeenCache(SeenTTL),
}
}
What does this code do?
The Router struct is the GossipSub node itself. It holds the mesh overlay (mesh), peer connections (peers), topic subscriptions (topics), and both caches. Each map is keyed by topic or peer address.
The fanout and lastPub fields support publishing to topics the node is not subscribed to. You will use these in a later step.
- Below NewRouter, add the keys helper and update the main function:
func keys(m map[string]bool) []string {
var result []string
for k := range m {
result = append(result, k)
}
return result
}
func main() {
r := NewRouter(":9001")
_ = r
fmt.Println("GossipSub node initialized")
}
What does this code do?
The keys helper extracts keys from a map[string]bool into a slice. You will use this throughout the project to print mesh state and select peers.
The main function creates a Router on port 9001. This exercises the full construction chain: NewRouter calls NewMessageCache and NewSeenCache, which launches the cleanup goroutine. If any type is wrong, Go catches it here.
Before you run this, what do you expect to see? The router initializes both caches and prints a confirmation.
- Save main.go and run the program:
go run main.go
You should see GossipSub node initialized printed and the program exits cleanly.
Program hangs instead of exiting?
The SeenCache launches a background goroutine with a ticker. Go's runtime exits when main returns, even if goroutines are still running. If your program hangs, check that main does not block (no select {} or infinite loop).
If you see an "imported and not used" error for net, make sure the Router struct is present with its peers map[string]net.Conn field.
help me debug why my GossipSub node won't initialize
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and the output reads GossipSub node initialized.
ⓧ I'd like to double check the full code
Here is the complete main.go at this point. Scroll through and confirm each section is present in order: imports, constants, MsgType enum, RPC struct, MessageCache (struct + constructor + Put + Get + GetGossipIDs + Shift), SeenCache (struct + constructor + Has + Add + cleanup), Router struct + NewRouter, keys helper, and main.
You now have the full protocol vocabulary and data structures in place. Next up, you will build the peer connection layer so nodes can discover each other and exchange subscriptions over TCP.
Build the Peer Connection Layer
You have the protocol vocabulary (message types, caches, the Router struct) but nodes cannot do anything useful until they can actually talk to each other.
In a real GossipSub deployment, peer discovery is handled by a DHT or bootstrap list. For our implementation, we will build the raw TCP connection layer ourselves and have nodes exchange topic subscriptions on connect. This gives you the "ambient peer awareness" that the rest of the protocol depends on.
In this step, get ready to:
- Implement length-prefixed JSON framing for sending and receiving RPCs over TCP.
- Create a TCP listener and dial function so nodes can form bidirectional connections.
- Exchange subscription announcements and verify that 3 nodes on localhost discover each other.
Implement the wire format and utility methods
Every RPC between nodes travels as a length-prefixed JSON frame over TCP. The 4-byte length prefix tells the reader exactly how many bytes to consume before unmarshalling. You also need a few small helpers that the networking methods will call.
- In main.go, find the keys() function near the bottom of the file. Add the following two methods above it:
func (r *Router) sendRPC(conn net.Conn, rpc *RPC) {
data, err := json.Marshal(rpc)
if err != nil {
return
}
length := uint32(len(data))
buf := make([]byte, 4+len(data))
binary.BigEndian.PutUint32(buf[:4], length)
copy(buf[4:], data)
conn.Write(buf)
}
func (r *Router) sendControl(peer string, msgType MsgType, topic string) {
if conn, ok := r.peers[peer]; ok {
rpc := &RPC{Type: msgType, From: r.addr, Topic: topic}
r.sendRPC(conn, rpc)
}
}
What does this code do?
sendRPC marshals any RPC to JSON, prepends a 4-byte big-endian length, and writes the whole frame to the connection in one shot. This is the only function that touches the wire.
sendControl is a convenience wrapper. It looks up a peer's connection and sends a control-type RPC (GRAFT, PRUNE, etc.) in one call.
- Now add the following four utility methods directly below sendControl (still above keys()):
func (r *Router) topicPeers(topic string) []string {
var result []string
for p, topics := range r.peerTopics {
if topics[topic] {
result = append(result, p)
}
}
rand.Shuffle(len(result), func(i, j int) { result[i], result[j] = result[j], result[i] })
return result
}
func (r *Router) subscribedTopics() []string {
var topics []string
for t := range r.topics {
topics = append(topics, t)
}
return topics
}
func (r *Router) removePeer(addr string) {
if addr == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
delete(r.peers, addr)
delete(r.peerTopics, addr)
for topic := range r.mesh {
delete(r.mesh[topic], addr)
}
}
What do these helpers do?
- topicPeers returns a shuffled slice of peers that support a given topic. The shuffle ensures random selection when choosing mesh members later.
- subscribedTopics returns the list of topics this node has joined. Used in the subscription hello.
- removePeer cleans up all state for a disconnected peer, including removing it from every topic mesh.
- Save main.go.
- Verify these additions compile by running:
go build ./...
You should see no output. Silence from go build means a clean compile.
Seeing a compile error?
- Check that the method receivers all use (r *Router) with the pointer. A value receiver won't have access to the connection maps.
- Make sure your methods are placed outside the existing function bodies (not nested inside main() or another method).
Help me fix my Go compile error in the utility methods.
Create the TCP listener and connect function
Each node needs to accept inbound connections (listen) and initiate outbound connections (dial). When a node dials another, it immediately sends a subscription hello announcing which topics it cares about.
- In main.go, find the NewRouter function. Add the Start method directly below it:
func (r *Router) Start() {
ln, err := net.Listen("tcp", r.addr)
if err != nil {
panic(err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go r.handleConn(conn, "")
}
}()
fmt.Printf("[%s] GossipSub node initialized\n", r.addr)
}
What does this code do?
Start binds a TCP listener on the node's address, then spawns a goroutine that accepts connections forever. Each accepted connection gets its own goroutine running handleConn. The empty string for peerAddr signals that we don't yet know who dialed us. We will learn their identity from the first RPC they send.
- Add the Connect method directly below Start:
func (r *Router) Connect(peerAddr string) {
conn, err := net.Dial("tcp", peerAddr)
if err != nil {
return
}
r.mu.Lock()
r.peers[peerAddr] = conn
r.mu.Unlock()
// Send subscription hello
topics := r.subscribedTopics()
hello := &RPC{Type: MsgTypeSubscribe, From: r.addr, Topics: topics}
r.sendRPC(conn, hello)
go r.handleConn(conn, peerAddr)
}
What does this code do?
Connect dials a peer's TCP address, stores the connection, and immediately sends a MsgTypeSubscribe RPC announcing this node's current topic list. Then it spawns a reader goroutine. This is the one-shot handshake that lets the remote peer know what topics you care about.
- Save main.go and run go build ./... again to confirm a clean compile.
Undefined: r.handleConn?
You will see this error if you try to build before adding the handleConn and handleRPC methods in the next substep. Continue to the next section and add them before running go build again.
Wire up the event loop and run the multi-node demo
The connection handler is the read loop that turns bytes on a TCP socket into dispatched RPC calls. It reads length-prefixed frames, unmarshals them, and routes each one to the appropriate handler.
- Add the handleConn method below Connect:
func (r *Router) handleConn(conn net.Conn, peerAddr string) {
reader := bufio.NewReader(conn)
for {
// Read 4-byte length prefix
var length uint32
if err := binary.Read(reader, binary.BigEndian, &length); err != nil {
r.removePeer(peerAddr)
return
}
// Read the JSON payload
buf := make([]byte, length)
if _, err := reader.Read(buf); err != nil {
r.removePeer(peerAddr)
return
}
var rpc RPC
if err := json.Unmarshal(buf, &rpc); err != nil {
continue
}
// Learn peer identity from the first message
if peerAddr == "" {
peerAddr = rpc.From
r.mu.Lock()
r.peers[peerAddr] = conn
r.mu.Unlock()
}
r.handleRPC(peerAddr, &rpc)
}
}
What does this code do?
This is the per-connection read loop. It mirrors the write side in sendRPC: read 4 bytes for the length, then read exactly that many bytes of JSON.
The peerAddr == "" check handles the accept-side case. When a node accepts an inbound connection, it does not yet know who dialed it. The first RPC's From field reveals the peer's identity.
- Add the handleRPC method directly below handleConn:
func (r *Router) handleRPC(from string, rpc *RPC) {
switch rpc.Type {
case MsgTypeSubscribe:
r.mu.Lock()
r.peerTopics[from] = make(map[string]bool)
for _, t := range rpc.Topics {
r.peerTopics[from][t] = true
}
r.mu.Unlock()
fmt.Printf("[%s] peer %s subscribes to %v\n", r.addr, from, rpc.Topics)
}
}
What does this code do?
handleRPC is the dispatch table. Right now it only handles MsgTypeSubscribe: it records which topics the connecting peer supports in the peerTopics map, then prints the announcement. You will add cases for GRAFT, PRUNE, IHAVE, IWANT, and Data in later steps.
Now replace the existing main() function to spin up 3 nodes and connect them all-to-all.
- Find the current main() function at the bottom of main.go:
func main() {
r := NewRouter(":9001")
_ = r
fmt.Println("GossipSub node initialized")
}
- Replace the entire main() function with this version that creates 3 nodes:
func main() {
// Spin up 3 nodes on different ports
nodes := make([]*Router, 3)
addrs := []string{":9001", ":9002", ":9003"}
for i, addr := range addrs {
nodes[i] = NewRouter(addr)
nodes[i].Start()
}
time.Sleep(100 * time.Millisecond)
// Connect all nodes to each other
for i := 0; i < len(nodes); i++ {
for j := i + 1; j < len(nodes); j++ {
nodes[i].Connect(addrs[j])
}
}
time.Sleep(500 * time.Millisecond)
}
What does this code do?
Three Router instances start on ports :9001, :9002, and :9003. The nested loop ensures every node connects to every other node exactly once. Each connection triggers a subscription hello, and the receiving node prints the announcement.
- Save main.go.
Before you run this, what do you expect to see? Three nodes initialize, then each node should announce the subscriptions it received from connecting peers. Since no node has called Subscribe() yet, the topic lists will be empty.
- Run the program:
go run main.go
You should see output similar to this:
[:9001] GossipSub node initialized
[:9002] GossipSub node initialized
[:9003] GossipSub node initialized
[:9002] peer :9001 subscribes to []
[:9003] peer :9001 subscribes to []
[:9003] peer :9002 subscribes to []
Each line proves a connection was established and a subscription hello was received. The empty brackets confirm the exchange mechanism works. Topics will populate once nodes call Subscribe() in the next step.
Not seeing peer subscription messages?
- If you see "address already in use", a previous run is still holding the port. Wait a few seconds or kill stale processes with lsof -i :9001 (macOS/Linux) to find and stop them.
- If you see only the "initialized" lines but no subscription messages, check that the time.Sleep(100 * time.Millisecond) line is present between starting nodes and connecting them. Without it, connections may race against listeners.
- Confirm that handleRPC has the fmt.Printf line inside the MsgTypeSubscribe case.
Help me debug why my GossipSub nodes aren't exchanging subscriptions.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that your terminal shows the subscription exchange messages.
ⓧ I'd like to double check the full code
Your main.go is now large. Scroll through and confirm you have all these sections in order:
- Package, imports, and protocol constants at the top.
- MsgType enum and RPC struct.
- MessageCache type with NewMessageCache, Put, Get, GetGossipIDs, Shift.
- SeenCache type with NewSeenCache, Has, Add, cleanup.
- Router struct and NewRouter.
- Start, Connect, handleConn, handleRPC.
- sendRPC, sendControl, topicPeers, subscribedTopics, removePeer.
- keys() helper and main() with 3 nodes at the bottom.
Your nodes can now discover each other and exchange topic information over TCP. Next up, you will use this peer awareness to construct bounded-degree meshes with GRAFT and PRUNE, the core mechanism that makes GossipSub scale.
Implement Mesh Construction with GRAFT and PRUNE
In the previous step, your nodes learned to discover each other and exchange topic subscriptions. But discovery alone does not move messages. Right now, if node A publishes a message, nothing happens because there is no forwarding layer.
This step introduces the core insight of GossipSub: instead of flooding a message to every connected peer (unbounded amplification), each node selects at most D=6 mesh peers per topic. Messages travel only along mesh links, giving you bounded-degree forwarding that scales.
In this step, get ready to:
- Implement topic joining (Subscribe and joinTopic) that selects mesh peers and sends GRAFT.
- Handle GRAFT and PRUNE control messages so the mesh is bidirectional.
- Implement message forwarding with seen-cache deduplication to prevent infinite loops.
Implement topic joining with Subscribe and joinTopic
When a node subscribes to a topic, it needs to pick up to D peers from its known topic-peers and form mesh links with them. This is the JOIN operation from the GossipSub spec.
- In main.go, find the Connect method. Add the following two methods directly below it:
func (r *Router) Subscribe(topic string) {
r.mu.Lock()
r.topics[topic] = true
r.mesh[topic] = make(map[string]bool)
r.mu.Unlock()
r.joinTopic(topic)
}
func (r *Router) joinTopic(topic string) {
r.mu.Lock()
defer r.mu.Unlock()
candidates := r.topicPeers(topic)
count := 0
for _, p := range candidates {
if count >= D {
break
}
r.mesh[topic][p] = true
r.sendControl(p, MsgTypeGraft, topic)
count++
}
}
What does this code do?
Subscribe registers the node's interest in a topic and initializes an empty mesh map for it. It then calls joinTopic to actually form mesh links.
joinTopic shuffles the known peers for that topic (randomness prevents hotspots), picks up to D=6, adds them to the local mesh map, and sends a GRAFT message to each. The GRAFT tells the remote peer to add this node to its own mesh, making the link bidirectional.
Handle GRAFT and PRUNE control messages
A GRAFT message means a peer wants to form a mesh link with you. A PRUNE message means a peer is dropping you from its mesh. These two control messages make the mesh self-organizing.
- In main.go, find the handleRPC function. Inside the switch rpc.Type block, add these two cases after the existing MsgTypeSubscribe case:
case MsgTypeGraft:
r.mu.Lock()
if r.topics[rpc.Topic] {
if r.mesh[rpc.Topic] == nil {
r.mesh[rpc.Topic] = make(map[string]bool)
}
r.mesh[rpc.Topic][from] = true
fmt.Printf("[%s] GRAFT from %s for topic %s\n", r.addr, from, rpc.Topic)
} else {
r.sendControl(from, MsgTypePrune, rpc.Topic)
}
r.mu.Unlock()
case MsgTypePrune:
r.mu.Lock()
delete(r.mesh[rpc.Topic], from)
r.mu.Unlock()
fmt.Printf("[%s] PRUNE from %s for topic %s\n", r.addr, from, rpc.Topic)
What does this code do?
The GRAFT handler checks whether this node is subscribed to the requested topic. If yes, it adds the sender to its own mesh (making the link bidirectional). If no, it responds with a PRUNE to tell the sender not to forward messages here.
The PRUNE handler simply removes the sender from the local mesh map. The node will no longer forward messages to that peer for this topic.
Now wire up the demo so you can see mesh formation in action. You need a placeholder heartbeat goroutine (filled in during the next step) and an updated main() that spins up 6 nodes.
- In the Start() method, find this line:
fmt.Printf("[%s] GossipSub node initialized\n", r.addr)
- Add go r.heartbeat() on the line directly above that print statement, so the end of Start() now looks like this:
go r.heartbeat()
fmt.Printf("[%s] GossipSub node initialized\n", r.addr)
- Add the placeholder heartbeat method anywhere below handleRPC:
func (r *Router) heartbeat() {
ticker := time.NewTicker(HeartbeatInterval)
for range ticker.C {
// placeholder
}
}
- Replace the entire main() function with the 6-node demo below:
func main() {
nodes := make([]*Router, 6)
addrs := []string{":9001", ":9002", ":9003", ":9004", ":9005", ":9006"}
for i, addr := range addrs {
nodes[i] = NewRouter(addr)
nodes[i].Start()
}
time.Sleep(100 * time.Millisecond)
for i := 0; i < len(nodes); i++ {
for j := i + 1; j < len(nodes); j++ {
nodes[i].Connect(addrs[j])
}
}
time.Sleep(200 * time.Millisecond)
for _, n := range nodes {
n.Subscribe("chat")
}
time.Sleep(500 * time.Millisecond)
fmt.Println("\n--- Mesh State ---")
for _, n := range nodes {
n.mu.RLock()
fmt.Printf("[%s] mesh[chat] = %v\n", n.addr, keys(n.mesh["chat"]))
n.mu.RUnlock()
}
time.Sleep(2 * time.Second)
}
What does this demo do?
It launches 6 nodes on ports 9001 through 9006, connects every node to every other node, subscribes all to the "chat" topic, then prints each node's mesh state. With 5 peers per node and D=6, every node will have all 5 in its mesh (since 5 is less than D).
Before you run this, predict: how many mesh peers will each node show, given that there are only 5 other nodes and D=6?
- Save main.go and run the demo:
go run main.go
Each node prints 5 mesh peers (all others), since 5 is below the D=6 cap. You will see GRAFT messages as nodes accept each other into their meshes, followed by the mesh state summary:
[%s] mesh[chat] = [:9002, :9003, :9004, :9005, :9006]
Why does every node have all 5 peers?
With only 6 nodes total, each node has 5 candidate peers. Since D=6 allows up to 6 mesh links, all 5 fit comfortably. In a larger network (13+ nodes), the D_high=12 cap would trigger PRUNE to keep the mesh bounded.
Not seeing GRAFT messages or mesh output?
- Make sure you added the MsgTypeGraft case inside the switch rpc.Type block, not outside it.
- Check that Subscribe is called AFTER the Connect loop and the time.Sleep that follows it. Peers must be connected before the node can join them to the mesh.
- If you see a compile error about heartbeat being undefined, make sure you added the placeholder heartbeat() method.
Still stuck? Help me debug why my GRAFT messages are not appearing.
Implement message forwarding with deduplication
The mesh is formed, but messages cannot travel along it yet. You need a data handler that forwards incoming messages to mesh peers, and a deduplication check that stops messages from looping infinitely through the mesh.
- In handleRPC, add this MsgTypeData case between the MsgTypeSubscribe case and the MsgTypeGraft case:
case MsgTypeData:
if r.seen.Has(rpc.MessageID) {
return
}
r.seen.Add(rpc.MessageID)
r.mcache.Put(rpc.MessageID, rpc)
fmt.Printf("[%s] received message %q from %s on topic %s\n", r.addr, rpc.Data, from, rpc.Topic)
r.mu.RLock()
for p := range r.mesh[rpc.Topic] {
if p == from || p == rpc.From {
continue
}
if conn, ok := r.peers[p]; ok {
r.sendRPC(conn, rpc)
}
}
r.mu.RUnlock()
How does deduplication prevent infinite loops?
The first thing the handler does is check r.seen.Has(rpc.MessageID). If this message ID was already processed, it returns immediately. Without this check, node A would forward to B, B would forward back to A, A would forward to B again, and so on forever.
The handler also skips forwarding to from (the immediate sender) and rpc.From (the original publisher). This avoids wasted traffic even before the seen cache would catch duplicates.
- Now add the Publish method that lets a node originate a message. Place it directly below joinTopic:
func (r *Router) Publish(topic, data string) {
id := fmt.Sprintf("%s-%d-%d", r.addr, time.Now().UnixNano(), rand.Int())
msg := &RPC{Type: MsgTypeData, From: r.addr, Topic: topic, MessageID: id, Data: data}
r.seen.Add(id)
r.mcache.Put(id, msg)
r.mu.RLock()
defer r.mu.RUnlock()
if r.topics[topic] {
for p := range r.mesh[topic] {
if conn, ok := r.peers[p]; ok {
r.sendRPC(conn, msg)
}
}
}
fmt.Printf("[%s] published message %q to topic %s\n", r.addr, data, topic)
}
What does Publish do?
Publish generates a unique message ID, wraps the data in an RPC envelope, then immediately marks it as seen (so this node will not process its own message if it bounces back). It stores the message in mcache for gossip recovery later.
The message is then sent to every mesh peer for the topic. Only mesh peers receive it directly. Non-mesh peers will hear about it through gossip (IHAVE/IWANT) in the next step.
- Save main.go and run the demo again to confirm everything still compiles and the mesh forms correctly:
go run main.go
You see the same GRAFT messages and mesh state as before. The MsgTypeData handler and Publish method are now wired up and ready for the next step, where you will publish a message and watch it propagate through the mesh.
Seeing a compile error?
- Check that the MsgTypeData case is inside the switch block, not after the closing brace.
- Make sure Publish is a method on *Router (starts with func (r *Router)).
Need help? Debug my GossipSub compile error in main.go.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and the output shows all 6 nodes with their mesh peers listed.
ⓧ I'd like to double check the full code
Your main.go is now over 30 lines, so confirm the following sections are present in order:
- Package declaration, imports, constants, type declarations (unchanged from before).
- MessageCache and SeenCache structs and methods (unchanged from before).
- Router struct and NewRouter (unchanged).
- Start() now includes go r.heartbeat() before the initialized print.
- Connect() (unchanged).
- Subscribe() and joinTopic() (new).
- Publish() (new, mesh-only path).
- handleConn() (unchanged).
- handleRPC() with cases for MsgTypeSubscribe, MsgTypeData, MsgTypeGraft, MsgTypePrune.
- heartbeat() placeholder.
- Helper functions: sendRPC, sendControl, topicPeers, subscribedTopics, removePeer, keys.
- main() with 6 nodes, full mesh connect, Subscribe to "chat", and mesh state printout.
Your mesh is alive. Every node has formed bounded-degree links with its peers, and the GRAFT/PRUNE protocol ensures the mesh stays bidirectional. Next up, you will add gossip emission (IHAVE/IWANT) so that non-mesh peers can recover messages they missed.
Add Gossip Emission and IHAVE/IWANT Recovery
Your mesh forwards messages reliably between connected peers. But what happens when a node joins late, or a mesh link drops for a moment?
The mesh alone is fragile. Gossip is the lazy-push recovery layer that makes GossipSub robust. Nodes periodically tell non-mesh peers "I have these messages" (IHAVE). If a peer missed one, it requests the full message (IWANT). This two-phase protocol recovers lost messages without flooding.
In this step, get ready to:
- Understand the gossip emission pattern that drives IHAVE messages.
- Implement the IHAVE handler that requests missed messages.
- Implement the IWANT handler that delivers requested messages.
Understand the gossip emission pattern
Gossip emission is the sending side of the recovery mechanism. On each heartbeat tick, a node selects up to D random peers from its topic peer list that are NOT in the mesh. It sends those peers an IHAVE message containing recent message IDs from the message cache.
Why not just send the full message to everyone?
That would be flooding, which is exactly what GossipSub avoids. IHAVE is metadata-only (just IDs, no payload). If a peer already has the message, it ignores the IHAVE. Only peers that actually missed something request the full data via IWANT. This keeps bandwidth bounded.
You will wire gossip emission into the heartbeat in the next step. For now, you will build the two handlers that respond to gossip: IHAVE and IWANT.
Implement the IHAVE handler
When a node receives an IHAVE, it checks each advertised message ID against its seen cache. Any ID it has not seen before is a message it missed. The node responds with an IWANT listing those unseen IDs.
- In main.go, find the handleRPC function. Locate the case MsgTypePrune: block that ends with:
case MsgTypePrune:
r.mu.Lock()
delete(r.mesh[rpc.Topic], from)
r.mu.Unlock()
fmt.Printf("[%s] PRUNE from %s for topic %s\n", r.addr, from, rpc.Topic)
- Add the following IHAVE handler case directly after the PRUNE case, still inside the switch statement:
case MsgTypeIHave:
// Check which advertised messages we haven't seen
var needed []string
for _, id := range rpc.MessageIDs {
if !r.seen.Has(id) {
needed = append(needed, id)
}
}
// Request any unseen messages via IWANT
if len(needed) > 0 {
iwant := &RPC{Type: MsgTypeIWant, From: r.addr, MessageIDs: needed}
r.mu.RLock()
if conn, ok := r.peers[from]; ok {
r.sendRPC(conn, iwant)
}
r.mu.RUnlock()
}
What does this code do?
- It iterates over the message IDs the peer advertised in the IHAVE.
- For each ID, it checks the seen cache. If the node already has it, no action needed.
- Any unseen IDs are collected into a needed slice. If at least one is missing, the node sends an IWANT back to the peer requesting the full messages.
- Save main.go.
Implement the IWANT handler and test message propagation
IWANT is the response to a node's request. When a peer sends IWANT with a list of message IDs, the handler looks each one up in the message cache and sends the full message back.
- In main.go, add the IWANT handler case directly after the IHAVE case you just wrote:
case MsgTypeIWant:
// Look up requested messages and send them back
for _, id := range rpc.MessageIDs {
if msg, ok := r.mcache.Get(id); ok {
r.mu.RLock()
if conn, ok := r.peers[from]; ok {
r.sendRPC(conn, msg)
}
r.mu.RUnlock()
}
}
What does this code do?
- It iterates over the message IDs the peer requested.
- For each ID, it calls mcache.Get(id) to retrieve the full message from the cache.
- If found, it sends the complete RPC (with data payload) back to the requesting peer.
Now update main() to publish a message so you can see mesh forwarding in action.
- In main.go, find the line in main() that says:
fmt.Println("\n--- Mesh State ---")
- Add these three lines directly above that line to publish a message and give it time to propagate:
fmt.Println("\n--- Publishing message ---")
nodes[0].Publish("chat", "Hello GossipSub!")
time.Sleep(2 * time.Second)
What does this change?
Node 0 publishes "Hello GossipSub!" to the "chat" topic. The message travels through the mesh to all other subscribed nodes. The 2-second sleep gives the mesh time to forward before printing state.
- Save main.go.
Before you run this, what do you expect to see? Will all 5 other nodes receive the message, or only some of them?
- Run the program by executing:
go run main.go
You should see all 6 nodes initialize, then a "Publishing message" section where node :9001 publishes "Hello GossipSub!". All other nodes print "received message" confirmations. The mesh state follows, showing each node's mesh peers for the "chat" topic.
Why do all nodes receive the message?
With only 6 nodes and D=6, every node's mesh contains all other peers (since 5 < D). The mesh forwarding alone delivers to everyone. The IHAVE/IWANT handlers you just built are in place and ready, but they will not be triggered until gossip emission runs inside the heartbeat in the next step.
Not seeing received messages from other nodes?
- Make sure the fmt.Println("\n--- Publishing message ---") and nodes[0].Publish(...) lines come BEFORE the mesh state printout, not after it.
- Check that the IHAVE and IWANT cases are inside the switch rpc.Type block, not after the closing brace.
- If you see a compilation error, verify that the closing brace of the PRUNE case is intact and the new cases follow it.
Help me debug why my GossipSub nodes aren't receiving published messages.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go with both the IHAVE and IWANT handlers in place.
ⓧ I'd like to double check the full code
Your handleRPC function should now have these cases in order: MsgTypeSubscribe, MsgTypeData, MsgTypeGraft, MsgTypePrune, MsgTypeIHave, MsgTypeIWant. Scroll up and confirm each handler matches the code blocks above.
Your main() function should have the publishing section (fmt.Println("\n--- Publishing message ---"), nodes[0].Publish(...), and a 2-second sleep) immediately before the mesh state printout.
Your IHAVE and IWANT handlers are ready. Right now, nothing triggers them because gossip emission is not running yet. In the next step, you will wire gossip emission into the heartbeat so that nodes actively advertise message IDs to non-mesh peers and recover missed messages automatically.
Implement Heartbeat and Run the Full Demo
Your mesh forwards messages and gossip recovers missed ones. But what happens when a peer disappears? A node could end up with zero mesh peers (deaf to all messages) or accumulate too many (back to flooding).
The heartbeat is the self-healing loop that keeps the protocol alive and bounded. Every second, each node audits its mesh degree per topic and uses GRAFT/PRUNE to restore balance.
In this step, get ready to:
- Implement heartbeat mesh maintenance with GRAFT and PRUNE to keep degree within bounds.
- Add gossip emission and cache rotation to the heartbeat tick.
- Extend Publish with the fanout path and run the full 6-node demo.
Implement the heartbeat mesh maintenance
The heartbeat fires every 1 second. On each tick, it checks every topic mesh: if the degree drops below DLow (4), it grafts new peers up to D (6). If the degree exceeds DHigh (12), it prunes down to D.
- In main.go, find the heartbeat function with the // placeholder comment inside it.
- Delete the // placeholder line. You will replace it with the mesh maintenance logic below.
- Paste this code in its place (inside the for range ticker.C { block):
r.mu.Lock()
// Mesh maintenance
for topic := range r.mesh {
// Remove dead peers
for p := range r.mesh[topic] {
if _, ok := r.peers[p]; !ok {
delete(r.mesh[topic], p)
}
}
meshSize := len(r.mesh[topic])
if meshSize < DLow {
candidates := r.topicPeers(topic)
needed := D - meshSize
added := 0
for _, p := range candidates {
if added >= needed {
break
}
if !r.mesh[topic][p] {
r.mesh[topic][p] = true
r.sendControl(p, MsgTypeGraft, topic)
added++
fmt.Printf("[%s] heartbeat: grafted %s for topic %s (mesh was %d)\n", r.addr, p, topic, meshSize)
}
}
- Continue immediately below with the prune branch, fanout maintenance, and the Unlock:
} else if meshSize > DHigh {
remove := meshSize - D
removed := 0
for p := range r.mesh[topic] {
if removed >= remove {
break
}
delete(r.mesh[topic], p)
r.sendControl(p, MsgTypePrune, topic)
removed++
}
}
}
// Fanout maintenance
for topic, lastTime := range r.lastPub {
if time.Since(lastTime) > FanoutTTL {
delete(r.fanout, topic)
delete(r.lastPub, topic)
} else if len(r.fanout[topic]) < D {
candidates := r.topicPeers(topic)
for _, p := range candidates {
if len(r.fanout[topic]) >= D {
break
}
r.fanout[topic][p] = true
}
}
}
r.mu.Unlock()
What does this code do?
The mesh maintenance loop runs on every heartbeat tick. It first removes peers that have disconnected (their connection is no longer in the peers map). Then it checks the mesh degree for each topic.
- If mesh size drops below DLow (4), it selects random peers from topicPeers and sends GRAFT to bring the count back up to D (6).
- If mesh size exceeds DHigh (12), it prunes excess peers back down to D.
- Fanout maintenance expires stale fanout entries older than FanoutTTL (60 seconds) and refills any fanout sets that have dropped below D.
- Save main.go.
- Run your program to see the heartbeat in action:
go run main.go
You should see nodes printing GRAFT messages during their heartbeat ticks as they maintain mesh degree. Lines like heartbeat: grafted :9005 for topic chat confirm the self-healing loop is working.
Not seeing heartbeat graft messages?
- Make sure you deleted the // placeholder line and pasted both blocks inside the for range ticker.C { body, between the opening { and the existing closing }.
- Check that r.mu.Unlock() appears after the fanout loop closes. A missing unlock causes the program to deadlock silently.
- With only 6 nodes and D=6, every node can have all 5 peers in its mesh (5 < D). Graft messages appear during initial mesh formation rather than as ongoing repairs.
help me debug my heartbeat mesh maintenance
Add gossip emission and cache rotation
The heartbeat does more than maintain mesh degree. It also emits IHAVE gossip to non-mesh peers and rotates the message cache window.
- In main.go, find r.mu.Unlock() inside your heartbeat function.
- Add the gossip emission logic directly below r.mu.Unlock() (but still inside the for range ticker.C loop):
r.mu.RLock()
allTopics := make(map[string]bool)
for t := range r.mesh {
allTopics[t] = true
}
for t := range r.fanout {
allTopics[t] = true
}
for topic := range allTopics {
ids := r.mcache.GetGossipIDs(topic)
if len(ids) == 0 {
continue
}
candidates := r.topicPeers(topic)
count := 0
for _, p := range candidates {
if count >= D {
break
}
if r.mesh[topic] != nil && r.mesh[topic][p] {
continue
}
if r.fanout[topic] != nil && r.fanout[topic][p] {
continue
}
- Continue immediately below with the IHAVE send, the read-unlock, and the cache shift:
ihave := &RPC{Type: MsgTypeIHave, From: r.addr, Topic: topic, MessageIDs: ids}
if conn, ok := r.peers[p]; ok {
r.sendRPC(conn, ihave)
count++
}
}
}
r.mu.RUnlock()
// Shift cache
r.mcache.Shift()
What does the gossip emission do?
Gossip targets peers that are NOT in the mesh and NOT in the fanout for a topic. It sends them IHAVE messages listing recent message IDs from the cache.
If a non-mesh peer missed a message, it can respond with IWANT to fetch the full content. After emission, mcache.Shift() rotates the sliding window so stale messages eventually expire.
- Save main.go and run the program again:
go run main.go
Watch the output for a few seconds. You should see the message delivered to all nodes, plus heartbeat activity showing the mesh is being maintained each second.
Seeing a deadlock or no output after the message?
- Confirm that r.mu.RLock() (read lock) starts the gossip section, and r.mu.RUnlock() closes it. Using r.mu.Lock() twice would deadlock.
- Make sure r.mcache.Shift() is after r.mu.RUnlock(), not inside the read-lock section.
help me debug my gossip emission in the heartbeat
Extend Publish with the fanout path and run the full demo
The heartbeat maintains fanout entries, but the Publish function does not yet populate them. Right now, publishing to a topic you are not subscribed to does nothing.
- In main.go, find the Publish method.
- Locate the closing } of the if r.topics[topic] block (after the mesh send loop).
- Find this line:
}
- Replace that closing brace with the fanout else-branch so the line now reads:
} else {
if r.fanout[topic] == nil {
r.fanout[topic] = make(map[string]bool)
}
if len(r.fanout[topic]) < D {
candidates := r.topicPeers(topic)
for _, p := range candidates {
if len(r.fanout[topic]) >= D {
break
}
r.fanout[topic][p] = true
}
}
r.lastPub[topic] = time.Now()
for p := range r.fanout[topic] {
if conn, ok := r.peers[p]; ok {
r.sendRPC(conn, msg)
}
}
fmt.Printf("[%s] published to fanout[%s] -> %v\n", r.addr, topic, keys(r.fanout[topic]))
}
What does the fanout path do?
When a node publishes to a topic it is NOT subscribed to, it cannot use the mesh (it has no mesh peers for that topic). Instead, it selects up to D peers from topicPeers and stores them in the fanout map.
The message is sent to those fanout peers. The heartbeat maintains and eventually expires these fanout entries after FanoutTTL (60 seconds) of inactivity.
- Now update main() to let the demo run longer so you can observe multiple heartbeat cycles.
- Find this line near the bottom of main():
time.Sleep(2 * time.Second)
- Replace it with:
time.Sleep(5 * time.Second)
- Save main.go.
Before you run this, predict: will every node receive the "Hello GossipSub!" message, or will some be left out?
- Run the full demo:
go run main.go
Every node receives the message. You should see output showing all 6 nodes initializing, mesh formation via GRAFT messages, the published message arriving at each node, a mesh state printout, and then several seconds of heartbeat ticks maintaining the mesh.
What should I see?
- Six GossipSub node initialized lines (one per node).
- GRAFT messages as nodes form their meshes.
- The published message "Hello GossipSub!" received by nodes :9002 through :9006.
- A mesh state printout showing each node's mesh peers for the chat topic.
- Heartbeat graft/prune logs if any node's mesh drifted outside bounds.
Program panics with address already in use?
- A previous run may still hold ports 9001-9006. Wait a few seconds for the OS to release them, or press Ctrl+C on any lingering process.
- If the panic mentions a specific port, check for other programs using that port range.
help me fix my GossipSub demo startup
✔️ Awesome, I've got everything!
Great. Confirm you have saved main.go and your demo runs successfully with all 6 nodes receiving the message.
ⓧ I'd like to double check the full code
Your main.go is now over 300 lines. Scroll through and confirm these sections appear in order:
- Protocol constants (D, DLow, DHigh, HeartbeatInterval, FanoutTTL, MCacheLen, MCacheGossip, SeenTTL).
- Message types and RPC struct.
- MessageCache with Put, Get, GetGossipIDs, Shift.
- SeenCache with Has, Add, cleanup.
- Router struct with mesh, fanout, lastPub, peers, peerTopics, mcache, seen.
- Publish with both the mesh path (if r.topics[topic]) and the fanout else-branch.
- handleRPC handling Subscribe, Data, Graft, Prune, IHave, IWant.
- heartbeat with mesh maintenance, fanout maintenance, gossip emission, and mcache.Shift().
- main() spinning up 6 nodes, connecting all-to-all, subscribing to chat, publishing, printing mesh state, and sleeping 5 seconds.
Your GossipSub implementation is fully functional. Nodes form bounded-degree meshes, forward messages without loops, emit gossip for recovery, and self-heal via heartbeat. Next up, you will push this further with the Secret Mission.
Secret mission
Add Fanout Mechanics
In the GossipSub v1.0 spec, a node can publish to a topic it has not subscribed to. The protocol uses a separate fanout map to reach subscribers without joining the mesh. Can you prove this path works in your implementation?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using only localhost TCP connections, so there are no ongoing costs or cloud resources to worry about.
Resources you used:
- The gossipsub project folder on your Desktop (contains main.go and go.mod).
- Localhost TCP ports :9001 through :9006 (released automatically when the process stops).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your GossipSub implementation.
- Your project folder stays on your Desktop ready for further development.
- The process only uses resources while running. Once you press Ctrl+C in the terminal, all TCP connections close and ports are released.
- Go remains installed on your machine for future projects.
Pause - I'll come back to this later
Stop the running process to free up your terminal. Your files stay on disk for next time.
- Press Ctrl+C in the terminal where the demo is running to stop all nodes.
- Your gossipsub folder remains on your Desktop. You can resume anytime with go run main.go.
Delete - I don't want to use this again
Remove all project files from your machine entirely.
- Press Ctrl+C in the terminal to stop the running process.
- Delete the project folder by running:
cd ~/Desktop
rm -rf gossipsub
On Windows, run:
cd ~\Desktop
rmdir /s /q gossipsub
- The TCP ports are released the moment the process stops. No additional network cleanup is needed.
Nice Work!
Nice Work!
You just built a working GossipSub v1.0 protocol implementation from scratch in pure Go, with zero external dependencies. You can now demo bounded-degree pubsub, gossip recovery, and self-healing mesh maintenance in a live multi-node cluster.
Here's what you learned:
- Implemented bounded-degree mesh construction with GRAFT/PRUNE control messages that prevent naive flooding and keep each node's peer count within D_low/D_high bounds.
- Built a message deduplication system using a seen cache and a sliding-window message cache that prevents infinite forwarding loops across the mesh.
- Created a heartbeat-driven self-healing loop that detects peer loss, grafts new peers to maintain mesh degree, emits IHAVE gossip for lazy recovery, and rotates the cache window every tick.
- Secret Mission: Added fanout mechanics so a node can publish to a topic it is not subscribed to, maintaining a separate fanout map with TTL expiry and heartbeat refill.
Ready to quiz yourself?