JA

Why I chose Go for an anonymous chat service

Introduction

I am building an anonymous chat service as a side project, written in Go. Before I started I went back and forth: for something like chat, where most of the work is waiting on the network, isn’t Node.js and its event loop the better fit?

The short answer is that I did not pick Go because it won on performance. Either one would have worked. This is why I picked it anyway, written so it makes sense even if you are new to programming.

Here is what this post covers:

First, the size of what I am building

Before the reasons, the premises. Change these and the conclusion changes with them.

People in one roomTwo or three
Concurrent connections, at most1,000
ServersTwo small ones

A room holds three people at most. That matters later.

What the server does for a single connection

For each connected person, the chat server watches three things at once.

Diagram: one connected user, watched by a reader, a writer and a heartbeat running in parallel One connected user Reader waits until they speak Writer sends when the room changes Heartbeat records that they are alive

All three spend nearly all of their time waiting for something to happen. That is the part people mean when they say chat is mostly waiting.

A restaurant, to make it concrete

Node.js (the event loop) = a restaurant with one waiter

Diagram: three customers all reach a single waiter, who handles the orders Node.js (one waiter) The waiter Take orders Customer A Customer B Customer C

There is only one waiter. But this waiter is very good: while customer A’s food is cooking, they take B’s order and bring C the bill. Not wasting the waiting time is exactly what they are best at.

The trade-off is that the one waiter has to remember where everybody is up to. “How far along is A?” lives in the waiter’s head, which in code means callbacks or async/await.

Go (goroutines) = every customer gets their own waiter

Diagram: every customer gets one dedicated waiter Go (a waiter each) Customer A A's own waiter Customer B B's own waiter Customer C C's own waiter

In Go, each customer gets a dedicated waiter (a goroutine). Being dedicated, that waiter only ever thinks about A, from the top down, and never has to hold anyone else’s situation in mind.

And these waiters are cheap to employ. Memory per goroutine starts at a few kilobytes, so even hiring 1,000 × 3 = 3,000 of them fits on a small server.

The waiting itself performs about the same

To be straight about it: while customers wait, the machinery behind the counter — the OS facility that reports which connections became ready — is essentially the same in both. For plain waiting, there is no meaningful difference between the two.

You also see “Go is faster because it uses several CPU cores.” That does not apply to this service. The next section covers why.

“Broadcasting gets heavy as people pile in” does not happen here

A line that comes up whenever chat is discussed: one person speaks, it has to reach everyone, so a crowd gets expensive. For a service with 100 people in a room, that is true.

But a room here holds three people at most. One message goes to three recipients at the outside. Any language finishes that instantly. Go gains nothing here.

On top of that, the service runs across two servers, so delivery to someone connected to the other one is handed to Redis. The heavy part of distributing messages is not Go’s job in the first place. Not a reason either.

Why I picked Go anyway

Four reasons, in the order they actually mattered, each next to how it would look in Node.js.

Reason 1: one connection’s work reads top to bottom, in the order it happens (readability)

The dedicated waiter is what pays off here. Because that waiter only watches one customer, the code reads in the order things happen: read the message, check it, hand it to the room.

// Go: you can write "wait here until they say something"
data, err := c.ws.ReadMessage()
c.svc.handleFrame(ctx, c, data)   // what comes next goes right below
// Node.js: you hand over a function — "call this when they say something"
ws.on("message", (data) => {
  handleFrame(data);              // what comes next moves inside it
});

The difference: Go lets you write “wait”. Node.js cannot wait, so you hand over a function instead, and the rest of the work moves somewhere else. With one or two that is nothing, but a single connection has a reader, a writer and a heartbeat, each with its own thing to wait on, so the handed-over functions keep multiplying.

Writing “block here and wait” only blocks that person’s own waiter; the other 999 keep going. You get to write the naive version without paying for it, and that was what mattered most.

Reason 2: “wait until one of these happens” lives in one place (readability)

The writer (also one of those dedicated waiters) is waiting on three things at once: something to send arrived, the heartbeat is due, the connection ended.

// Go: the three waits sit next to each other
select {
case msg := <-c.out:   // something to send arrived
case <-ping.C:         // the heartbeat is due
case <-c.done:         // the connection ended
}
// Node.js: the three end up in three different places
queue.on("message", ...);        // something to send arrived
setInterval(ping, 30000);        // the heartbeat is due
ws.on("close", ...);             // the connection ended

The difference: both work, but in Node.js you cannot see in one place which of the three fired first. Who owns the teardown is also up to you, spread across those separate places. In Go they line up together, so reading them tells you everything.

A chat server is made of these multi-way waits. Even batching messages into the database has the same shape: “100 have piled up”, “200 milliseconds passed”, “the server is shutting down”.

Reason 3: it was a language I can write

Blunt, but for a side project, getting something running in a language you know beats a difference in language performance.

The difference: none, as far as the languages go. If you are fluent in Node.js, the opposite conclusion is the right one.

Reason 4: a single binary, easy to ship as one app

The app keeps matching, chat, topics and reporting side by side in one process (a modular monolith). Go compiles all of it into one executable, so shipping means putting a file on the server and running it.

The difference: Node.js means carrying Node itself and node_modules along. Not much of a burden, so this is a small one.

In the order they mattered

RankReasonWhere it showed up
1Code reads in the order things happenThe shape of the whole codebase. “One connection = three watchers” became the code
2select for multi-way waitsThe writer, the heartbeat, the batched writes — the same shape kept recurring
3I can write itHow fast I got going. Not a property of the language
4Single binaryOnly at deploy time

Number three was, at the time I chose, actually number one. Having written the thing, the readability in one and two mattered more, because I touch it every day.

What choosing Go costs me

It is not all upside. Thousands of dedicated waiters means you have to keep them from bumping into each other. With Node.js and its single waiter, the problem would not exist.

  • Lock what is shared: the table of who is connected to which room is touched by every waiter, so writes keep the others out. The mechanism is a mutex — short for mutual exclusion, which describes “only one at a time” rather than “a lock”. Forget one and you get a bug that is hard to trace.
  • Do not forget to send waiters home: if the connection drops and its waiter keeps waiting, that memory stays occupied (a goroutine leak). There is a dedicated signal for “we are done” so all three watchers go home.
  • Cut off slow customers: one connection that cannot take what it is sent will block the path. Here, a connection that still cannot receive after 32 queued items gets disconnected. Waiting for it would make everyone else in the room wait too.
  • Keep database writes off the delivery path: saving each message inline puts the round trip straight into how slow the conversation feels. Messages go into a buffer and get written in one go, at 100 items or 200 milliseconds, whichever comes first.

Wrapping up

“Chat is mostly waiting on the network, so surely Node.js, which is good at waiting, is the better fit?” — that instinct is not wrong. The waiting performs about the same, and with three people to a room, broadcasting does not separate them either. Node.js would have worked.

I picked Go for how it reads, not for how it performs. Give every connection its own dedicated worker, write the waiting top to bottom, and still fit thousands of them on a small server. Express the multi-way waits in the language’s own standard construct. A chat server has that shape all the way through, so the code came out straightforward.

Rather than “which one is faster”, it is fine to choose on “does the way this language is written match the shape of what I am building”. That is what I took away.

https://github.com/yosuke318/anontopic