Any Rails developer will know it is best to execute slow-running tasks in a background job rather than during the HTTP request/response cycle. For example, when creating a resource, we want to redirect the user to the newly created resource as soon as possible, while any slower related tasks such as sending emails happen asynchronously in the background.
Sometimes we need to update the view to notify the user when the background job is complete. These days we don’t expect the user to have to manually reload the page to see this updated content. We expect the user to be notified automatically.
I encountered this recently in an application I was working on. In this app, a user can duplicate a Project
model, along with all of its associations. After creating a duplicated project, the user is redirected to the project index page. The new project is displayed but in an in_progress
state. Meanwhile, the actual duplication is whirring away in a background job. When the background job finishes, I needed to update the project in the view so that the user knows it is ready.
There a few different methods for achieving this, namely:
- Short polling
- Long polling
- WebSockets
- Server-Sent Events (SSE)
I experimented with the implementation of two of these techniques, short polling and WebSockets, to better understand the advantages and disadvantages of each. This is what I found out:
1. Short polling
Polling is a technique whereby the client regularly requests data from the server at a set interval. It is essentially mimicking the user sitting there spamming the browser refresh button, eager for the latest content (except, of course, using AJAX to avoid a page reload).
To achieve this, we first need an endpoint to fetch the latest content from the server:
# config/routes.rb Rails.application.routes.draw do get 'check_projects', to: 'projects/check_projects#index', as: :check_projects end
And here is the controller:
# app/projects/check_projects_controller.rb module Projects class CheckProjectsController < ApplicationController def index @finished_projects = Project.where(id: params[:ids]).finished end end end
All that happens inside the controller action is I query for any projects that have finished processing, matching the IDs passed in the params. The controller renders the index.js.erb
template which renders a partial for each finished project and injects the partial into the DOM, like so:
# app/view/projects/check_projects/index.js.erb <% @finished_projects.each do |project| %> $(".js-project-<%= project.id %>").replaceWith("<%= j render 'projects/project', project: project %>"); <% end %>
Now we need some Javascript to do the polling:
let interval; $(document).on('turbolinks:load', () => { interval = setInterval(() => { const projectIds = $('[data-check-project]').map(function() { return $(this).data('check-project'); }).get(); if (projectIds.length) { $.get('/check_projects', { 'ids[]': projectIds }); } else { clearInterval(interval); } }, 5000); }); $(document).on('turbolinks:before-cache', () => { clearInterval(interval); });
On the project index page, any project partial that is rendered in an in_progress
state is given a data attribute that stores its ID. So when the page loads, we can easily use jQuery to get an array of project IDs we need to check. If we find at least one project ID to check, an AJAX request is made. If not, we clear the interval and stop polling the server. Any project that has been updated to a finished state will be re-rendered without the data attribute, so when we re-fetch the projectIds
each interval, any complete projects will drop out the array.
And that’s it! Pretty straightforward.
2. WebSockets
The WebSocket protocol makes it possible to open a two-way communication session between the client and the server. Rather than the client having to request data from the server, WebSockets allow the server to send data to the client at any time, without the client having to ask for it. Action Cable was first introduced in Rails 5 and provides a painless way to integrate WebSockets into our Rails applications. So isn’t polling a bit old hat now? It certainly feels intuitively more ‘correct’ to notify the user as soon as our Project
is finished, rather than expecting them to keep asking if it is very few seconds.
Let’s see how an Action Cable solution would look:
First some setup, we need to make sure we have some files that are automatically generated in recent versions of Rails:
// app/javascript/channels/consumer.js import { createConsumer } from "@rails/actioncable" export default createConsumer()
// app/javascript/channels/index.js const channels = require.context('.', true, /_channel\.js$/) channels.keys().forEach(channels)
# app/channels/application_cable/channel.rb module ApplicationCable class Channel < ActionCable::Channel::Base end end
The Channel
class is for shared logic between our channels, as we only need one channel, this will stay empty.
# app/channels/application_cable/cable.rb module ApplicationCable class Connection < ActionCable::Connection::Base end end
Rails uses the Connection
class to handle authorisation and authentication of connected users. For our purposes, we can leave this class as is too.
We also need to update config/cable.yml
to use Redis in our development environment:
development: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
Now that’s out the way, let’s create a project channel:
# app/channels/project_channel class ProjectChannel < ApplicationCable::Channel def subscribed stream_from 'project_finished' end end
Streams provide the mechanism by which the server can send data to the client. Any user that connects to the ProjectChannel
will start streaming from the project_finished
stream. We can then broadcast data to this stream, and any connected user will receive it.
Now we need to subscribe to the ProjectChannel
on the client-side. We only really need the WebSocket connection on the project index page, and only if there is at least one Project
in an in_progress
state. So let’s reuse the data attribute from the last example to check if we need to connect (we could, however, just use a class this time instead, as we are not actually going to use the value of the data attribute).
import consumer from './consumer' let subscription $(document).on('turbolinks:load', () => { if (subscription) { consumer.subscriptions.remove(subscription); } const $projects = $('[data-check-project]'); if ($projects.length) { subscription = consumer.subscriptions.create({ channel: "ProjectChannel" }, { connected() { console.log('connected'); }, disconnected() { console.log('disconnected'); }, received(data) { // do something with the the data here... } }); } });
Ideally, what I wanted to do here was, once the background job completes, render the finished project partial on the server and send this data down to the client, Something like this:
project_partial = ApplicationController.render( partial: 'projects/project', locals: { project: project } ) ActionCable.server.broadcast('project_finished', { project: project_partial })
The client would receive this partial and could simply inject it onto the page.
However, this is where I hit my first stumbling block: my project partial contains logic based on the current user. So rendering this partial once and sending it out to all subscribers wasn’t going to work. In order to overcome this, I would have to implement a purely frontend solution for things such as showing/hiding a delete link based on the privileges of the user. But this seemed needlessly complex and would most likely lead to a duplication of logic between the frontend and backend.
Instead, the far easier solution would be to just broadcast the ID of the complete project out to the subscribers, who would then each make an AJAX request to fetch this project from the server. This would require adding an endpoint to fetch the project from and a js.erb
template to render the partial and inject it onto the page. Suddenly my WebSockets solution was looking an awful lot like my polling solution, except with more code and added complexity. Was it worth it to avoid a few extra HTTP requests?
Then I encountered another problem. What if the user initially requested the project index page while some projects were in_progress
, but then missed the broadcast instructing them to fetch the finished project from the server? Perhaps the broadcast was sent in the gap between fetching the projects from the database initially and establishing the WebSockets connection. Or perhaps the user temporarily lost their network connection while the broadcast was sent. In order to avoid a situation where the user is left for an eternity staring at their screen waiting for the project to load, we would have to fetch the projects with an AJAX request every time the WebSockets connection was established.
Conclusion
Initially, I thought that polling was obsolete as a method for displaying up-to-date content to the user without requiring them to refresh the page. But after implementing a solution with Action Cable, I am not so sure.
Of course, polling does have its disadvantages. Firstly, updates are not instant; the user might have to wait a few seconds for the next HTTP request before they see the updated content. This is probably important for something like a chat application or a multiplayer game, but for my use case, it is pretty much inconsequential. I am really not worried about a user being notified a Project
being complete a few seconds late.
Secondly, there is the issue of the extra bandwidth and resources required to handle users making requests every few seconds. Again, there are cases where this may be a concern. If my application had a notification system that required every user to always be polling for new notifications on every page, there may be a strong argument in favour of WebSockets. But again, my use case only involves polling on a specific page and only when a new Project is created. Once the project is complete, the client stops polling. And because I don’t expect users to be creating Projects very often, the extra HTTP requests are not something I am worried about.
Rails being Rails, Action Cable made integrating WebSockets into my application painless, with very little set up to do. However, we have to handle a lot of added complexities, such as users missing broadcasts, that we don’t have to worry about at all when polling.
Next time you need to implement live updates in your application, it is worth thinking about whether WebSockets will add any significant advantages over polling that make it worth dealing with the added complexities. Polling is definitely still the right choice in many situations.