← Back to listing

Real-time multi-user canvas with Elixir and Phoenix

Development Elixir Phoenix Websockets
WallEx being used by multiple people to express their.. something
WallEx being used by multiple people to express their.. something

Note: The demo I discuss below was built in 2017, so certain examples might not reflect best practices today. It’s still a fun little interactive example of Elixir though, so I wanted to write some thoughts about it regardless.

Three years ago I wanted to experiment with the soft real-time capabilities of Elixir and Phoenix. I wanted to build a fun demo that could be easily shared with my friends and co-workers, and which would also illustrate the real-time aspect well. With that, I came up with the idea of creating a shared canvas users could draw on. I had seen these on the internet before, but none built with Elixir.

I dubbed this demo WallEx (GitHub link), and you can check it out running on Heroku. Due to using free dynos, there might be a spin-up time if no traffic has been directed WallEx’s way lately.

How it works

UI with canvas, JavaScript and phoenix.js

To be honest, I wasn’t really interested in the frontend of this project. I just wanted something usable in order to demonstrate the cool things happening on the server side, and didn’t really care what the frontend was like as long as it did its job.

The frontend is essentially a single HTML canvas element with simple JS front-end to allow drawing lines on the canvas. The only front-end library is phoenix.js, which ships with the Phoenix framework and which simplifies the use of Phoenix' Channel abstraction.

Phoenix.js makes it super simple to handle the websocket parts. Most of my websocket-related code looked just like the following:

/**
 * Draw lines upon first page load. Can happen multiple times, as the server will
 * batch messages to keep array sizes sane.
 */
channel.on("load", payload => {
  canvas.drawLines(payload.lines);
});

/*
 * Draw on canvas whatever we receive from the server.
 */
channel.on("draw", payload => {
  canvas.drawLines(payload.lines);
});

Server-side websockets with Phoenix Channels

Phoenix has an amazing abstraction around Websockets called Channels, which can handle hundreds of thousands (or up to millions, given enough CPU juice) of connected clients on a single machine. While on my non-existing scale any solution could do, having something that can scale as my foundation was a nice benefit to have.

As an example of how the draw event from the previous example is handled in the Elixir code:

@doc """
Handle a draw event, storing the lines drawn to our storage alongside with a
timestamp that can be used to clear the canvas.
"""
def handle_in("draw", %{"canvas_id" => canvas_id, "lines" => lines}, socket) do
  timestamp = :os.system_time(:nano_seconds)
  Storage.insert_drawing(%{timestamp: timestamp, canvas_id: canvas_id, lines: lines})
  broadcast!(socket, "draw", %{canvas_id: canvas_id, lines: lines})
  {:noreply, socket}
end

Each stroke on the canvas is sent to server, saved to ETS (see the storage section below), and broadcast back to all other users. To avoid broadcasting the unnecessary data back to the client that sent it, we intercept the outgoing event and pass the message onwards only if the canvas_id (user identifier) differs from the sender:

@doc """
Intercept the outgoing draw event and filter it out if the receiver would be
the sender. Pages draw locally to their own canvas before sending out draw events
so we don't rebroadcast them the event they sent the server.
"""
def handle_out("draw", %{canvas_id: canvas_id, lines: lines}, socket) do
  if canvas_id === socket.assigns.canvas_id do
    {:noreply, socket}
  else
    push(socket, "draw", %{lines: lines})
    {:noreply, socket}
  end
end

When a user connects, the server sends them all stored data in chunks. Chunking was my solution to the problem where certain low-powered devices (mobile phones) would have their browsers crash when massive (hundreds of thousands of entries) arrays would be sent to their way, and they’d have to draw them all to the canvas at once. I could’ve batched the draws client-side as well, but this is the solution I went with.

@doc """
Load existing drawings and send them to the joining client.
"""
def handle_info(:after_join, socket) do
  batches = get_drawings_from_storage()
  # Push each batch to the client
  Enum.each(batches, fn lines -> push(socket, "load", %{lines: lines}) end)

  {:noreply, socket}
end

defp get_drawings_from_storage do
  drawings = for [{_, item}] <- Storage.get_drawings(), do: item
  lines = Enum.map(drawings, fn drawing -> drawing end) |> List.flatten()

  # Go through `lines` and step through every 500 of them.
  # Returns a list of lists, so we can enumerate through it to push.
  Enum.chunk_every(lines, 500)
end

What I would do differently today: I would have a separate task for running Storage.insert_drawing/1 rather than running it as part of the handle_in/3 function. This change could save some milli- or microseconds per write (of which there are plenty), as we wouldn’t need to wait for the insert to be complete to broadcast the drawing to others. I’ve never witnessed any problems with this in the real world, but on a large-scale application this might be a worthwhile consideration. I would also reconsider the need to batch the draws the way I did.

In-memory storage with ETS

As might already be clear from above, storage was a requirement to allow new users to see what others had already drawn. Without storing all the drawings, it’d be impossible to ensure everyone sees the same things, and there’d be no way to handle refreshes or disconnects reliably.

WallEx saves each stroke as its own row. I used a tuple of the timestamp and canvas_id as the key, since I wanted to preserve the possibility of both 1) expiring old drawings, and 2) identifying which drawings belong to whom. I also needed something more than the timestamp as the key anyway, as multiple people could be drawing at the same time, and theoretically their keys (based on the timestamp alone) could collide.

@doc """
Given a drawing, insert it to ETS.
"""
@spec insert_drawing(map()) :: boolean()
def insert_drawing(drawing) do
  :ets.insert_new(
    @drawings_table,
    {{drawing.timestamp, drawing.canvas_id}, drawing.lines}
  )
end

What I would do differently today: I would put a limit in place on how much drawing can be done on a specific canvas, or replace a stored row with another when their coordinates match. Thanks to not having this in place now, with enough drawings and enough time you can kill the entire application by exhausting it of its memory :).

Benefits of doing this with Elixir

While the demo discussed above is fairly simple, and could be built with any reasonable programming language and stack, I feel there are certain reasons why Elixir is a great fit for soft real-time projects like this. Aside from the usual “BEAM is built for concurrency” line, there was something in particular that made me productive: A simple tech stack with no external software dependencies. Thanks to ETS being built in to BEAM, I was free to not configure any additional software on my server to make the demo work. In most other languages I would have reached for something like Redis to act as the datastore, but with Elixir I could just write a few dozen lines of code and be done with it.

Utilizing the tools built in to BEAM simplified my tech stack, as now I just had to build and run my application and not worry about anything else. With fewer external dependencies in place, I also had more control over how exactly my storage system behaves.

This isn’t to say Redis isn’t amazing (it is!), but being able to avoid extra pieces of technology was a welcome feature of my stack choice.