Welcome! In this post, we’ll explore how to build a real-time chat application (think Discord-like: channels, real-time messages, user presence, typing indicators) by comparing Elixir (with Phoenix) and Go. If you’re an experienced Elixir developer new to Go, this guide will map concepts you know in Elixir to their counterparts in Go. We’ll dive into syntax, concurrency models, data structures, real-time features (WebSockets vs Phoenix Channels), presence tracking, dependency management, testing, performance, error handling, and deployment. Code snippets in both languages will illustrate how each feature might be implemented in a chat app.
By the end, you’ll see how a Phoenix-powered chat compares to a Go implementation, and understand Go’s paradigm through the lens of your Elixir knowledge. Let’s get started!
Syntax and Basic Language Structures #
Modules/Packages: In Elixir, code is organized into modules (using defmodule
). In Go, code is organized into packages (each file starts with a package
declaration). Elixir’s modules can be nested and are similar to namespaces. Go’s packages correspond to directories and provide a way to encapsulate code. There isn’t a one-to-one equivalent of Elixir’s module nesting in Go; instead, you create sub-packages by using subdirectories.
Functions: Defining functions also looks different. Elixir uses def
within a module, and functions are named with pattern matching allowed in arguments. Go defines functions with the func
keyword, and types for parameters and return values. Go functions can exist at the package level (since Go has no classes) and can optionally have receivers (to define methods on struct types). Elixir functions can be anonymous (using fn -> end
) or named; Go supports anonymous functions as well (literals with func(...) { ... }
). Also, Elixir functions can have multiple clauses via pattern matching, whereas Go would use if/else or switch logic for similar behavior.
Variables: Elixir variables are immutable (you can rebind names, but you can’t mutate data structures – you create new ones). Go variables are mutable by default. In Elixir: once you create a list or map, you don’t alter it – you get a new updated copy instead. In Go: you can modify a list (slice) in place or change a struct’s field. Elixir’s immutability simplifies reasoning but requires different patterns (like recursion or Enum functions instead of loops). Go’s mutable variables allow traditional loops and in-place updates. Also, Elixir is dynamically typed (with “strong” typing enforced at runtime), whereas Go is statically typed with explicit type declarations.
Let’s compare a simple example – a greeting function in each language:
defmodule Greeter do
def greet(name) do
"Hello, #{name}!"
end
end
IO.puts(Greeter.greet("Phoenix"))
package main
import "fmt"
func greet(name string) string {
return "Hello, " + name + "!"
}
func main() {
fmt.Println(greet("Phoenix"))
}
In the Elixir version, Greeter
is a module with a greet/1
function. In Go, we defined a greet
function at the package level (since Go has no modules in the Elixir sense) and a main
function to run the program. String interpolation in Elixir uses #{}
, while in Go we concatenate strings (or use fmt.Sprintf
for formatting). Elixir code tends to be concise and expressive (thanks to features like interpolation and Ruby-like syntax), whereas Go’s syntax is more verbose (curly braces, explicit returns) but straightforward for developers coming from C/Java backgrounds.
Syntax quirks: Elixir uses do/end blocks and relies on significant syntax (no braces). Go uses {}
braces to delimit blocks and ;
line terminators (though they are usually implicit at end of lines). Elixir atoms (:atom
) resemble Ruby symbols and are often used for identifiers; Go uses constants or enums for similar purposes. One thing to note: Elixir code is compiled to bytecode and run on the BEAM VM (with .ex
for compiled files and .exs
for scripts), whereas Go is compiled to native binaries. Despite being compiled, Go’s build is extremely fast even for large codebases – you won’t have a slow edit-compile-run cycle.
In summary, Go’s syntax will feel lower-level compared to Elixir’s, but it’s quite simple. You’ll just have to get used to explicit types and braces. Next, let’s tackle the biggest difference between the two languages: how they handle concurrency and messaging.
Concurrency and Message Passing #
Elixir and Go are both designed for concurrency, but they use very different models. Understanding this difference is key to building a real-time app like a chat server in each language.
Elixir’s Model – BEAM Processes (Actor Model): In Elixir (and Erlang/BEAM), you spawn processes which are extremely lightweight threads of execution managed by the BEAM VM. Each process has its own isolated memory and mailbox. Processes communicate by sending messages (asynchronously) and can receive messages via pattern matching. This is the Actor Model – no shared memory, just message passing. Because processes are isolated and cheap, you can literally have millions of them running concurrently on the BEAM. For example, in a Phoenix chat app, each client connection might be handled by its own process, and processes can send messages to each other (for broadcasting chat messages, coordinating presence, etc.) without worrying about locks or race conditions. The BEAM scheduler will preemptively multitask these processes across all CPU cores.
Here’s a quick Elixir concurrency example: spawning a process and sending it a message:
parent = self()
spawn(fn ->
send(parent, {:hello, "from new process"})
end)
receive do
{:hello, text} -> IO.puts("Received #{text}")
end
In this snippet, spawn/1
creates a new process that sends a message back to the parent process. The parent uses receive
to await a message matching the {:hello, text}
pattern. This is how you’d manually use message passing. In practice, you’d often use higher-level abstractions (like GenServer or Phoenix Channels which under the hood manage processes and messaging for you). The important part: Elixir processes don’t share memory and communicate via messages, which avoids a whole class of thread-safety issues.
Go’s Model – Goroutines and Channels (CSP Model): Go uses goroutines as its units of concurrency. A goroutine is a lightweight thread managed by the Go runtime. You create one by simply prefixing a function call with the go
keyword (e.g. go doSomething()
). Thousands of goroutines can run concurrently. Unlike Elixir processes, goroutines share memory by default (they all live in the same address space). To coordinate safely, Go encourages the use of channels – typed conduits that goroutines can send and receive messages on. This is Go’s take on Tony Hoare’s Communicating Sequential Processes (CSP) model. The mantra is “Do not communicate by sharing memory; instead, share memory by communicating.”
In Go, you might set up a channel and have one goroutine send data into it while another receives. A simple example equivalent to the Elixir one above:
ch := make(chan string)
go func() {
ch <- "from new goroutine"
}()
msg := <- ch
fmt.Println("Received", msg)
We create a channel ch
of type string. Then we spawn a new goroutine (with an anonymous function) that sends a string into ch
. The main goroutine (implicitly, main()
or any function we’re in) then receives from ch
and prints the message. This synchronization ensures that the print happens after the send. Go channels can also be buffered (to allow sending some number of messages without blocking immediately) and you can use select
to wait on multiple channels. By default, though, sends and receives on a channel are blocking until the other side is ready. This is a big difference from Elixir’s mailboxes, which buffer messages until received – Go channels of size 0 will force synchronization, which can prevent certain race conditions but also requires careful design to avoid deadlocks.
Scheduling: The BEAM uses preemptive scheduling for processes – each process gets a slice of time and the VM can preempt a long-running process to keep the system responsive. Go’s scheduler is cooperative – a goroutine runs until it performs an operation that checks in with the scheduler (like I/O or certain function calls), or you explicitly yield (via runtime.Gosched()
), meaning a compute-heavy goroutine could starve others if not written cooperatively. Practically, Go’s scheduler is very good and mostly you don’t worry about this, but it’s a reason why BEAM processes have more consistent latency under heavy loads (the VM will preempt anything taking too long). The trade-off is that Go’s approach has a bit less overhead and can be faster in raw throughput for CPU-bound tasks.
No Shared Memory vs Shared State: Another critical difference: Elixir eliminates shared memory by design – you must communicate via messages. Go allows shared memory (e.g. multiple goroutines accessing the same variable or data structure), so you must be careful. The idiomatic approach is to confine data to a goroutine or use channels to pass ownership of data, but nothing stops you from using locks (sync.Mutex
) to protect shared data across goroutines. Elixir’s approach leads to fewer concurrency bugs at the cost of copying data between processes. Go’s approach can be more memory-efficient (no need to copy messages if using shared pointers) but puts responsibility on the developer to avoid race conditions. (Go does have a race detector you can run with go test -race
to catch unsynchronized access.)
For our chat application, these differences mean:
In Phoenix/Elixir, each connected client typically corresponds to a BEAM process (each channel socket runs in its own process). When a user sends a message, Phoenix can broadcast it to all subscribers of that topic by sending messages to those processes (via Phoenix.PubSub). Because of BEAM’s transparent distribution, broadcasts even work across a cluster of nodes without extra infrastructure – Phoenix’s PubSub handles forwarding messages to processes on other nodes. You don’t worry about threads or locks; you let the framework spawn processes and route messages.
In Go, you might handle each client connection in a separate goroutine (for example, the standard net/http
server spawns a goroutine per request, and similarly you’d have a goroutine per WebSocket connection). To broadcast a message to all clients, you could have a shared list (or map) of client connections, and use a channel to signal messages to broadcast. One goroutine could be dedicated to reading from that channel and writing out to all active connections. This is exactly how a typical Go WebSocket chat server is structured (we’ll see an example shortly). You have to be mindful of protecting shared data structures (like the map of clients) – usually by controlling access to it from one goroutine or using synchronization.
To illustrate, here’s a very basic outline of a Go chat server’s concurrency, compared to Elixir:
Elixir (Phoenix): Each client’s socket runs in a process. Suppose a client sends a chat message “Hello”. Phoenix receives it in that process and simply calls broadcast!(socket, "new_msg", %{body: "Hello"})
. Phoenix’s channel layer takes care of delivering that message to all other socket processes subscribed to the same topic (possibly on other nodes). Under the hood, Phoenix will use PubSub (which might use Erlang’s distribution or an adapter like Redis) to route the message. All this happens with no explicit locking on your part – the runtime ensures messages are delivered reliably or the process dies trying (to be restarted by a supervisor).
Go: Each client connection could be handled by a goroutine that reads incoming WebSocket messages. When one goroutine receives a chat message, how to broadcast to others? A common pattern is to have a channel (let’s call it broadcast
) that all client goroutines can send messages into. Then have a single goroutine (a “hub” or dispatcher) reading from broadcast
channel and, for each message, iterating over the list of active client connections to send it out. This “hub” ensures only one goroutine writes to each connection list at a time (avoiding concurrent map writes). You might still need a mutex if multiple goroutines modify the clients list (like adding/removing on connect/disconnect), or use the hub goroutine to handle addition/removal too.
Here’s a simplified Go code snippet following that pattern:
var clients = make(map[*websocket.Conn]bool)
var broadcast = make(chan Message)
func handleConnections(w http.ResponseWriter, r *http.Request) {
conn, _ := upgrader.Upgrade(w, r, nil)
clients[conn] = true
defer func() {
delete(clients, conn)
conn.Close()
}()
for {
var msg Message
err := conn.ReadJSON(&msg)
if err != nil {
break
}
broadcast <- msg
}
}
func handleMessages() {
for {
msg := <-broadcast
for conn := range clients {
err := conn.WriteJSON(msg)
if err != nil {
conn.Close()
delete(clients, conn)
}
}
}
}
And you’d start handleMessages()
once (in main, as go handleMessages()
) and set up an HTTP route to handleConnections
. This is roughly based on a common example for Go WebSocket chat servers. Compare that to the Phoenix version which we’ll see in the next section – Phoenix’s equivalent logic is hidden inside the framework’s broadcast!
function.
TL;DR: Elixir provides processes and message passing, giving you a higher-level abstraction with isolation and fault-tolerance (at the cost of some explicit control). Go provides goroutines and channels, which are lower-level primitives for concurrency that you compose to send messages and synchronize. Both can handle massive concurrency: millions of lightweight threads are possible in both runtimes, though Elixir processes are even more isolated (and arguably safer) while Go goroutines can be a bit more efficient for raw performance.
Next, let’s see how these concurrency building blocks are used to implement the real-time features of our chat app – starting with how we handle real-time messaging with WebSockets or channels.
Structs and Data Modeling #
Data modeling in Elixir vs Go also differs in syntax and philosophy, especially when representing things like chat messages or users.
Elixir Structs: In Elixir, you often use plain maps or structs to represent domain data. A struct in Elixir is essentially a special map with a fixed set of fields and a named type. For example, we might define a %Message{}
struct for chat messages:
defmodule ChatApp.Message do
defstruct [:username, :content, :timestamp]
end
msg = %ChatApp.Message{username: "alice", content: "Hello!", timestamp: DateTime.utc_now()}
Elixir structs are immutable (you can’t change a field without returning a new copy), and they come with some nice features like default values and pattern matching support. For instance, you could write function clauses that match on %Message{content: "ping"}
to handle a special case. But under the hood, it’s just a map – you access fields with msg.username
or by pattern matching %Message{username: name} = msg
. Elixir (being dynamically typed) doesn’t enforce the types of those fields unless you use optional tools like Dialyzer for static analysis.
Go Structs: Go, being statically typed, has structures (struct
) that act as typed containers for fields (similar to a class without methods). You’d model a message like:
type Message struct {
Username string `json:"username"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
}
Here we define a Message
struct type with three fields. We also added `json:"name"`
tags to specify how to encode/decode JSON (useful when sending over WebSockets as JSON, for example). Using it:
msg := Message{Username: "alice", Content: "Hello!", Timestamp: time.Now().Unix()}
fmt.Println(msg.Username)
Go structs are mutable value types – you can modify msg.Content
in place (if you have it as a variable). If you pass a struct to a function, it copies it (unless you use a pointer). There’s no built-in pattern matching on struct fields; you check or switch on fields manually. However, Go does have the concept of methods on structs: you can define functions with a receiver of type *Message
or Message
to operate on them (similar to adding methods in a class). For example, you could add a method func (m *Message) Preview() string { ... }
.
Key differences:
Immutability: As noted, Elixir’s data is immutable, Go’s is mutable. This means in Elixir, adding a new chat message to a list returns a new list; in Go, you might append to a slice in place. Elixir’s approach avoids side-effects, while Go’s can be more straightforward imperative code.
Type enforcement: In Go, if Message.Content
is defined as a string, you can’t accidentally assign a number to it – the code wouldn’t compile. Elixir would allow any type in any field (unless you explicitly guard it) and errors would show up at runtime or in tests.
Modeling user state: In a chat app, you might also have a User
struct. In Elixir: %User{id: 1, name: "Alice", online: true}
(possibly defined by defstruct
). In Go: a type User struct { ID int; Name string; Online bool }
. In Phoenix/Elixir, a lot of state (like presence info) is often not kept in long-lived data structures in memory, but rather in processes or ETS tables or sent to clients as needed. In Go, you might keep some in-memory data structures (maps of userID to connection or to an online flag) and/or rely on external stores.
To give a quick visual, here’s an Elixir and Go snippet side by side for creating a message struct and encoding it to JSON (as might be done for sending over a WebSocket):
defmodule ChatApp.Message do
defstruct [:username, :content]
end
msg = %ChatApp.Message{username: "alice", content: "Hello"}
json = Jason.encode!(msg)
type Message struct {
Username string `json:"username"`
Content string `json:"content"`
}
msg := Message{Username: "alice", Content: "Hello"}
jsonData, err := json.Marshal(msg)
if err != nil { }
fmt.Println(string(jsonData))
If we print the json
/jsonData
from both, they’d produce something like: {"username":"alice","content":"Hello"}
.
One more note: Maps vs Structs – In Elixir, for quick data structures or dynamic data, you might just use maps (%{}
) with arbitrary keys. In Go, the analogous structure is a map[keyType]valueType
, but you need to fix the types. E.g. map[string]interface{}
could be like a JSON object with arbitrary values. But you lose type safety on the values (since interface{}
accepts anything). Often, you’ll use structs in Go where in Elixir you might use maps with atom keys. For example, Phoenix channel payloads are maps (since JSON decoding in Elixir yields maps), whereas in Go you’d probably decode JSON into a struct or map[string]interface{}
and then cast.
In our chat app, modeling messages, users, and other entities is straightforward in both languages – just keep in mind the static vs dynamic typing. Next, let’s focus on the real-time communication mechanisms (WebSockets and channels) that make a chat app tick.
Real-Time Messaging: WebSockets (Go) vs Phoenix Channels (Elixir) #
Real-time chat relies on persistent connections to push messages instantly to clients. In the web world, WebSockets are the de facto standard for such bi-directional communication. Phoenix’s channels abstract WebSockets (or long-polling fallback) into a higher-level concept. Let’s compare how you implement a chat room broadcast in Phoenix vs in a raw Go WebSocket server.
Phoenix Channels (Elixir): Phoenix Channels provide a great abstraction for real-time messaging. When you use Phoenix, you get a socket
endpoint (by default at /socket
) that clients connect to (typically using Phoenix’s JS client or any Socket library). Clients can join one or more topics (like chat rooms). On the server, you write Channel modules that handle events. For example, a basic chat channel might look like:
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
def join("room:" <> _room_id, _params, socket) do
{:ok, socket}
end
def handle_in("new_msg", %{"body" => body}, socket) do
broadcast!(socket, "new_msg", %{body: body})
{:noreply, socket}
end
end
This Elixir code does a lot with a little. When a client joins, we allow them (join
returns :ok
). When a client pushes a "new_msg"
event with a message body, we call broadcast!/3
. This sends the message to all clients joined on the same topic (except the sender by default). Phoenix takes care of delivering that over the WebSocket to each client, encoding the %{body: body}
map to JSON. On the client side, Phoenix’s JavaScript would receive that event and, say, append it to the chat UI.
Notice how we didn’t have to manage any connection list or explicit loop – Phoenix does it. Under the hood, each channel is a BEAM process subscribed to a PubSub topic (here the topic might be "room:123"
for room 123). broadcast!
publishes to that topic, and Phoenix’s PubSub distributes it to all processes (which then send down the WebSocket frames to their respective clients). This even works across nodes – if you have 10 Phoenix servers in a cluster, a broadcast will reach all of them. As one Reddit user succinctly put it, Phoenix Channels are like a “batteries included” WebSockets library with PubSub, topics, auto-reconnect, presence, etc., all out of the box. You’d have to implement all that yourself in a raw WebSocket solution.
Go WebSocket Implementation: Go’s standard library supports WebSockets only via lower-level HTTP upgrades (there’s no built-in high-level Channel abstraction). Typically, you’d use a library like Gorilla WebSocket (github.com/gorilla/websocket
) to handle the WebSocket handshake and reading/writing frames. In a Go chat server (like the snippet we started writing in the concurrency section), you’d do something like:
- Set up an HTTP route (e.g.,
"/ws"
) and in the handler, use a websocket Upgrader to upgrade the connection. - Keep track of the
*websocket.Conn
objects for each client in a list or map. - Each connection’s reader loop waits for a message (e.g.,
conn.ReadJSON(&msg)
which blocks until the client sends something). - When a message comes in, you push it to a
broadcast
channel. - A separate goroutine (broadcaster) reads from that channel and writes the message out to each connection via
conn.WriteJSON(msg)
.
We showed a code snippet above for Go (in the concurrency section), but to reiterate the essence:
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
break
}
broadcast <- msg
}
for msg := range broadcast {
for conn := range clients {
conn.WriteJSON(msg)
}
}
You’d also want to handle error cases (if WriteJSON
fails, perhaps remove that client). This is a minimal viable approach. Libraries or frameworks in Go can abstract this a bit more (for example, Centrifuge is a library mentioned by some as “the closest thing to Phoenix Channels in Go” which provides channels, presence, etc., on top of WebSockets). But without such a library, you implement the patterns manually.
Message Routing and Scaling: With Phoenix, as mentioned, if you run multiple server nodes, Phoenix uses its distributed PubSub to route messages to the right node. With Go, a WebSocket server on one machine knows nothing of another server’s clients. So if you plan to scale to multiple instances, you need to introduce a message broker or PubSub system (like Redis, NATS, RabbitMQ, etc.) that all instances use. For example, each Go server could publish the received message to a Redis channel, and all servers subscribe to that channel to get the message and broadcast to their local clients. Essentially, you’d be using an external system to do what Phoenix does with its clustering. As one community member noted, “Typically with other languages (such as Golang) you’d need a separate real-time DB or pub/sub (e.g. Redis) for multi-node broadcasts. Elixir lets you skip Redis” because of its built-in clustering.
For a single-server scenario, Go does great. If we were to run a simple chat server in Go (like the popular examples), it might handle many thousands of WebSocket connections on one machine. Phoenix can handle millions of WebSocket connections on a single node, as demonstrated in benchmarks – thanks to the BEAM’s ability to handle large numbers of processes. Go can also handle a very large number, but you might run into OS limits or memory considerations. Both are excellent at real-time, but Phoenix gives you a powerful abstraction and proven scalability out-of-the-box.
To sum up this section:
Elixir/Phoenix: You write high-level channel handlers without worrying about the low-level WebSocket details. Broadcasting to a channel topic is one function call. You get automatic reconnection logic, heartbeats, etc., from the Phoenix client. The tradeoff is you’re within the Phoenix framework and BEAM’s world (which, for you as an Elixir dev, is familiar territory).
Go: You have the freedom to use any WebSocket library and pattern you want. The code is more manual – you must manage connections and broadcasting yourself. It’s a bit more code (and potential for bugs if you’re not careful), but it’s not terribly complex for a basic chat. If you need features like fallback transports, you’d have to implement them (Phoenix falls back to long-polling automatically if WebSocket isn’t available; in Go, you’d handle that or use a library that does).
So far, we’ve focused on messaging. Let’s move on to presence tracking – knowing which users are online in a chat (and maybe their typing status).
Presence Tracking and Typing Indicators #
User presence (e.g., the online/offline status of users, or “who is in this channel right now”) and typing indicators (“Bob is typing…”) are important features in apps like Discord. Phoenix provides a feature specifically for presence, whereas in Go we’ll need to design our own solution.
Presence in Phoenix (Elixir) #
Phoenix includes Phoenix.Presence, a module that makes tracking distributed presence simple. Phoenix.Presence uses the PubSub system under the hood to replicate a CRDT (Conflict-Free Replicated Data Type) of presence information across nodes. That sounds fancy, but using it is straightforward for developers. Typically, you:
- Add a
MyAppWeb.Presence
module (using use Phoenix.Presence, otp_app: :my_app, pubsub_server: MyApp.PubSub
) and ensure it’s started in your supervision tree (Phoenix generators often do this for you). - In your Channel, after a user joins, you track them and push the current presence list.
For example, using the earlier RoomChannel
example:
defmodule MyAppWeb.RoomChannel do
use Phoenix.Channel
alias MyAppWeb.Presence
def join("room:lobby", _params, socket) do
send(self(), :after_join)
{:ok, socket}
end
def handle_info(:after_join, socket) do
Presence.track(socket, socket.assigns.user_id, %{online_at: DateTime.utc_now()})
push(socket, "presence_state", Presence.list(socket))
{:noreply, socket}
end
end
With those few lines, Phoenix is now keeping track of all users in “room:lobby”. The track
call registers this user’s presence with some metadata (online_at
in this case). The Presence.list(socket)
returns a summary of all present users which we push to the client. Thereafter, Phoenix.Presence will automatically send diff updates to the channel on joins/leaves. On the client side, you can use the Phoenix Presence JS library to listen for presence events and update a UI list of online users. The heavy lifting (synchronizing across nodes, detecting joins/leaves) is handled by the Phoenix.Presence behavior and the CRDT it maintains. In essence, Phoenix’s server-side will broadcast events like "presence_diff"
to channel subscribers whenever someone joins or leaves, and you merge those diffs into your local state.
The advantage for Elixir developers is clear: you don’t have to reinvent presence logic. Discord’s own Elixir architecture famously used Phoenix Channels and Presence to handle millions of concurrent users in chat and voice channels, leveraging the BEAM’s strengths.
Presence in Go #
Go has no built-in concept of presence. We have to design it. The simplest approach (for a single server) is:
- Maintain a data structure of currently connected users (for example, a set or map of user IDs).
- Update it on connection and disconnection.
- Possibly broadcast join/leave events to other users in the channel so they can update their online list.
If you only had one server, you might keep a map[userID]bool
for who’s online, or piggyback off your clients
map (e.g., map of userID -> connection
). For a multi-server deployment, you’d need to propagate presence info between servers. A straightforward way is to use an external store like Redis to track presence. For example, you could use a Redis sorted set keyed by user IDs where the score is a timestamp (last seen time). Each server, when a user connects, updates that user’s score to now and perhaps publishes a “user joined” message. When user disconnects (or times out), that info is updated.
One approach described in a blog post suggests: have each client send a small heartbeat (“I’m alive”) every X seconds, and use a Redis sorted set to record the last timestamp of each heartbeat. Then to decide if someone is online, you check if their last timestamp is within, say, the last 60 seconds. If yes, consider them online (green), if not, offline (gray). This is a pull model (others query to see who’s online), but you can combine it with a push model: when someone actually connects or disconnects, publish an event so others in the same channel get an immediate update (like “Alice just came online”).
Let’s illustrate a part of a Go solution using Redis for presence:
func setUserOnline(userID string) {
now := time.Now().Unix()
redisClient.ZAdd(ctx, "presence:global", &redis.Z{
Score: float64(now),
Member: userID,
})
}
Later, to get all users online in the last minute:
oneMinuteAgo := time.Now().Add(-1 * time.Minute).Unix()
onlineUsers, _ := redisClient.ZRangeByScore(ctx, "presence:global", &redis.ZRangeBy{
Min: fmt.Sprint(oneMinuteAgo),
Max: fmt.Sprint(time.Now().Unix()),
})
fmt.Printf("Users online: %v\n", onlineUsers)
This would retrieve all user IDs with a “last seen” timestamp between one minute ago and now. We could also just check a specific user with ZScore
to get their last-seen time.
For per-channel presence, you might maintain separate keys per room (e.g., presence:room123
). When a user joins a room, add them to that set. When they leave, remove them. You can use Redis Pub/Sub or an event system to notify other servers that “user X joined room Y” so they can broadcast to local clients. This quickly gets involved – essentially you’re implementing a distributed presence system. It’s doable (companies have built large-scale Go chat systems with such designs), but it’s more effort than using Phoenix Presence.
There are also community packages: for example, github.com/cihangir/presence
is a Go library aiming to provide a presence system (persisting to Redis) that you can use, rather than coding it from scratch.
Typing indicators: Typing status is usually ephemeral and localized to a channel. With Phoenix, you might not use Presence for this (as presence is more for state that persists while you’re connected). Instead, a common approach is to have clients send a message like "user:typing"
over the channel when the user is typing and then perhaps quickly time it out. For example, when a user is typing, you could broadcast a "user_typing"
event to others. Phoenix doesn’t have a built-in for typing indicators (it’s easy enough to implement as just another channel event). You might throttle these events (to avoid spamming if someone holds a key down).
In Go, you’d do the same: define a message type or event for typing. When a user is typing, their client sends e.g. {"event": "typing", "user": "alice"}
and your server’s message loop broadcasts that to others in the room. Perhaps your frontend then shows “Alice is typing…” for a couple of seconds.
Essentially, typing indicators are transient messages – both Phoenix and a Go server handle them just like any other message, except the client might handle them differently (e.g., not adding to chat log, but showing a UI hint). There’s no need for special storage; you rely on timely delivery. If using Phoenix Presence, one trick could be storing a flag in the metadata (like typing: true
for a user) but typically that’s overkill. It’s simpler as a direct event.
Recap: Phoenix gives you a ready-to-use distributed presence tracker with minimal code. In Go, you’ll likely use a combination of in-memory state plus an external store (Redis) or messaging to sync presence across servers. It’s a trade-off: Elixir has this incredible out-of-the-box capability, while Go requires more engineering – but then you have full control to optimize it as you see fit. In a small app, you might even ignore the heavy distribution and just track presence in one server’s memory. For many cases, that’s sufficient.
Now that we have messaging and presence covered, let’s talk about how each ecosystem handles dependencies and project organization, since building a full app means pulling in some libraries.
Dependency Management: Go Modules vs Mix (Hex) #
Both Elixir and Go have modern dependency management tools, but they work quite differently.
Elixir (Mix and Hex): Elixir projects use the Mix build tool. In your mix.exs
file, you declare dependencies in a deps
function. For example, a Phoenix app’s deps might include {:phoenix, "~> 1.7"}
, {:phoenix_pubsub, "~> 2.1"}
, etc. When you run mix deps.get
, Mix consults Hex, the package manager for the Erlang/Elixir ecosystem, to resolve and download those packages. Hex is a centralized package repository (hex.pm) where published packages live. Mix generates a lock file (mix.lock
) to pin exact versions. Hex/Mix’s approach is similar to Rust’s Cargo or npm in that it’s centralized and uses semantic versioning ranges to resolve the latest compatible versions, etc. As an Elixir dev, you know that Mix also provides tasks like mix deps.update
to update packages, mix deps.compile
etc. It’s a very straightforward system.
For example, adding Phoenix Presence to your project was just adding {:phoenix_pubsub, "~> 2.1"}
(for PubSub) or actually Phoenix includes it. The key point: Mix and Hex handle fetching and version locking for you automatically. Hex.pm ensures packages are globally unique by name and has a web UI to see releases. Mix integrates with Hex for publishing as well.
Go (Go Modules): Go initially had a more ad-hoc approach (GOPATH, manual vendoring), but now uses Go Modules. A Go module is essentially your project (identified by a module path, usually something like github.com/yourname/yourproject
). Dependencies are managed through a go.mod
file in the project root, and go.sum
file which locks versions with hashes. You add a dependency either by running go get some/package
or by adding an import in your code and running go mod tidy
. The Go toolchain will resolve the dependency and add it to your go.mod with a specific version.
Go’s approach is decentralized: there’s no single repository like Hex.pm. Instead, Go fetches modules from their VCS repositories (Git), typically from GitHub or others. There is a public proxy (proxy.golang.org) which caches modules to speed things up and ensure availability. So when you run go get github.com/gorilla/websocket
, the Go tool either hits the Go proxy or goes directly to GitHub to download the module source at the latest version (unless you specified a version). Go uses semantic version tags from Git (vX.Y.Z) to pick versions. If you don’t specify, it usually gets the latest tagged version.
In practice, you will specify in go.mod
a requirement like:
require (
github.com/gorilla/websocket v1.5.1
github.com/go-redis/redis/v8 v8.11.5
)
This means our module depends on Gorilla WebSocket 1.5.1 and go-redis 8.11.5 (for example). The go.sum
will have hashes to verify those exact versions’ contents. Go’s module system uses minimal version selection – it tends to pick the minimum version that satisfies requirements to avoid sudden upgrades (as opposed to always picking latest; it’s a bit complex, but the outcome is you explicitly upgrade deps when you want). There’s no lockfile that you manually edit; it’s managed by the tool.
One thing to note: in Go’s import statements, the module path is part of the import. For example, in code you write import "github.com/gorilla/websocket"
and that corresponds to what to fetch. Elixir, you just use Phoenix.Channel
and mix knows phoenix is a dependency because of mix.exs (the mapping of module name to package is established by Hex metadata).
Comparing the experience:
Elixir’s Mix is very user-friendly and integrated with Hex’s centralized ecosystem. If a library is published on Hex, Mix can fetch it easily by name and version. If not, you can specify a Git repo or path as a dep in mix.exs (for example, {:my_lib, git: "https://.../my_lib.git", tag: "0.1.0"}
).
Go’s module system is also user-friendly in day-to-day use: you mostly run go mod init
once, then just write code and run go build
or go test
– it will automatically download needed deps. No separate command is needed most of the time (though go get
or go mod tidy
is used to clean up). However, if something isn’t tagged properly or you want the latest commit of a library, you have to specify go get github.com/user/lib@branch-or-commit
. There’s no concept of a central index by names – the URL is the identifier. This means any GitHub repo (or other git source) can be a Go dependency without publishing to a central service. There’s pros and cons: you’re not beholden to a central service (if Hex.pm went down, Elixir folks would scramble; Go’s proxy could go down but you can always set GOPROXY=direct
to fetch from origin). On the other hand, in Go it’s easier to accidentally depend on a repo that might vanish or change (though the proxy caches), whereas Hex gives more stability and accountability for published packages.
Dependency Example: Suppose our chat app needs a JSON library (Elixir uses Jason or Poison; Go has encoding/json
in the stdlib, so no need) and maybe a WebSocket library and Redis client.
In Elixir’s mix.exs:
defp deps do
[
{:phoenix, "~> 1.7.0"},
{:phoenix_pubsub, "~> 2.1"},
{:jason, "~> 1.4"},
{:redix, "~> 1.1"}
]
end
Then mix deps.get
pulls these from Hex. In Go’s go.mod, the equivalent might be:
module github.com/yourname/chatapp
go 1.20
require (
github.com/gorilla/websocket v1.5.1
github.com/go-redis/redis/v8 v8.11.5
)
The Go versions are precise. If you want to update, you run go get github.com/go-redis/redis/v8@latest
for example, then go mod tidy
. In Elixir, you’d bump the version in mix.exs (or run mix hex.outdated
to see what’s up) and then mix deps.update redix
.
Build tooling: Mix not only fetches deps but also compiles them and your project. It also handles running tests (mix test
) and more. Go’s build is integrated with the toolchain: go build
will fetch any missing deps and compile everything into a binary. Testing (go test
) similarly just works. Both languages produce a build artifact (Beam bytecode for Elixir, native binary for Go). But deployment differs, which we’ll cover soon.
In short, Elixir’s dependency management is centralized (Hex + Mix) and easy to use, and Go’s is decentralized (Go Modules pulling from VCS) but also easy to use once you understand it. As an Elixir dev, you’ll find Go’s approach means you often specify imports with pseudo-URLs (like "github.com/abc/pack"
) and you won’t have a hex.pm web UI to inspect packages (though pkg.go.dev serves a similar purpose, letting you view docs of any Go package by its import path).
Next, with our dependencies sorted, let’s consider how each ecosystem approaches testing and ensuring code quality.
Testing is crucial in any language. Elixir has a robust testing framework built-in (ExUnit), and so does Go (the testing
package and go test
tool). There are differences in style and capabilities.
ExUnit (Elixir): ExUnit is the test framework that comes with Elixir. You write test files as Elixir scripts (usually *_test.exs
files) that use ExUnit.Case
. You get a bunch of macros like test
, assert
, refute
, etc., to make writing tests succinct. Example:
defmodule GreeterTest do
use ExUnit.Case, async: true
test "greet returns hello message" do
result = Greeter.greet("Phoenix")
assert result == "Hello, Phoenix!"
end
end
You can group tests with describe
, use setup blocks, tag tests (to include/exclude), and so on. ExUnit runs tests concurrently by default (tests in the same module can run async: true
if they don’t share state), leveraging BEAM processes for isolation. You also have features like capture IO, and you can use libraries like Mox for mocking behavior or ExVCR for HTTP mocking, etc. If you’ve been doing Elixir, you know you can even do property-based testing with StreamData, integration tests with Phoenix’s ChannelTest (simulating socket connections), etc.
Writing tests in Elixir feels high-level – you seldom manually print errors; assert
macros will raise on failure and ExUnit will report nicely. Pattern matching often simplifies what you need to assert.
Go testing: Go’s built-in testing is a bit more bare-bones but very effective. You write test files with _test.go
suffix. Any function that looks like func TestXxx(t *testing.T)
is a test. You use t.Errorf
or t.Fatalf
to signal failures (or the convenience assert
from testify library, but let’s stick to built-in for now). Example:
import "testing"
func TestGreet(t *testing.T) {
got := greet("Phoenix")
want := "Hello, Phoenix!"
if got != want {
t.Errorf("greet() = %q; want %q", got, want)
}
}
This is a simple test without any helper frameworks. If the condition fails, we call t.Errorf
which marks the test as failed (but continues execution). t.Fatalf
would fail and stop the test immediately. Instead of assert_equal
like in ExUnit, we manually compare and log. This is a bit more verbose, but some Go developers use the Testify package which provides an assert
package to do things like assert.Equal(t, want, got)
for nicer syntax.
Go tests run in parallel at the package level by default (different test files can run simultaneously). Within a test file, tests run sequentially unless you call t.Parallel()
at the start of a test, which signals that test can run in parallel with others. It’s slightly less automatic than ExUnit’s async: true, but you can achieve parallelism easily. There is also subtest support (you can call t.Run("subname", func(t *testing.T){ ... })
to create nested tests dynamically).
Both Elixir and Go support doctests and examples respectively (Elixir can test code in documentation via ExUnit’s doctest feature, and Go has an ExampleXYZ
function convention that can serve as documentation and is run as a test to verify output).
Mocking/Dependency Injection: In Elixir, one approach is to pass module names into functions (because of the ability to call functions on module references) and then use libraries like Mox to substitute dummy implementations in tests. In Go, since you have interfaces, you often define an interface for a dependency and then in tests provide a fake implementation of that interface. Or use simpler stubs by monkey patching variables (not common due to static typing – better to use interfaces). There are libraries for mocking in Go (like github.com/golang/mock
which auto-generates mock implementations from interfaces).
Example – testing our chat broadcast logic:
In Elixir, we might use Phoenix’s channel testing facilities. Phoenix provides Phoenix.ChannelTest
which lets you simulate joining a channel and pushing messages without a real network. For example, you can do {:ok, _, socket} = subscribe_and_join(socket, RoomChannel, "room:lobby", %{})
then push socket, "new_msg", %{"body" => "Hi"}
and use assert_broadcast "new_msg", %{"body" => "Hi"}
to verify that broadcast was sent to all subscribers. ExUnit’s ability to pattern match in assertions is handy. We could also verify that a message was received by a specific test process using assert_receive
.
In Go, to test the broadcast logic, you could abstract the broadcaster into an interface so you can plug in a dummy that collects messages. But perhaps simpler: you might start your handleMessages
goroutine, then simulate adding a couple of dummy *websocket.Conn
objects that actually just write to a buffer (you’d need to implement a minimal WriteJSON
in a fake connection that writes to, say, a channel you control). Testing concurrency in Go can be trickier because you have to avoid race conditions in the test itself. Alternatively, you might not test the infinite loop directly; instead, factor the “send to all clients” part into a function that’s easier to test by calling with a sample set of client stubs.
General observation: Elixir’s testing is very process-isolated and forgiving – if a process in a test crashes, ExUnit captures it as a failure but your VM remains running due to supervision hierarchies in tests, etc. In Go, if a goroutine launched in a test panics and isn’t recovered, it might crash the whole test binary (though the testing package tries to recover panics in test functions). So error handling in tests is important.
Running tests: In Elixir, you run mix test
. In Go, you run go test ./...
(to test all packages) or just go test
in a package directory. Both will find tests automatically. The output of failing tests: ExUnit gives a nice diff of expected vs actual. Go’s default will print the logs we gave in t.Errorf. With testify’s assert, you can get colored diffs as well.
Property and concurrency testing: Elixir has StreamData (property-based) and things like async tests with multiple processes to simulate concurrent events. Go has a built-in fuzz testing from version 1.18 (go test -fuzz
will try random inputs on fuzz target functions) – somewhat like property testing for inputs. For concurrency, Go doesn’t have a built-in “check for race” in tests aside from the race detector (run tests with -race
to catch data races). The BEAM, on the other hand, naturally encourages you to test isolated processes by sending messages, etc., which is a bit more deterministic due to the scheduler fairness.
Given our chat app scenario, an Elixir test might easily simulate multiple users joining and sending messages in a single process by sending messages to channel processes and asserting broadcasts. In Go, you might end up writing an integration test where you actually run the server (maybe on localhost) and then have multiple WebSocket client connections (perhaps using Gorilla’s client or a simple dial via the library) to verify they receive messages. That’s certainly doable but more involved (you have to spin up a server and actual OS threads for connections).
In summary, testing in Elixir is high-level and integrated; you’ll likely miss the nice assertions and seamless concurrency tests when moving to Go. Testing in Go is a bit more manual but has the advantage of being just code (no magic, which some prefer). Both languages have excellent support for writing and running tests quickly as part of the development workflow.
Now let’s consider how our two systems perform and the runtime characteristics, especially relevant for a real-time app under load.
Elixir and Go are often compared for building scalable systems, but they have different performance profiles due to their runtimes.
Raw speed (throughput): Go is a compiled language (to native machine code), statically typed, with optimizations and a garbage collector. Elixir runs on the BEAM VM (with JIT compilation in recent Erlang/OTP versions) and is dynamically typed. For CPU-bound tasks, Go tends to outperform Elixir by a fair margin – often several times faster for equivalent algorithms – simply because low-level code and math can be optimized by the Go compiler, whereas Elixir carries more overhead (dynamic dispatch, etc.). For example, a CPU-heavy web scraping or number-crunching task might be faster in Go. One benchmark in a web crawler context showed Go completing the task in 2.5 seconds vs Elixir in 7.6 seconds. That was a network-heavy task (HTTP requests), and Go’s HTTP library is highly optimized for concurrency, partly explaining the speedup. Elixir can be optimized too (using fewer processes or more efficient libraries), but generally Go’s straightforward execution model wins on pure speed.
Concurrency and scale: When it comes to handling massive concurrency (like millions of lightweight processes/goroutines), both languages shine. The BEAM is famous for being able to handle millions of processes and passing messages between them with low latency. It also has features like scheduler collapsing and distributed process registry which aid in scaling out across nodes. Go’s runtime can also handle on the order of millions of goroutines (in 64-bit systems with enough memory) since each goroutine’s stack starts small and grows dynamically. Where differences arise is scheduling fairness (preemptive vs cooperative as discussed) and tail latency. The BEAM’s preemptive scheduler ensures no single process can hog a scheduler thread for too long – which is great for soft real-time systems needing consistent latency. Go’s cooperative scheduler can sometimes suffer if a goroutine doesn’t hit a sync point – for example, a tight loop doing computations in Go might not yield to others, causing latency spikes. Go is improving here (and in practice, calling any builtin like a channel op or syscall will allow scheduling, plus Go 1.14 added some preemption at function call boundaries).
Memory usage: Elixir processes each have their own heap, which starts small and grows, and are garbage-collected independently (a per-process generational GC). Go has a global heap and a concurrent mark-and-sweep GC. Interestingly, one might assume Elixir (Erlang) is more memory-heavy due to per-process overhead, but Go’s goroutines also have overhead (initial stack, etc.). In a benchmark noted earlier, Go and Elixir used roughly similar memory for a concurrency-heavy task (around 300MB) whereas Rust (no GC) used far less. The takeaway is that both use more memory when dealing with many concurrent tasks because they both rely on garbage collection to manage memory, which trades memory for convenience and speed. Go’s GC will kick in and potentially pause very briefly (Go’s GC is optimized for low pause times, aiming for <1ms pauses). Elixir’s GC being per-process often means short pauses spread across many processes rather than a big stop-the-world pause. This can actually lead to more consistent performance under load for the BEAM. For instance, if one process has a lot of garbage, only that process is paused for GC; others continue. In Go, the single GC is collecting from a potentially huge heap of all goroutines, but it’s concurrent now, so pauses are small – however, it can cause a throughput hit during collection cycles.
Latency and tail performance: In real-time chat, latency (message delivery time) is critical. Both platforms can deliver messages in sub-millisecond times within a single node. Across nodes, Elixir’s distribution adds a small overhead but still very fast (it’s used in telecom for a reason). One advantage of BEAM: if you have many WebSocket messages coming in and out, each is handled in an isolated process – even if one process gets slow (maybe a clogged mailbox), it won’t directly slow others except by competing for CPU. In Go, if you accidentally put all message broadcasting on one OS thread (say your broadcaster goroutine always runs on one thread), that could become a bottleneck – though Go will distribute goroutines across OS threads generally (GOMAXPROCS controls how many threads run simultaneously; by default it’s set to number of cores).
Parallelism: Both can utilize multiple cores efficiently. Go does it by dividing goroutines among OS threads (work-stealing scheduler). Elixir does it by running scheduler threads (by default as many as cores) executing BEAM processes. Both achieve near-linear scaling on CPU-bound tasks across cores (with the caveat that Go might need you to avoid global locks, and Elixir needs you to break work into many processes to keep cores busy).
Fault tolerance trade-off: This is more an architecture than raw performance, but worth mentioning: Elixir’s “let it crash” philosophy means the system is designed to heal from errors at the cost of doing extra work (restarting a process, reconstructing state). Go’s philosophy is to prevent crashes by handling errors, but if something does go wrong (panic), it’s not isolated – it could bring down the whole program. This influences performance considerations: in Elixir, you might not code a lot of defensive checks – you assume failures will be rare and contained (and you have supervisors). In Go, you often check every error and handle it, which can add verbosity and slight overhead. But on the flip side, handling errors explicitly can sometimes allow the program to continue in a controlled way rather than restarting part of it. It’s hard to quantify performance impact of that; it’s more about reliability. We’ll discuss fault tolerance more in the next section.
When to use which: A common view is: if you need high throughput, CPU-bound performance (say video encoding in real-time, heavy computations) along with concurrency, Go might have the edge. If you need to handle huge concurrency with reliability (lots of connected users, with more concern about not crashing than about absolute CPU cycles), Elixir shines. Discord chose Elixir for the massive fan-out chat and presence features – it handles the scale and failure recovery well. On the other hand, something like Whatsapp (originally on Erlang) might also be done in Go nowadays with enough care – but you’d have to implement more supervision and recovery patterns yourself.
A nuance: Preemptive vs cooperative scheduling can show up in tail latency. For example, if one user runs a very heavy computation in a channel (not typical in chat apps, but imagine some expensive operation), on BEAM it wouldn’t stall the whole VM (the scheduler would preempt it periodically). In Go, if that computation doesn’t allocate or block, it might monopolize its thread for a while. That said, Go’s runtime from 1.14+ does attempt to preempt long-running goroutines even in pure computation (by inserting checks at function call points), so the gap has narrowed.
Memory footprint per connection: Each Phoenix Channel (each user in a room) is a process – a few KB of overhead. Each Go connection as shown might be a goroutine or two plus an entry in a map – also a few KB. Very comparable. The difference is if you have millions of idle connections, the BEAM’s constant overhead might be slightly larger due to process mailbox structures, etc., but not by much. Both can scale far beyond what most applications need on a single machine.
Throughput of message passing: For chat, how many messages per second can you broadcast? Phoenix Channels have been shown to handle hundreds of thousands of messages per second on a single node with low latency. Go can likely achieve similarly high throughput if the network and kernel aren’t the bottleneck, given its good I/O performance (thanks to a built-in network poller that is very efficient, similar to how BEAM uses select
/poll
). The difference is in ease of achieving that: Phoenix makes it trivial to use all cores and handle that load because of its processes. In Go, you might need to fine-tune things like how many messages a single goroutine is relaying versus splitting work.
In summary, both Elixir and Go are performant for real-time chat. Go might win on raw CPU and maybe uses slightly less CPU for the same work due to being compiled, whereas Elixir might win on maintaining consistent latency under absurd loads and giving you out-of-the-box clustering and recovery, which indirectly improves performance (in the sense of resilience). As one LogRocket article concluded: Go’s stdlib gives great concurrency support but uses a lot of memory; Elixir can spawn processes easily but making it faster may not be straightforward – each have their own sweet spot.
Next, we should address what happens when things go wrong: error handling and fault tolerance.
Error Handling and Fault Tolerance #
One of the most significant differences in philosophy between Elixir (Erlang/BEAM) and Go is how they handle errors and failures.
Elixir’s “Let It Crash” Philosophy: In the Erlang/Elixir world, the common approach is to not defensively handle every error, but rather to let processes crash if something unexpected happens, and rely on a supervisor to restart them in a known good state. This is tied to the idea that processes are isolated – if one process dies (say, a user’s channel process due to some bug), it doesn’t take down the whole application. The supervisor (part of the OTP framework) will start a new process (maybe logging the crash). This leads to fault-tolerant systems that self-heal. For example, if our chat RoomChannel process crashes while handling a message (perhaps due to a pattern match error or an undefined function), Phoenix will log it and the channel will terminate, but the rest of the server keeps running. The user might simply need to reconnect (Phoenix by default will try to rejoin the socket). You can also design supervisors with strategies (one-for-one, one-for-all) to restart dependent processes, etc.
Elixir also uses exceptions for errors (e.g., raise "oops"
or functions like File.read!/1
that raise on error). If an exception isn’t rescued within the process, it will crash the process. Usually, you don’t catch exceptions unless you can actually recover or you want to wrap them. This means a lot of code in Elixir doesn’t have to check return codes for everything – the control flow is simpler, and if something truly unexpected occurs, it bubbles up as a crash which is handled by the supervision tree.
For example, in an Elixir chat app, you might call File.read!("config.yml")
during initialization to load some config. If that file is missing, this will raise an exception and crash that process. If that process is supervised (which it would be, as part of your application startup), the supervisor can either retry or shut down gracefully. Typically it might cause the app to fail to start – which is fine; you'd notice the issue. If a user triggers an error (say a malformed message causes a crash in their channel process), only that user’s channel is affected; they might just silently get disconnected, and can reconnect. Other users aren’t affected.
Go’s Explicit Error Handling: Go takes a very different stance: errors are values. Functions often return an error as a second return value, and you are expected to check it. There’s no built-in supervisor or process isolation – all goroutines share the fate of the whole program. If a goroutine panics (the equivalent of an unhandled exception), by default it will crash the entire program (there is a defer-recover mechanism to catch panics, but it has to be done manually at the appropriate level). This means as a Go developer, you adopt a defensive programming style: handle errors where you can, and if you can’t, propagate them up (return the error to the caller). Ultimately, if an error goes unhandled, it will eventually end up in main
and typically you’ll log and exit or panic.
In practice, for our chat server example: if writing to a WebSocket connection returns an error (maybe the connection is closed), our code snippet above did delete(clients, conn)
and continued. That’s an error we handled (removing a bad connection). If something truly unexpected happened (maybe a programming error like a nil pointer), a panic could occur. If that panic happens on the handleMessages
goroutine, by default it would crash the program because it’s not recovered. We could wrap that loop in a defer func(){ if r := recover(); r != nil { ... } }()
to catch panics and continue, but manually doing that everywhere is tedious and rarely done globally. So typically, Go programs aim to avoid panics except for truly unrecoverable situations.
No built-in restart: Without supervisors, if our Go chat server hits a fatal error, the process exits. To achieve fault tolerance, one would rely on external tools (like running the Go program under a systemd service or Kubernetes which restarts the process if it dies). Within the Go program, you can structure it to be robust: for example, each connection handler could use recover
to prevent a panic from killing the whole server – but those are design choices you implement yourself. There are some libraries that emulate supervision trees in Go, but they’re not idiomatic or common.
Example comparison:
Elixir code to read config and let it crash if not found:
def load_config(path) do
content = File.read!(path)
:ok = do_some_parsing(content)
end
Go code to read config and handle the error:
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
if err := doSomeParsing(data); err != nil {
return err
}
return nil
}
In Go, we return an error up to the caller, who will then decide what to do (maybe log and exit if config is required). In Elixir, if this function crashes, perhaps the supervisor for the app’s startup process could catch it (if it’s the top of a supervisor tree, the whole app might fail to start which is fine).
In a Phoenix Channel, if something goes wrong inside a handle_in
, Phoenix rescues it and treats it as that channel crashing (I believe Phoenix will log and terminate that channel process). The supervision tree may then start a new Channel process if the client reconnects. The rest of the system keeps running. This isolation is a huge selling point of BEAM for long-running systems: a bug in one part doesn’t take everything down.
In Go, if a bug causes a runtime panic (like an out-of-bounds array or nil dereference), and you didn’t recover it, you lose the whole process. One might attempt to recover at the top of each goroutine handling a connection to mimic isolation – e.g., wrap each connection handler in a recover
to avoid one crashing the server. But you then have to decide how to clean that up and whether to continue accepting new connections, etc. It’s doable, but not built-in. As a post on Erlang vs Go noted, goroutines have no identity and no links/monitoring – meaning one goroutine can’t easily detect if another fails like an Erlang process can. So you can’t have a supervisor goroutine that “knows” a child failed except by conventional error reporting (the child would have to send on a channel “I died” which, if it died unexpectedly, it can’t). This is why in Go, we typically rely on processes (OS processes) for isolation if needed.
The let-it-crash approach also influences how you write code. Elixir developers will often not check for conditions that “shouldn’t happen” – they’ll let a pattern match fail or an exception be raised, trusting that it will be caught by the runtime’s supervision if unhandled. Go developers will check every error return diligently. For a concrete example, in Phoenix if broadcasting to a channel fails (perhaps due to a network issue), that error might just cause a log but the app continues, or it’s abstracted away. In Go, if conn.WriteJSON
fails, we removed the client. If somehow writing to one client caused an exception (it won’t, WriteJSON just returns error), we would need to ensure it doesn’t crash the loop. In our code, a panic
would break out of the loop and not be caught, terminating handleMessages
entirely – thus stopping all broadcasts. We didn’t code any recovery for that, so that would be a bug. We could improve by doing:
func handleMessages() {
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered in handleMessages:", r)
go handleMessages()
}
}()
for {
msg := <-broadcast
for conn := range clients {
if err := conn.WriteJSON(msg); err != nil {
delete(clients, conn)
}
}
}
}
This adds a defer
to catch panics, log them, and (in this naive case) restart the loop by spawning a new goroutine running handleMessages()
again. This is a rudimentary custom supervisor logic. It’s not commonly seen in simple Go apps, but in more complex systems you might incorporate similar patterns (often by structuring goroutines and channels such that if one fails, another part notices a channel closed and respawns something). Still, it’s not as seamless as OTP supervisors.
Bottom line: Elixir/BEAM was designed for fault tolerance – embrace failure, isolate it, recover from it. Go was designed for simplicity and predictability – avoid failure by handling errors, and if fail, fail fast (crash the program). There’s a quote: “Errors are values in Go.” and “Failures are expected in Erlang.” They reflect different domains historically (Erlang for telecom where the system must never stop, Go for Google’s infrastructure where if a server crashes, a new one starts quickly and that’s okay under orchestration).
In our chat app scenario, the Elixir version might be more resilient out of the box. For instance, if one user’s handling has a bug, it crashes their channel, but others are fine and the supervisor may log it. In Go, a bug in the broadcaster goroutine could bring down message delivery for everyone until the process is restarted by an external monitor. As developers, we’d add safeguards to avoid that single point of failure – so we’d end up building a little supervisor logic or ensuring any panic in a child goroutine is caught.
That being said, Go programs can be made highly reliable too – often by running in environments like Kubernetes which restarts them if they crash, doing canary deployments, and writing code with exhaustive error checks and tests to minimize runtime surprises. It’s just a different approach: Elixir trusts the runtime to manage failure, Go trusts the programmer to manage failure (or external systems).
We’ve covered a lot of ground on architecture and code. Finally, let’s look at how deploying a Go service differs from deploying an Elixir/Phoenix application.
Deployment and Builds #
Deploying your chat application will involve building it and running it on a server (or many servers). Elixir and Go differ fundamentally here.
Build Artifacts:
Elixir/Phoenix: You typically deploy the BEAM bytecode (or use Mix Releases to create an OTP release). A release bundles the Erlang VM, your compiled .beam files, and any native compiled dependencies into a folder. You can then run a script to start it. The release is tied to an OS and CPU architecture (since it includes the VM for that platform). If you don’t use releases, you’d need Erlang and Elixir installed on the server and you might simply copy your source and compile on the server (less common in production). Tools like Distillery (older) or Mix releases (since Elixir 1.9) are common to create self-contained releases.
Go: Go builds a static binary (especially if you compile with flags to static link libs). You just get a single executable (e.g., chatserver
) that you can SCP to a server and run. There’s no external runtime needed (no JVM or VM dependency, Go’s runtime is embedded in the binary). This is very convenient – no need to worry about version of glibc (if you static link) or installing anything on the server except copying the binary. Cross-compiling is easy (you can build Linux binaries from your Mac by setting GOOS=linux GOARCH=amd64 go build
). So Go has an edge in deployment simplicity.
For example, to get a Linux binary from a Go project:
env GOOS=linux GOARCH=amd64 go build -o chatserver
This yields chatserver
which you can run on any x86_64 Linux box (assuming no cgo dependencies or those are handled).
With Elixir:
MIX_ENV=prod mix release
This might produce a tarball or directory like _build/prod/rel/my_app/
containing bin/my_app
script. You copy that whole directory to the server. Inside it has erts-XX
(Erlang runtime), lib/my_app-X.Y
(your app’s code), etc. To run: bin/my_app start
(or as a service).
Running and Supervising:
Elixir releases can be run as system services. They produce logs, etc., and you might integrate with systemd or run inside a Docker container. On failures, because Elixir app is one OS process, you’d rely on something external (systemd, Kubernetes, etc.) to restart if the whole VM crashes (which is rare unless something really unrecoverable happened, but say someone :erlang.exit(1)
the VM or an out-of-memory occurs).
Go binaries similarly would be run under a supervisor or container orchestration for restarts.
Clustering:
If you want your chat app to run on multiple nodes (for scaling or redundancy), Elixir makes it relatively straightforward to cluster nodes. You can start Phoenix on multiple machines and as long as they have the same cookie and are told about each other (using DNS or something), they can connect into a mesh. Phoenix PubSub can use the Erlang distribution for inter-node comms (by default using Distributed Erlang). So broadcasts and presence sync happen automatically across nodes. There’s some config, but basically it’s built-in.
In Go, there’s no built-in clustering for your app. You would either use an external pub/sub (like all nodes connecting to a Redis or NATS server to exchange messages and presence info), or something like hash-based routing (maybe all users in room X connect to the same server). Typically, you’d put a load balancer in front of multiple instances of the Go server. Sticky sessions might be needed if certain operations aren’t stateless (but ideally, for chat, you could treat any node equally if they share state through Redis). Essentially, Go relies on external systems to coordinate multi-node state. This adds complexity: e.g., you’ll have to deploy a Redis cluster to handle broadcasting to all WebSocket servers.
Containers: Both Elixir and Go are commonly deployed via Docker containers nowadays. The difference is image size: a Go image can be very minimal (from scratch
base, just the binary, maybe 10-20MB total). An Elixir/Phoenix image typically includes the Erlang runtime (~100MB) plus your app code, etc. However, using alpine
or debian-slim
bases, you can get an Elixir release image down to ~50-100MB. It’s not huge in modern terms, but Go is definitely smaller. If using Docker, you don’t worry about system packages – you bake everything in. The ease of static binary helps Go use scratch
(no OS at all, just the kernel interface).
Hot Code Upgrades: The BEAM supports hot code swapping – in theory you can deploy new code to a running system without downtime. In practice, few Elixir deployments use this in production because it’s complex to coordinate across many nodes and state migrations. But it’s a capability. Go does not support hot code reload out of the box – you’d typically do a rolling restart of processes for zero downtime (start new process, stop old). So for continuous updates, you often do blue-green or rolling deployments with Go services.
Deployment example: Let’s say you have to deploy our chat app to AWS:
For Elixir: you might build a release using mix release
, then copy it to an EC2, or create a Docker image and use ECS/Kubernetes. The app might be configured with an environment variable telling nodes to connect (using something like libcluster for auto-discovery). Once deployed, the nodes form a cluster; clients could connect to any node (with a load balancer) and Phoenix PubSub ensures messages reach all relevant clients.
For Go: you compile the binary for Linux, push it into a Docker image or directly to the server. You might run, say, 3 instances behind an ELB (Elastic Load Balancer). For presence and messaging sync, you also deploy a Redis instance. Your Go apps on startup connect to Redis (subscribe to a channel for global broadcasts, etc.). When a user in instance A sends a message to room X, instance A’s code publishes it to Redis; all instances receive it and each one sends to its local connections in room X. Similarly for presence, each writes to Redis when a user goes online/offline, and perhaps uses Redis pubsub or key expires to notify others. It’s a more microservices approach (each piece doing one thing: the Go app for WebSockets, Redis for coordination).
Operational considerations: Go binaries produce logs (to stdout typically), and you’d aggregate logs via whatever system. Elixir logs can be captured similarly. One difference is memory usage: an Elixir app might use more memory at idle due to the VM overhead. A minimal Phoenix app can be, say, 50MB RSS at idle. A minimal Go app might be 5-10MB at idle. Under load, both will grow with connections and activity (Elixir might handle more connections in same memory due to sharing code in VM, or maybe Go’s cheap stacks are more memory efficient – it depends). But generally, expect a Phoenix app to use more baseline memory than a Go app. CPU usage at idle is low for both (just heartbeat timers etc.).
Another difference: Observability – Elixir has great introspection (you can connect a remote IEx console to a running node, trace functions, inspect process mailboxes, etc., in production if needed). This can be invaluable for debugging. Go doesn’t have an equivalent remote console (though you can run a debug server or use pprof
to get profiles). You’d more rely on logging and metrics. Both ecosystems have good metrics integration (Telemetry in Elixir, and Prometheus client libraries in Go, for example).
Summing it up:
- Go deployment: very simple artifact, easy to containerize, just remember to manage config (commonly via env vars or a config file), and set up external services for any stateful coordination (if needed). Usually straightforward to scale horizontally (stateless by default, since no built-in state sharing).
- Elixir deployment: requires shipping the Erlang VM or having it installed. Releases simplify that. There’s a bit more overhead in startup (starting the VM, loading code). Clustering is available which is a superpower but also something to configure (you need to ensure nodes can discover each other). Scaling horizontally is natural with clustering, or you can run separate clusters and use an external pubsub like you would in Go.
Given you’re an Elixir veteran, picking up Go deployment should be easy: you’ll likely appreciate the single binary. On the other hand, you might miss how in Elixir you can connect to a running prod node and inspect state – with Go, you often have to rely on logs or metrics emitted by the program to understand its runtime state.
Finally, whether you choose Go or Elixir for a chat app might come down to team expertise and specific requirements. Elixir/Phoenix gives you a lot out of the box: the real-time layer, distributed presence, and a delightfully concise way to express business logic. Go gives you simplicity, speed, and a great standard library, but you’ll write more code for things that Phoenix gives you for free (you may implement some yourself or use libraries).
Conclusion #
Building a Discord-like real-time chat in Go and in Elixir shows two different philosophies achieving a similar goal:
Syntax & Structure: You saw how Elixir’s elegant, concise syntax (with immutable data and pattern matching) contrasts with Go’s straightforward, imperative style with explicit types. An Elixir developer might find Go code verbose, but also familiar in some ways (the flow of control is quite readable, just with if/for
instead of case/Enum.each
).
Concurrency: Elixir uses actors (processes + mailboxes) – a model that leads to isolated, fault-tolerant components. Go uses goroutines and channels – which encourage a more shared-state concurrency with careful synchronization. Each model has its merits: BEAM processes for supervised, let-it-crash design; Go channels for explicit communication and potentially higher raw performance in tight loops. In our chat app context, Elixir allowed us to handle each user in a separate process effortlessly, whereas in Go we manually managed goroutines and a broadcast channel.
Real-time features: Phoenix Channels made implementing chat rooms and messaging almost trivial – a few callbacks and broadcasts. Go required us to utilize a WebSocket library and write loops to manage client connections. It’s more manual, but also very transparent. Presence in Phoenix was a few lines with Phoenix.Presence, whereas in Go we outlined a strategy using Redis to synchronize presence – more code and an extra component to manage. This demonstrates how Elixir’s ecosystem is batteries-included for web real-time features, while Go’s ecosystem gives you pieces to assemble (there are libraries like Centrifugo or Socket.IO-like libraries in Go if you want more Phoenix-like abstraction – those could be considered too).
Reliability: The Elixir approach will inherently be resilient to failures – your chat server could run for months without a restart, hot upgrading if needed, handling millions of events, with the supervisors keeping it healthy. The Go approach will rely on your error handling and maybe external process supervisors; it can also run for long times, but a crash would drop all connections at once. You might mitigate that with load balancers and multiple instances (so one crash doesn’t drop every user, only those on that instance, who could reconnect to others).
Performance: We compared trade-offs – Go likely uses less CPU per message and can squeeze out more single-thread performance; Elixir provides more consistent experience under extreme concurrency and simplifies scaling to multiple nodes. For a typical chat app, both can easily handle the load (the bottleneck is usually network or external systems like databases). Elixir’s coordination (PubSub, presence) is a win for fast development. Go’s static typing and simple model is a win for maintainability in large codebases (though Elixir is also quite maintainable, dynamic typing aside, thanks to pattern matching and clear semantics).
Testing & Dev Experience: Elixir’s interactive IEx, rapid recompile with Phoenix live reload, and ExUnit’s rich features make development joyful. Go has a fast compiler (near instant build/test feedback even on large projects) and simplicity that make debugging straightforward (plus a great race detector). It lacks a REPL, but tools like go run
and Delve debugger fill some gaps.
Deployment: We saw Go produce a single binary for easy deploy, while Elixir needs an OTP release or container with the runtime. Go might be favored for deploying small microservices (very low memory/cpu overhead per service). Elixir might shine when you want an all-in-one solution (the Phoenix app handling websockets, background jobs, pubsub, etc., all within one runtime).
In the end, for an Elixir expert, learning Go means un-learning some magic (there’s no OTP to save you – you are OTP). But it also means enjoying the efficiency of a compiled language and a powerful standard library. By walking through how to build a chat app in both, we see there’s no mystery – just different ways to achieve concurrency and real-time communication.
Both implementations can meet the requirements of real-time messaging, presence, typing indicators, and channel support – it’s about the journey to get there. Elixir gives you a train with the tracks laid out (you jump on and go), whereas Go gives you the raw engine and you lay the tracks as needed (more control on where you lay them).
To wrap up, if you’re expanding a Phoenix-based system with some Go services (which some teams do, to leverage strengths of each), this comparison should help you map concepts:
- Module vs Package, Process vs Goroutine, send/receive vs chan send/recv, Supervisor vs (external restart or none), Mix/Hex vs Go Modules, ExUnit vs testing.T, and so on.
Each ecosystem has rich community support: Elixir has hex.pm and guides for Phoenix; Go has a huge standard library and examples for practically everything (including WebSockets and Redis usage we referenced). As a final thought, there’s an increasing trend of using them together – for instance, using Phoenix Channels for front-end websockets and Go for some backend services – leveraging Phoenix as an interface and Go for heavy lifting. That’s an architecture some companies adopt (each doing what it’s best at).
But if it’s a head-to-head choice: a small team wanting fastest time to market with real-time features might favor Phoenix for its high-level abstractions and fault-tolerance. A team that prioritizes absolute performance or already has Go expertise might go with Go, possibly using a framework or library to speed up development of the real-time aspects.
Either way, both are excellent tools. Having proficiency in both Elixir and Go is a powerful combination in today’s backend engineering world. Good luck with building that next-gen chat application – whichever stack you choose, hopefully this comparison has given you clarity on how to implement those Discord-like features in each. Happy coding!
Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.
Published