Like A Girl

Pushing the conversation on gender equality.

Code Like A Girl

How to Make a Server-Side Timer with Phoenix WebSockets

WebSockets is a technology that allows communication between the back-end and the front-end over a single TCP connection. The connection remains open until the user leaves the channel, making event driven responses a breeze. There is no need for HTTP requests to get data, only broadcasting of messages and subscriptions to topics to enable communication.

The Timer Project

For an easy to understand project, I decided to do a server-side timer that tells the front-end to update the time on the page every second. The front-end can start the timer, and reset it at any given time. Both the front-end and the server will be subscribed to the channel and broadcasting messages, making it a two way communication.

Complete timer project flowchart

Follow along or get the completed project from this repo.

Part I: Joining The Channel

Setting Up The Phoenix Application

Let’s start by creating our project and starting the server. Type y if asked to install any dependencies. We won’t be needing ecto since our project doesn’t contain any database tables.

$ mix phx.new enchufe --no-ecto
$ cd enchufe
$ iex -S mix phx.server

The JavaScript

Since all of the socket magic happens through JavaScript, we need to uncomment the import line to include the socket.js file. This file will contain all of the front-end channel logic to be used through the application.

# assets/js/app.js
import socket from "./socket"

The socket file already has some channel code written in for us. Notice how we are importing the Socket class from "phoenix" at the beginning of the file giving us the functionality we need to make this work. We will be console logging if we successfully joined the timer channel. Change the topic:subtopic to timer:update to listen to the timer updates. Feel free to change the success and error messages to whatever you like!

# assets/js/socket.js
let channel = socket.channel('timer:update', {})
channel.join()
.receive('ok', resp => { console.log('Joined successfully', resp) })
.receive('error', resp => { console.log('Unable to join', resp) })

The Timer Channel

Channels send and receive messages, broadcasting to subscribers of different topics. The timer channel is going to have two topics, one for updates and one for starting the timer. All of this logic will be encapsulated in the TimerChannel module which will be utilizing the Phoenix.Channel behaviour.

First, we need to allow the front-end to join the channel through the join callback function. This allows the front-end to receive any messages with the timer:update topic. We need return :ok and the socket for a successful connection. If you had an authorization flow, here is where you would either allow or deny connection to your socket based on credentials.

# lib/enchufe_web/channels/timer_channel.ex
defmodule EnchufeWeb.TimerChannel do
use Phoenix.Channel
  def join("timer:update", _msg, socket) do
{:ok, socket}
end
end

Routing Channels Through Our Socket

We need to route all the incoming messages to the corresponding channel in the socket module. We will keep the name user_socket for lack of a better name. We can uncomment the channel macro line and change the name from room to timer to send all the timer messages to the TimerChannel module.

# lib/enchufe_web/channels/user_socket.ex
## Channels
channel "timer:*", EnchufeWeb.TimerChannel

Test It Out

We have joined or “subscribed” our front-end to the timer channel and the update topic.

Front-end joined timer channel and listens to update topic

Go tolocalhost:4000 in your browser and you should be able to see you joined a room!

If you are using Chrome, you can access the console by pressing command + ctrl + i (or left click -> Inspect) and going to the Console tab. That will enable you to see what we are logging in the js console and debug.

Part II: Broadcasting Messages

The Timer GenServer

We will be using a GenServer for our timer since they are amazing and everybody needs to know how to use them. Short and sweet: a GenServer is an Elixir process that maintains state and executes code.

When the server is started with start_link the callback init is run. This sends a logger message that the server has started and broadcasts the new_time event to the timer:update topic including a start time of 30 and a message of Started timer!. Then, it schedules a timer to send itself an :update message after 1000 ms (1 second).

Once the second is up, the server receives the :update message, firing the handle_info callback which reduces the timer’s time by 1. The leftover time is broadcasted to the timer:update topic with new_time event and reschedules the 1 second timer. When the time is 0 the timer doesn’t reschedule an update.

# lib/enchufe/timer.ex
defmodule Enchufe.Timer do
use GenServer
require Logger
  def start_link() do
GenServer.start_link __MODULE__, %{}
end
  ## SERVER ##

def init(_state) do
Logger.warn "Enchufe timer server started"
broadcast(30, "Started timer!")
schedule_timer(1_000) # 1 sec timer
{:ok, 30}
end
  def handle_info(:update, 0) do
broadcast 0, "TIMEEEE"
{:noreply, 0}
end
def handle_info(:update, time) do
leftover = time - 1
broadcast leftover, "tick tock... tick tock"
schedule_timer(1_000)
{:noreply, leftover}
end
  defp schedule_timer(interval) do
Process.send_after self(), :update, interval
end
  defp broadcast(time, response) do
EnchufeWeb.Endpoint.broadcast! "timer:update", "new_time", %{
response: response,
time: time,
}
end
end

Handling Incoming Messages

Our channel needs to handle the new_time event and push it so the front-end can receive the message. This is done with the handle_in callback function which takes the event, message and the socket.

# lib/enchufe_web/channels/timer_channel.ex
def handle_in("new_time", msg, socket) do
push socket, "new_time", msg
{:noreply, socket}
end

We can then write an event handler for new_time in our js socket file that outputs the time to the console.

# assets/js/socket.js
channel.on('new_time', msg => {
console.log("The timer is: ", msg.time)
})

Test It Out

We have enabled our Timer GenServer to broadcast update topic events that will be received by the front-end through our TimerChannel.

Timer server broadcasts update topics to the timer channel

For now, we need to start our GenServer by going to the same terminal window we used to start the server and calling the start_link function. (You may have to restart your application)

iex> Enchufe.Timer.start_link()

If you go back to your browser, you should be able to see the timer running in your console!

HTML Timer

In order to see our timer change in our actual page we are going to id two <p> tags in our HTML with status and timer, respectively. (I know this may not the best way to handle this but I’m not teaching JavaScript).

# lib/enchufe_web/templates/page/index.html.eex
<div class="jumbotron">
...
<p id="status" class="lead">Ready</p>
<p id="timer" class="lead"></p>
...
</div>

We are going to select those elements in our JavaScript and set the innerHTML to show the messages from the server.

# assets/js/socket.js
channel.on('new_time', msg => {
document.getElementById('status').innerHTML = msg.response
document.getElementById('timer').innerHTML = msg.time
}

Call the start_link function in your terminal again, and you should be able to see your page changing!

Timer showing in HTML

Part III: Completing The Loop

Front-end Broadcasting

We need to give the ability for the front-end to start and restart the timer. We are going to add a button in the HTML and link it to an onclick event to capture the restart of the timer.

# lib/enchufe_web/templates/page/index.html.eex
<div class="jumbotron">
...
<p id="status" class="lead">Ready</p>
<p id="timer" class="lead"></p>
<button class="btn btn-primary" id="start-timer">Start Timer</button>
</div>

In the JavaScript file, we can push messages to our socket by using the channel.push function, sending the event and capturing the response from the channel.

# assets/js/socket.js
let startTimer = function (event) {
event.preventDefault()
channel.push('start_timer', {})
.receive('ok', resp => { console.log('Started timer', resp) })
}
document.getElementById('start-timer').onclick = startTimer

In the channel, we can handle the start_timer event and broadcast it through the endpoint to the rest of the application. We’ll call this topic timer:start and pass in an empty map since we have no data to send over.

# lib/enchufe_web/channels/timer_channel.ex
def handle_in("start_timer", _, socket) do
EnchufeWeb.Endpoint.broadcast("timer:start", "start_timer", %{})
{:noreply, socket}
end

Subscribing The Timer Server

In order for the GenServer to receive the broadcasted message, we need it to subscribe to the timer:start topic. We are going to do it in the init function so the server is listening for events as soon as it’s spun up. We are also removing the schedule timer from the init function since we will be firing that only when the start_timer event is received.

We will write a handle_info callback function that will match on the start_timer event and reset the timer to 30, schedule the 1 second update, and broadcast that the time has restarted. This function will get called every time a start_timer event is received from the broadcaster.

# lib/enchufe/timer.ex
def init(_state) do
Logger.warn "Enchufe timer server started"
EnchufeWeb.Endpoint.subscribe "timer:start", []
{:ok, nil}
end
def handle_info(%{event: "start_timer"}, _time) do
duration = 30
schedule_timer 1_000
broadcast duration, "Started timer!"
{:noreply, duration}
end

Starting The GenServer On App Start

At this point, we don’t want to be going into the terminal and running the start_link function to start the timer server. We need it to be started on app start so it is always ready to receive events. We can put our server under the supervision tree in the application module.

# lib/enchufe/application.ex
children = [
...
worker(Enchufe.Timer, []),
]

By adding our GenServer as a worker under the supervision tree, the supervisor calls the start_link function to start the server whenever the application starts. If the server crashes, the supervisor will restart it, ensuring our timer is always available.

Test It Out

GenServer and front-end socket communcation

Our application infrastructure should finally be complete! Restart your application (so the GenServer starts) and check out your page. If you click on the start button the timer should start and update the time every second. But what happens if we click the start button again? It seems like our timer is decreasing twice as fast… click it three times and its decreasing three times as fast.

What’s going on? It turns out, we are firing the scheduler each time the start button is clicked and never canceling it when the button is clicked again. Luckily we can solve that pretty easily by storing the reference to our timer process.

Cancelling Timers

The function Process.send_after returns a reference id to the timer process. We can store this reference in the GenServer state and call Processs.cancel_timer(reference) when we need to cancel the timer process. We are going to change our GenServer state to store the timer value and the timer reference so every time the start_timer event is received, the old timer can be cancelled and the reference to the new timer can be stored! Your timer module final state should look like this:

# lib/enchufe/timer.ex
defmodule Enchufe.Timer do
use GenServer
require Logger
  def start_link() do
GenServer.start_link __MODULE__, %{}
end
  ## SERVER ##

def init(_state) do
Logger.warn "Enchufe timer server started"
EnchufeWeb.Endpoint.subscribe "timer:start", []
state = %{timer_ref: nil, timer: nil}
{:ok, state}
end
  def handle_info(:update, %{timer: 0}) do
broadcast 0, "TIMEEEE"
{:noreply, %{timer_ref: nil, timer: 0}}
end
def handle_info(:update, %{timer: time}) do
leftover = time - 1
timer_ref = schedule_timer 1_000
broadcast leftover, "tick tock... tick tock"
{:noreply, %{timer_ref: timer_ref, timer: leftover}}
end
  def handle_info(%{event: "start_timer"}, %{timer_ref: old_timer_ref}) do
cancel_timer(old_timer_ref)
duration = 30
timer_ref = schedule_timer 1_000
broadcast duration, "Started timer!"
{:noreply, %{timer_ref: timer_ref, timer: duration}}
end
  defp schedule_timer(interval), do: Process.send_after self(), :update, interval
  defp cancel_timer(nil), do: :ok
defp cancel_timer(ref), do: Process.cancel_timer(ref)
  defp broadcast(time, response) do
EnchufeWeb.Endpoint.broadcast! "timer:update", "new_time", %{
response: response,
time: time,
}
end
end

Make sure you restart your app before you test since we messed with the GenServer.

Summary

The simple web socket implementation we have built isn’t all that exciting but we learned how web sockets work and how easily we can have the front-end and back-end communicating seamlessly through channels.

Want to turn it up a notch? Try setting the time interval through the front-end instead of hardcoding it in the backend. Add authentication of users. Add a stop button. We could keep going but this keeps it short and sweet. I hope you had as much fun as I did!

Did you like this tutorial and would you want more? I probably won’t make a video unless requested since they take a little more work. Any feedback is appreciated! Thanks for reading 🙂