Background

Starting with Rails 7, the turbo-rails gem is automatically configured in a new Rails application. Among other things, Turbo excels at simplifying live updates to web pages. As an example, consider a chat application. Turbo reduces the amount of code needed to build real-time chat messages that are visible to each chat participant without refreshing the page.

To learn more about Turbo, I set out to create a simple timeline-based application. I wanted users to be able to submit messages and optionally upload images to a Timeline. Any submitted updates should appear live for other users viewing the Timeline. I chose ActiveStorage to store the image files, and turbo-rails to broadcast updates to the UI. As I wrote the application, I encountered some surprising behavior that sent me into the Rails and Turbo source code to learn how Rails callbacks work. To share my experience, I will walk through my initial, buggy implementation before arriving at a subtle fix.

The Buggy Implementation

First I had models to represent a Timeline and TimelineUpdate.

# app/models/timeline.rb

class Timeline < ApplicationRecord
  has_many :timeline_updates
end

# app/models/timeline_update.rb

class TimelineUpdate < ApplicationRecord
  belongs_to :timeline

  has_one_attached :image

  after_create_commit -> { broadcast_append_to(timeline, target: :timeline_updates) }
end

For those unfamiliar with Turbo and broadcasting changes, the broadcast_append_to method will publish a message using an ActionCable WebSocket. That message includes the HTML of the TimelineUpdate Rails view partial. The Turbo JavaScript framework automatically listens for messages like these and will append the published partial to the HTML element with the id specified as the target.

The view to show a Timeline and its TimelineUpdates included some header content, a list of the TimelineUpdates, and a form to create new TimelineUpdates. I used a partial to render each TimelineUpdate, the same partial that Turbo would broadcast. In erb the views looked something like this:

<!-- app/views/timelines/show.html.erb -->

<h1>
  Timeline <%= @timeline.id %>
</h1>

<div id="timeline_updates">
  <% timeline_updates.each do |timeline_update| %>
    <%= render timeline_update %>
  <% end %>
</div>

<%= turbo_frame_tag @timeline.timeline_updates.new do %>
  <%= form_with model: @timeline.timeline_updates.new do |form| %>
    <%= form.label :message %>
    <%= form.text_field :message %>
    <%= form.file_field :image %>
    <%= form.submit
  <% end %>
<% end %>
<!-- app/views/timeline_updates/_timeline_update.html.erb -->

<p>
  <%= timeline_update.message %>
</p>

<% if timeline_update.image.attached? %>
  <%= image_tag timeline_update.image.representation(resize_to_limit: [100, 100]).processed.url %>
<% end %>

I used Rails to generate the controllers and made no significant alterations.

Given those models and views, I expected a new TimelineUpdate to show up in the timeline_updates div whenever any user created one. When I started creating new TimelineUpdates without uploading any images, the application worked as expected. I could open up two browsers and see messages showing up in both users’ timelines. Although, whenever I would upload an image, I would encounter an ActiveStorage::FileNotFound error. The errors were not visible within the UI, but I could see them in the Rails development logs. The stack trace from the errors revealed that the error was occurring when Turbo rendered the partial for the newly created TimelineUpdate and attempted to generate the image_tag.

Where is the Bug?

At first, I thought maybe resizing the image in the TimelineUpdate partial was my issue. It made intuitive sense to me that any image processing may happen in a background process which may not finish before Turbo would broadcast the created TimelineUpdate; however, the call to processed.url ensures that the image variant is available. Furthermore, I tried removing the resizing from the partial, and the error persisted. The image processing was not causing the error.

The ActiveStorage::FileNotFound error included a key for the ActiveStorage::Blob that Rails failed to find. Locally, I was using the ActiveStorage Disk service, which stores files on the local filesystem. Sure enough, the file that Rails was looking for to generate a URL was not saved anywhere. My debugging was telling me that ActiveStorage had attached the image to the TimelineUpdate but had not uploaded the file itself. How could that happen?

I began to wonder, “When and how does ActiveStorage upload files?” Does it happen before_save, after_save, somewhere in-between somehow? It turns out that the file for an attachment gets uploaded in an after_commit callback. The callback gets defined when has_one_attached gets called in a model. For the Disk service, ActiveStorage will not save the file to the filesystem until that after_commit callback executes.

Recall that I was also calling broadcast_append_to in the TimelineUpdate model in an after_commit callback. The bug was starting to take shape. I needed ActiveStorage to upload the file before trying to generate a src for the image_tag, so I needed the ActiveStorage callback to run before the Turbo broadcast. Well, what determines the order of ActiveRecord callbacks when the callbacks are both defined as after_commit?

Callback Order

For before and around callbacks, the callbacks run in the same order that you define them. These callbacks:

class Model < ApplicationRecord
  before_create -> { puts "Callback 1" }
  before_create -> { puts "Callback 2" }
end

would print statements in this order:

Callback 1
Callback 2

According to the ActiveSupport source code, after callbacks run in reverse order. Meaning that for the following after callbacks:

class Model < ApplicationRecord
  after_commit -> { puts "Callback 1" }
  after_commit -> { puts "Callback 2" }
end

You would see the puts statements print in this order:

Callback 2
Callback 1

What is the Fix?

In the end, this was a long journey to a trivial fix. All that I had to do was switch the order of the has_one_attached call and the after_create_commit callback in the TimelineUpdate model. After that switch, everything “just works”. The broadcast_append_to callback gets defined first. Then the ActiveStorage callback gets defined by has_one_attached. The callbacks run in reverse order, so ActiveStorage uploads the file before Turbo broadcasts the UI update.

# Buggy

class TimelineUpdate < ApplicationRecord
  has_one_attached :file

  after_create_commit -> { broadcast_append_to(...) }
end

# Fixed

class TimelineUpdate < ApplicationRecord
  after_create_commit -> { broadcast_append_to(...) }

  has_one_attached :file
end

A Note on Direct Uploads

When using direct uploads with ActiveStorage, either order works. The direct upload stores the file before the form even gets submitted.

Conclusion

I rarely consider has_one_attached or other Rails DSLs as methods that do anything at runtime in an application. If the model raises no exceptions when it gets loaded, I tend to think that all the pieces will “just work”. That is empirically an unsafe assumption. Investigating this bug and diving into the ActiveStorage and ActiveSupport source code gave me a much better grasp of some tools that I use often. It’s important to understand the tools that we use; misunderstood tools tend to surprise you.