All articles

Building real-time messaging in Go: WebSocket hub design, @mention notifications, and the concurrency bugs I hit first

Suman Akkisetty
Builder of KaryoSpace
February 2026
12 min read

Real-time messaging feels straightforward until you sit down to implement it. You need WebSocket connections, a way to route messages to the right recipients, typing indicators that clean themselves up, @mention notifications, and file attachments, all running concurrently under Go's memory model. The first version I wrote had three distinct concurrency bugs. This article explains what I built, how I structured the hub, and what broke before it worked.

The hub: one goroutine owns the maps

The core of the messaging system is a Hub struct that lives for the lifetime of the server process. Every WebSocket connection registers with it. Every message goes through it. Every disconnect is handled by it.

type Hub struct {
    clients        map[*Client]bool
    channelClients map[string]map[*Client]bool // channelID → set
    register       chan *Client     // buffered 64
    unregister     chan *Client     // buffered 64
    broadcast      chan BroadcastMsg // buffered 256
    stop           chan struct{}
    mu             sync.RWMutex
}

The most important design decision here is what is not in the struct: no mutex guarding the clients and channelClients maps directly. Instead, all mutations to those maps happen inside a single Run() goroutine that processes items from the register, unregister, and broadcast channels. Because only one goroutine ever touches these maps, there is no data race. The channels carry the synchronization.

The mu sync.RWMutex is used for a different purpose: guarding per-client state that both the hub goroutine and the client's own read pump need to access simultaneously. More on that below.

Channel-scoped fan-out

Early WebSocket hub examples broadcast to every connected client. That is fine for a chat room with one room. KaryoSpace has named channels, each with its own membership. A message sent to the #engineering channel must not appear in #general.

The channelClients map[string]map[*Client]bool field makes this O(1). When a broadcast arrives, the hub looks up the channel ID in the outer map and iterates only over the inner set of subscribers. If the channel has 12 members and 400 people are connected to the server, the hub touches 12 clients, not 400.

type BroadcastMsg struct {
    ChannelID     string
    Envelope      WSEnvelope
    ExcludeClient *Client
}

The ExcludeClient field solves the echo problem. When you send a message, you do not want to receive it back from the server as if it were new. The hub skips the sending client during fan-out. This keeps the client-side code simple: it renders the message locally on send and never has to deduplicate against a server echo.

Serialization is done once, before the fan-out loop. marshalEnvelopeHub() converts the WSEnvelope to []byte a single time, and the hub passes the same byte slice to every target client's send channel. Repeated JSON marshaling per recipient would be wasteful and pointless since all recipients receive the same bytes.

The send buffer and the slow receiver problem

Each Client has a buffered send chan []byte. The hub writes to this channel without blocking. If the channel is full because the client's write pump is behind, the hub drops the client:

1

Hub tries to write to client's send channel

Non-blocking select. If the channel has capacity, the message is queued. If not, the default branch fires.

2

Send buffer full: close the channel and remove the client

close(c.send) signals the write pump to exit. The hub removes the client from clients and all channelClients entries.

3

Client reconnects

The WebSocket client detects the closed connection and reconnects. The hub treats this as a fresh registration. No special recovery path needed.

The alternative to dropping is blocking: make the hub wait until the slow client drains its send buffer. I rejected this because a single slow client would stall all other recipients in the same channel. Dropping is the correct tradeoff for a real-time system where a missed message during a transient slowdown is recoverable but stalling all other subscribers is not.

Design decision

When a client's send buffer is full, close the connection rather than block. The client reconnects cleanly. A blocked hub write would propagate the slowness to every other subscriber in the channel. One slow connection should not make messaging feel slow for everyone else.

The three concurrency bugs I hit

Bug 1: map mutation outside the hub goroutine

The first version had HTTP handlers registering new clients by writing directly to hub.clients:

// WRONG: handler goroutine writing to hub's map
hub.clients[c] = true

This is a data race. The Run() goroutine reads and writes hub.clients continuously. Any concurrent write from an HTTP handler, which runs on its own goroutine, can corrupt the map. Go's race detector caught this immediately in testing, but in production it would have been a silent crash.

The fix is the channel pattern: handlers send a *Client to hub.register, and only Run() processes it:

// Correct: handler sends to channel; Run() owns the map write
hub.register <- c

Every mutation to clients and channelClients now happens exclusively inside Run(). No other goroutine touches those maps.

Bug 2: typing.stop not broadcast on disconnect

When a client disconnects, any typing indicator they triggered stays visible to other channel members forever. This is one of those bugs that is invisible during normal testing because disconnects happen infrequently and the indicator times out on the client after a few seconds. Under load, or when a tab closes during active typing, the indicator would stick.

The fix: the unregister path reads the client's channel subscriptions, broadcasts a WSTypeTypingStop envelope for each one, then removes the client:

// In Run(), on unregister:
c.mu.RLock()
channels := make([]string, 0, len(c.channels))
for ch := range c.channels {
    channels = append(channels, ch)
}
c.mu.RUnlock()

for _, ch := range channels {
    hub.broadcastTypingStop(ch, c)
    delete(hub.channelClients[ch], c)
}
delete(hub.clients, c)
close(c.send)

Reading c.channels requires c.mu.RLock() because the client's read pump can write to c.channels when it processes a join message. This is the one place where per-client locking is genuinely necessary, not a shortcut for lazy synchronization.

Bug 3: deciding between blocking and dropping

This was not a race condition. It was a design bug: the first version blocked on send:

// WRONG: blocks the hub if client is slow
c.send <- data

Under normal conditions this works fine. Under load, or when one client has a slow network connection, the hub goroutine blocks on one client's channel write and stops processing all other incoming events: new messages, registrations, unregistrations. The system appears to freeze from every other user's perspective.

The fix is a non-blocking select with a drop-and-close default:

select {
case c.send <- data:
default:
    close(c.send)
    delete(hub.clients, c)
    // remove from channelClients...
}
Lesson learned

In a fan-out system, never block on a single recipient. If you block on the slowest client, you degrade the experience for every other connected user. The hub's throughput must be independent of any individual client's receive speed.

The @mention system

Mentions in KaryoSpace use a structured token format rather than raw display names. When the frontend serializes a mention, it encodes both the display name and the stable user ID:

@[Display Name](userID)

Storing display names alone would break if someone changes their name. Storing only user IDs would require a lookup on every render. The token stores both: the display name for rendering, the user ID for notification routing.

Parsing is a single regex compiled once at startup:

mentionRe = regexp.MustCompile(`@\[([^\]]+)\]\(([^)]+)\)`)

ParseMentions(body) runs this against the message body and returns a deduplicated slice of user IDs. Deduplication matters: mentioning the same person twice in one message should produce one notification, not two.

Rendering for display converts each token to safe HTML:

// Token: @[Sarah Jones](user_abc123)
// Output: <span class="mention">@Sarah Jones</span>

RenderBodyHTML() handles this conversion before the message is sent to clients. The stored body always contains the raw tokens. The rendered HTML is generated on read, not stored.

Notification delivery

After a message is saved to MongoDB, the message save handler calls Notify() for each mentioned user ID. Notifications are stored as in-app events, visible via the bell icon. The notification payload includes the channel name, the sender's display name, and a preview of the message body, truncated and stripped of token syntax so the notification reads as plain text.

A user mentioned while offline sees the notification the next time they load the app. A user currently online receives it as a WebSocket push to their notification channel, which is separate from the messaging channels. This keeps notification delivery decoupled from the message broadcast path.

Attachment handling

File attachments are stored separately from messages. The message body contains a reference token rather than the binary data:

[attachment:a3f9b2c1d4e5f678:report.pdf:204800]

The token encodes the attachment's hex ObjectID, filename, and size in bytes. RenderBodyHTML() expands these tokens to either an inline image or a download link, depending on the file extension:

imageExts = map[string]bool{
    ".jpg": true, ".jpeg": true, ".png": true,
    ".gif": true, ".webp": true, ".svg": true,
}

Images render as <img> tags with the CDN URL. Everything else renders as a download link with a formatted file size label. formatFileSize(b int64) produces human-readable output: "1.2 MB", "340 KB", "512 B". It sounds minor, but displaying "204800 bytes" in a chat message is not acceptable.

Keeping attachments as separate documents has another advantage: you can delete an attachment without modifying the message record, and you can reference the same attachment from multiple messages without duplicating the binary data in MongoDB.

Channel management

The REST API for channels uses a dispatcher pattern rather than individual route registrations for each endpoint. ChannelsDispatch reads the request method and path and routes to the appropriate handler function. This keeps the route table clean and makes it easy to see at a glance which operations each resource supports.

HandlerOperation
ListChannelsHandler Returns active channels the user is a member of
ListArchivedChannelsHandler Separate endpoint to declutter the main list
CreateChannelHandler Creates channel, sets creator as first member
GetChannelHandler Fetches channel metadata and current members
UpdateChannelHandler Renames channel or updates description
ArchiveChannelHandler Soft-deletes: hides from active list, preserves history
AddMemberHandler Adds user to channel membership
RemoveMemberHandler Removes user; does not delete their message history

All operations persist to MongoDB's messaging_channels and messaging_messages collections. There is no in-memory cache of channel membership in the hub: membership is authoritative in the database. The hub's channelClients map tracks who is currently connected and subscribed, not who is a member.

WebRTC signaling via the hub

The hub also relays WebRTC signaling messages: offer, answer, and ICE candidates. This is a small addition to the hub's broadcast loop. When a signaling envelope arrives, the hub routes it to the target peer by user ID rather than by channel.

There is no media relay. The hub passes the signaling handshake and then steps out. Once the peers have negotiated, audio and video flow peer-to-peer. This keeps the server load flat regardless of how many concurrent calls are happening, and it avoids the latency that a server-side relay would add to a real-time voice connection.

Architecture note

Reusing the existing WebSocket hub for WebRTC signaling avoids adding a second persistent connection type. The signaling messages are small and infrequent. The hub already has the connection registry and routing logic. There is no reason to build a separate signaling server.

What I would do differently

Three things stand out now that the system has been running for a while:

  1. Add a message sequence number from the start. Clients currently request missed messages after a reconnect by timestamp. Timestamps have edge cases around clock skew and messages created within the same millisecond. A monotonically increasing per-channel sequence number would make gap detection and replay reliable with no ambiguity.
  2. Write the race detector into CI from day one. All three bugs I described above would have been caught immediately if I had been running go test -race in CI from the first commit. I caught them during development, but I caught them by reading the code carefully rather than by running the detector. That is a slower feedback loop.
  3. Decouple notification delivery from the message save path. Currently, Notify() is called synchronously inside the message save handler. For most messages it is fast. A notification delivery failure, a slow MongoDB write, blocks the handler response. Moving it to an async worker would make the message save path more resilient and easier to retry independently.

The thing that surprised me most

I expected the hardest part to be the WebSocket connection management. It was not. The channel and select patterns in Go make the hub straightforward to reason about once you commit to the single-goroutine-owns-the-maps rule.

The hardest part was the @mention system, specifically getting the token format right such that the stored representation is stable across renames, the rendered HTML is safe against injection, and the notification preview reads naturally as plain text. It required more thought than the concurrency model.

If I had to summarize the design in one sentence: keep map mutations on one goroutine, make fan-out O(channel size) not O(all clients), and never block the hub on a single slow receiver.


KaryoSpace is live at karyospace.com. Questions about real-time systems or WebSocket architecture: sumanakkisetty@gmail.com

Contact Us
Message sent!
We read every submission and will get back to you.