Finnian's blog

Software Engineer based in New Zealand

Hotwire in the real world.

Building & scaling a Rails app built using Turbo/Hotwire, what did we learn?

15-Minute Read

I’ve recently been working on a new project at work which utilises Rails and Hotwire.

There’s lots of articles out there about getting started with Hotwire in Rails but I couldn’t find much in the way of how to architect a Rails app in this way or how to run it in production, so this is a bit of a brain dump on how we’ve done things, what we learnt along the way, plus some things we don’t like.

First, some terminology.

Hotwire (or, Html-Over-The-WIRE) is a methodology, rather than a specific library. It’s stack-agnostic and simply describes an approach for delivering interactive applications by transmitting standard HTML instead of client-side components or state. It means that state lives on the server rather than the client, which means you don’t need to manage business logic or state in two places.

The Basecamp team has provided us with three components that can be used to build an application using the Hotwire approach.

Turbo

Turbo powers the majority of the functionality within a Hotwire application. It’s responsible for capturing form submissions & links to speed up page loads, decomposing HTML pages into “frames” which encapsulate logic & contexts, and streaming HTML over WebSockets to deliver realtime updates to the browser.

Turbo is split into an additional four components:

Turbo Drive

Turbo Drive is responsible for handling all the form submissions and link clicks mentioned above. This component used to be called “turbolinks” but has now matured into Turbo Drive.

Turbo Frames

Turbo Frames are useful for encapsulating logic, contexts and navigation into a single “frame”. I found it useful to think about these as being similar to iframes. You can also configure frames to be lazy-loaded which is really useful for content below the fold or for expensive requests.

Turbo Streams

Turbo Streams let you deliver HTML from the server to the browser when events occur on the server (SSE). A simple example of this is refreshing a view of a model when the model changes on the server.

Turbo Native

We don’t know too much about Turbo Native yet, but my understanding is it will allow you to use your HTML views to power native navigation within an iOS or Android app.

Stimulus

Where you can’t (or don’t want to) use Turbo to achieve the functionality you want, Stimulus gives you the tools to write custom JavaScript to handle more complex use cases.

Some good examples of this might be an autocomplete component or some client-side validation.

Strada

We don’t quite know what Strada looks like yet, but it’s a tool that enables a bridge between standard HTML views and native code within an iOS or Android application.

Whilst the Hotwire/Turbo + Rails approach is fairly new, we opted to go for it for a number of reasons, namely:

  1. Our app needs to update in real time, but there’s not really a need for React
  2. The Basecamp team have been using Turbo in production with Hey.com and seem to have done okay with it
  3. We ❤️ Rails

Domain-specific terminology

In the Turbo documentation, the Basecamp team references models from their HEY email service which I find very difficult to understand. In our application, we have cards and boards. It may be useful to think of a tool with some similar characteristics to Trello for context throughout this article.

Building our app

Our application renders a board with a bunch of cards within columns. Each column is contained within its own Turbo Frame and accompanying stream which allows us to deliver realtime updates to the entire column, rather than a specific card. This is because we need to retain the order of the elements - we make the whole column a frame so that we can reorder the cards on the server without needing to do anything client-side.

You may think that swapping out the inner HTML of a scrollable element would break the browser’s scroll position, but it works just fine in our experience. However, we think this approach is a little heavy-handed and are investigating swapping this out for single-card streams, then maintaining the ordering client-side using a mutation observer (which is how DHH recommends we do this: https://github.com/hotwired/turbo/issues/109#issuecomment-761538869).

We also use Turbo Frames for allowing the user to edit cards in a modal. We simply fire up a modal, render cards/_card within it, then swap out the whole content of the modal with cards/edit when the user hits the “Edit” button — which is just a request to users#edit. Then we do the reverse when the user submits the form (if successful). This works extremely well and was the first real “holy crap” moment we had when using Hotwire.

We also use a bunch of smaller components paired with streams to re-render parts of the app when required. We’ve run into some kinks when nesting frames but for the most part, it’s been relatively straightforward.

We’re running Sidekiq as our background worker and it seems to handle all the rendering jobs just fine. We’re keeping tabs on our Action Cable usage as we’re not sure how that will scale just yet.

View Components, Tailwind & Storybook

For our frontend CSS framework, we opted to go with Tailwind. We’ve used it on other projects and really saw the value of utility classes and were interested in using it to build out a design system. This primarily consisted of defining colours, typography (font sizes, kerning, etc) within the Tailwind config and then using that throughout the app. We also built a “design system” page which showcases all of the variants of different components, colours & typography available in the app, so designers & developers could collaborate and discuss the designs with something tangible.

We’ve paired our Tailwind setup with View Components which gives us a really nice way of encapsulating components with their Ruby code, styles & Stimulus controllers if required. This is additionally utilised with the View Component Storybook gem which gives us a super useful library of all our components which stakeholders can interact with.

We ran into an issue with this approach where we rendered Tailwind classes dynamically from Ruby, rather than specifying the entire class. This caused issues because Tailwind’s purge option wasn’t picking up on these, resulting in missing classes when run in production mode (luckily, we caught these before production as we use Heroku Review Apps). Our fix for this was to either:

  1. Define the classes manually in the Tailwind config’s purge.options.safelist parameter — this isn’t ideal as if the original HTML code changes to no longer require the class, it’s very likely that a developer will forget to remove it from the safelist. It’s additionally complex to work out if there’s another location that dynamically uses this class.
  2. Use the whole class name in the Ruby or ERB code — this is possible but can make the code less tidy.

For the majority of instances where this occurred, we opted to go with option 2. This is more explicit and makes it obvious what’s happening and removes the dependency on the Tailwind config. We’d like to use a magic comment to do this, even if we could just specify the classes in either the Ruby code (as comments) or in the ERB (also as comments) that would be much better. It may already be possible to do this, we haven’t explored it yet.

If you want to know more about my View Component + View Component Storybook setup, check out my previous post: https://finnian.io/blog/view-components-storybook-tailwind-the-holy-trinity/

Since we ran into this issue, we’ve upgraded to Tailwind v3. We’ve seen massive speed improvements in development cycles after upgrading from v2 to v3 — our webpack builds went from ~30s to ~5s ⚡️ and I was able to remove a bunch of stuff from the Tailwind config, nice!

Dealing with permissions

In most applications, it’s important to restrict what users can do and a common requirement is to prevent users interacting with other users' data. In our app, boards can be accessed by multiple users and those users can have ‘roles’, allowing the owner of the board to control who can access what.

In a standard MVC app, dealing with this is relatively straightforward as you can limit the output of each view to ensure no accidental leakage of data to the wrong user. You might have a /users/1 route, which gives you information about user #1 — user #2 should not be able to access this page.

In Rails, there’s a few different ways of building this kind of thing, but I’ve had really good experiences with Pundit. It provides a great API for writing “policies” which determine what objects are available to who and what actions can be performed on them. For instance, User #1 can edit themselves, but User #2 cannot edit User #1. You can take this as far as you want - we’re planning on implementing usage limits using these policies.

We have since implemented usage restrictions using policies and it’s working really well so far.

Your Rails controller might look something like this:

class UsersController < ApplicationController
  before_action :set_user, only: %i[edit update]

  def edit
    authorize @user, :edit?
  end

  def update
    authorize @user, :update?

    if @user.update(user_params)
      redirect_to edit_user_path(@user),
                  flash: { success: t('.success') }
    else
      render :edit, status: :unprocessable_entity
    end
  end

  private

  def set_user
    @user = User.find(params[:id])
  end

  def user_params
    params.require(:user).permit(:first_name, :last_name)
  end
end

We’re invoking Pundit by calling :edit? and :update? in the controller actions. We don’t actually need to specify :edit? here though, as Pundit will infer it from the name of the action (nice!). If the current user is allowed to perform the action, the controller code will execute. If not, we’ll get an error from Pundit which we can then turn into a 403.

Why am I dragging you through how an authorisation system works? Bear with me. When we’re rendering standard HTML in response to a request, all is well as we’re in the request lifecycle and know who the user is. However, if we’re triggering updates to a model and want to display those to a user, we can’t just broadcast it globally willy-nilly as any users who’re subscribed to the stream will receive the update. This can lead to cross-contamination of data between users, which is really not what you want.

In order to avoid this, you need to make sure you scope your Turbo streams to a specific context. For instance, we scope our column streams to the ID of the column, then render cards within it. Scoping the streams is safe as the stream IDs are tamper-resistant — they’re signed using MessageVerifier — so bad actors cannot subscribe themselves to another stream. Another example might be that you only broadcast messages intended for a specific user, to that user themselves. In that scenario, a parallel to the problem we encountered would be a group chat, where you want to send one message to multiple people, but only people in that group.

However, we still have to be careful as users cannot always access all the cards within a column. Usually we’d chuck a policy_scope in and render the view as normal, but as broadcasts from Turbo are rendered outside of the request context (and therefore don’t know anything about the current user), we can’t do this. So, we loop through all the users of the board, apply Pundit’s policy_scope for each one and then broadcast, so we never send the wrong cards to the wrong user when a change occurs.

This has a performance overhead as there’s now multiple broadcasts per change, but we haven’t yet come up with another solution for this problem. I’m going to tentatively name this conundrum “multi-tenant broadcasts”.

We also use our policies for not only restricting our controllers, but also deciding which actions/buttons are available in the UI itself, so users are never setup for failure. This has been extremely beneficial as we can display warnings, disable buttons, show other states etc instead of allowing the user to fail.

Whilst it takes a little bit to wrap your head around how this all works, we’ve been able to get some good mileage out of this solution. Number one takeaway: don’t use any globals in your partials - pass whatever they need in as locals so you can safely render them in different contexts. We’ve added the erb_lint gem to help us stay on top of this.

Wrapping custom JS libraries with Stimulus

Another feature of the app is being able to move cards between columns. This is entirely powered by Shopify’s Draggable JS library, wired up via Stimulus. When the user drops a card, we fire an AJAX request to Rails, which updates the model to indicate which column it’s in. Turbo handles the rest by broadcasting an update to that column, causing it to re-render itself across multiple users' boards. We do a similar thing for rearranging columns, where we’re using acts_as_list to keep things simple.

Allowing Stimulus to take control of when & where custom JS is attached is really nice. We find the various data-* attributes a little difficult to remember but it’s very light and we like the changes that’ve been made to Stimulus recently, especially for v3.

Rails 7

Rails 7 was released a few days ago, promising to remove much (or all) of the JS tooling for a standard Rails app, so I’m really keen to swap out our current webpacker configuration with import maps and see how we go. Then we’d only need Tailwind’s CLI, which we can use through the tailwindcss-rails gem. I think this would dramatically increase productivity but we need to be careful about browser support for importmaps.

Nags

A full breakdown of how we use Turbo in production would not be complete without some nags & nits.

The multi-tenant streaming issue

As mentioned above, we haven’t found a good way to perform a single broadcast to multiple users with different content. My gut feeling is that we shouldn’t even try to do this — they should be different streams. I haven’t been able to find anything on the web about how to approach this problem though, so I’m really keen to hear people’s thoughts on it (DM me on Twitter: @developius).

Scaling

We don’t have much traffic yet, so we don’t know too much about any scaling issues we might run into. However, I can say with good confidence that Sidekiq can process our background jobs in about 22ms on average, so monitoring this along with our queue latency should give us a good indicator of how well we’re doing.

I don’t have concerns about the app server itself as Heroku can handle this side of it for us.

The boards#show page is quite heavy at the minute, so that’s something we might look to optimise by lazy-loading the columns. Perhaps. Premature optimisation is a thing.

Documentation

The documentation for Turbo itself is a little sparse. The information is there if you read it properly, but I found it quite hard to digest initially until I understood the terminology well. Other members of my team found the same, especially around the use of terminology from Hey.com.

As it’s such a new technology, there’s also a limited amount of info on how to architect systems using Turbo, or how even to go about testing some of the functionality.

Some weird translation issue

Every once in a while, we’ll get a weird problem where the broadcast jobs can’t find their translations. The translations are there and work fine, but we get “missing translation: en.x.y” in the views. This happens both on production as well as for review apps, but never in development or test — both of which raise errors if the translations are missing, so I know it doesn’t happen in those environments.

I’ve messed around with the config.i18n settings for Rails without too much success, but it’s an elusive problem so it’s hard to be concrete about any of the fixes. My gut feeling is that there’s some kind of race condition or load order problem in Sidekiq or ActiveJob which causes the translations to not get loaded. If anyone has any ideas, please ping me on Twitter — I’d be really keen to hear any thoughts on what’s going wrong.

Checking if records can be created using Pundit

We often have the need decide whether a user can create a new record or not. They may have hit usage limits, or maybe they’re trying to do something they’re not supposed to. Either way, we have policies to cover this which is super useful.

However, sometimes we need to know the answer to this from the view. This is where it gets tricky, as we would normally instantiate a new model, then ask pundit if :create? can be performed upon it by the current user. The annoying thing is that by instantiating a new record, we attach it to its associations too as often the permissions rely on those relationships. This is a problem because after instantiating it, all those other records will “see” it in their own associations. This can very easily throw off things like showing the number of cards in a column and we have to be really careful not to call .save on those records, or our temporary one will attempt to save too.

# @hack: we need a Board to check if this works, but it'll get added to the Board
# instance, so we're destroying the temporary Group straight away
def group_creation_allowed_for_board?(board)
  temporary_group = Group.new(board: board)
  group_creation_allowed = policy(temporary_group).create?
  board.groups.destroy(temporary_group)
  group_creation_allowed
end

Our hack to work around this problem for now is to instantiate the model, check if we can create it, then immediately call .destroy so that we never save it and it can’t be counted by accident. We don’t like this solution, but haven’t found a good way around it and there’s nothing in Pundit’s docs to suggest another way. This isn’t related to Turbo or Hotwire, but it’s something we’ve had to content with. Again, if anyone has thoughts on this, get in touch!

Conclusion

So far, we’re enjoying the Hotwire/Turbo + Rails setup. There’s a bit of a learning curve, but once you iron out the kinks and develop some patterns in your application, it becomes quite intuitive. Our app does not have enough traffic for me to comment on how it performs at scale, but given HEY appears to be doing okay, I think we’ll be fine too.

I’d like to keep this article up-to-date with more information about Turbo as/when I get time and find interesting things to share.

Recent Posts