A Refreshing Tonic, Realtime Updates with Phoenix Channels

Over the last few months I've been working in Elixir and its most popular web framework Phoenix. During this time I built "Houston", a deployment tool written in Elixir/Phoenix to help make deploying at TpT easier by providing a simple uniform interface to all of our Jenkins Pipelines. The Houston interface shows a number of important pieces of time-sensitive information that I needed to keep up-to-date so developers could coordinate deployments more effectively. I wanted to minimize the usage of other frameworks and tackle the problem quickly. I read about the team at thoughtbot using Phoenix Channels in lieu of React to deliver updated content to the browser and decided that using Channels would allow me to implement realtime updates easily by leveraging Phoenix directly.

houston

A few weeks ago I had the pleasure of pairing with Chris McCord, one of the co-creators of the Phoenix framework, when TpT invited him to help with our migration to Elixir. One of the things we worked on together was implementing realtime updates for Houston with Channels. The Houston code is unfortunately still closed source, but I have created a simple application, TonicTime, to demonstrate the same Channel concepts. You can see the code on GitHub. The code on GitHub is slightly modified from the snippets here due to how it is being hosted.

After a short blurb about Channels we will look at the most important snippets from TonicTime to see how it all works.

What are Phoenix Channels? #

Channels are a simple high-level abstraction for developing realtime components in Phoenix applications using WebSockets. Channels allow for interaction between application components that are not Elixir processes without building out a separate pub/sub system or using a third-party service like Pusher.

Using Channels #

If you take a look at the embedded live demo of TonicTime below you will notice that the time is constantly updating without reloading the page or submitting a new request. So how is this happening? Below is a diagram of some of the components involved. We'll look at the Javascript/HTML first and then the supporting Elixir modules. The lettered interaction stages are referenced below as we walk through the code.

arch diagram

HTML #

The index template contains only one div with the current time inside, web/templates/page/index.html.eex:

<div id="clock" class="jumbotron">
  <h2><%= @time_now %></h2>
</div>

Above @time_now is accessing a variable passed to the template in the assigns map. The Phoenix docs go into more detail about this, see Phoenix.View.render/3.

Javascript #

All of our Javascript is in web/static/js/app.js, it opens a socket connection to our application's mount point (A) and also specifies that we should replace the innerHTML content when receiving update messages (G):

// We set an explicit id on the div in our HTML to allow
// us to easily access it and replace its content.
let container = document.getElementById("clock");

// The code on GitHub connects to "/time/socket", this is due
// to how TonicTime is deployed. The socket endpoint just needs
// to match the socket created in the TonicTime.Endpoint module.
let socket = new Socket("/socket");
socket.connect();

let timeChannel = socket.channel("time:now");

// When an `update` message is received we replace the contents
// of the "clock" element with server-side rendered HTML.
timeChannel.on("update", ({ html }) => (container.innerHTML = html));

// Attempt to connect to the WebSocket (Channel).
timeChannel
  .join()
  .receive("ok", (resp) => console.log("joined time channel", resp))
  .receive("error", (reason) => console.log("failed to join", reason));

Elixir #

There are a number of moving parts here:

  1. The socket mount-point (B) needs to be defined in our Endpoint setup. As mentioned above, this needs to match the socket endpoint that we attempt to connect to in our Javascript code:
socket "/socket", TonicTime.UserSocket
  1. The function Page.Controller.index which handles GET requests to the index:
 def index(conn, _params) do
   # Get the time from the TimeManager state, we'll look at this
   # in detail below.
   time_now = TimeManager.time_now()

   # Render the template `index.html` passing `time_now` in
   # the `assigns` map.
   render conn, "index.html", [time_now: time_now]
 end
  1. The TimeChannel Channel (C) which listens for updates and alerts subscribers:
 defmodule TonicTime.TimeChannel do
   use TonicTime.Web, :channel

   # Client method called after an update.
   def broadcast_update(time) do
     # Render the template again using the new time.
     html = Phoenix.View.render_to_string(
         TonicTime.PageView,
         "index.html",
         [time_now: time]
     )

     # Send the updated HTML to subscribers.
     TonicTime.Endpoint.broadcast(
         "time:now",
         "update",
         %{html: html}
     )
   end

   # Called in `app.js` to subscribe to the Channel.
   def join("time:now", _params, socket) do
     {:ok, socket}
   end
 end
  1. The TimeManager GenServer (D) which holds the current time in its state, and is also responsible for triggering updates to the time (E). If you look at the full file you'll see there is a lot of code there to facilitate interaction with the GenServer. Most of this is not important to understanding Channels, the most relevant function is below:
 defp update_time do
   updated_time =
     "US/Eastern"
     |> Timex.now()
     |> Timex.format!("%I:%M:%S %p", :strftime)

   # Schedule another update call to happen in 1 second.
   schedule_time_update()

   # Send the updated to our Channel so it can update clients.
   TonicTime.TimeChannel.broadcast_update(updated_time)

   %{clock: updated_time}
 end

Review #

By leveraging Channels I was able to reduce the use of another framework in my project and still provide users with seamless dynamic page content. While we have looked at a trivial example of what Channels can do, there are many possibilities and native support in Phoenix makes implementation fast and natural.

Hopefully through this overview I was able to provide you with an easy to follow introduction to Channels and a framework for implementing them in your own projects.

Credits #

A huge thanks to Chris McCord who walked me through Channels and their usage in Phoenix. Credit for the pun in the title, "A refreshing tonic", goes to my fantastic friend and coworker Shanti Chellaram, who is almost as good as making up puns as she is at programming. And finally a big thanks to Ryan Sydnor for his help with building Houston, his many edits to this post, and his endless enthusiasm.