Spot the Tax · Card 2 of 20
External I/O belongs outside the transaction
Why an HTTP call inside before_save slows down every save and can leave your data in a broken state.
The code
What will this cost you in six months?
class User < ApplicationRecord
before_save :sync_to_mailchimp
private
def sync_to_mailchimp
Mailchimp::Subscribers.upsert(
email: email,
merge_fields: { name: name }
)
end
end The problem
Every time a user gets saved, the save now has to wait for Mailchimp to respond. That waiting happens inside the database transaction Rails opens for the save, which means the transaction stays open the whole time the API call is in flight. If Mailchimp is slow, every save gets slow with it; if Mailchimp is down, your saves fail entirely. And if anything later in the transaction causes it to roll back, the user save gets undone — but the Mailchimp call already went out, so you end up with a user that exists in Mailchimp but doesn't exist in your database.
Take a moment. Before revealing, try to work out the fix yourself. Where should the Mailchimp call live instead, and how do you want to handle a Mailchimp outage?
The solution
Pull the Mailchimp call out of the model's lifecycle entirely. Once the controller has finished saving the user, enqueue a background job that handles the Mailchimp sync separately. That way the save no longer depends on Mailchimp being up, and a slow API response can't slow down your signups.
- A Mailchimp outage doesn't break user signups
- The job retries on failure, the save doesn't
- Database transactions stay short
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
MailchimpSyncJob.perform_later(@user.id)
redirect_to @user
else
render :new
end
end
end The principle at play — Transaction scope
Rails wraps every save in a database transaction, which is essentially a deal with the database: do all of these writes together, and if anything fails, undo all of them. While the transaction is open, the database is also holding locks on the rows being changed, so any other request that wants to touch those same rows has to wait its turn.
When your before_save calls Mailchimp and Mailchimp takes two seconds to respond, the transaction stays open for those two seconds and other requests start stacking up behind it. On top of that, if anything later in the transaction causes a rollback, the user save gets undone — but the Mailchimp call already happened, and HTTP doesn't have a rollback. You're left with state in Mailchimp that has no equivalent in your database.
That's why external calls belong after the transaction commits. The "all or nothing" guarantee only covers things the database can control, which means database writes. As soon as you put an HTTP call inside the transaction, you create a situation where your database can roll back but the outside world cannot.