Don't Smoke Your Database With This Common Mistake
Third-Party API Calls Inside Database Transactions
It’s an easy mistake to make, and an intuitive one. You’re building a command that does a few things: call Stripe a couple of times, get back payment objects, and write the results to the database. You want the whole thing to be atomic — if anything fails, nothing should be persisted. So you wrap everything in a transaction. Done, right?
Not quite. This pattern — third-party API calls inside database transactions — is one of those things that looks correct on paper but causes real pain in production. I’ve seen it take down services at exactly the wrong times. Let me explain why, and what to do instead.
The Intuitive Approach
Imagine you’re scheduling a payment. The operation requires three calls to Stripe: create a customer, attach a payment method, and schedule a charge. After each call you get back an object and you want to persist it. Something like this:
def call
ActiveRecord::Base.transaction do
customer = Stripe::Customer.create(email: user.email)
db_customer = StripeCustomer.create!(stripe_id: customer.id, user: user)
payment_method = Stripe::PaymentMethod.attach(
payment_method_id,
customer: customer.id
)
db_payment_method = StripePaymentMethod.create!(
stripe_id: payment_method.id,
stripe_customer: db_customer
)
charge = Stripe::PaymentIntent.create(
amount: amount_in_cents,
currency: 'usd',
customer: customer.id,
payment_method: payment_method.id
)
StripeCharge.create!(stripe_id: charge.id, stripe_payment_method: db_payment_method)
end
end
The appeal here is obvious. If the third Stripe::PaymentIntent.create call blows up, the transaction rolls back and none of those partial records land in the database. Clean and atomic.
The problem is what you’re doing to your database in the meantime.
The Latency Mismatch Problem
A database transaction is not a free operation. Depending on your isolation level, it holds locks — at minimum on the rows it has written to, potentially on the pages or tables those rows live in. Those locks are held for the duration of the transaction.
On a well-provisioned system — say, two services on the same AWS availability zone — a straightforward database operation (a simple SELECT, INSERT, or UPDATE) should complete in well under 10 milliseconds. A batch of several such operations inside a single transaction? Still probably under 10ms total.
Stripe API calls, on the other hand, regularly take 500–700ms. Sometimes more. There’s no amount of infrastructure tuning you can do on your end to change that. You’re at the mercy of Stripe’s servers and the public internet.
So what you’ve actually built is a transaction that holds database locks for the better part of a second, per call, times however many Stripe calls you need to make. Three Stripe calls? You’re looking at a transaction that holds locks for potentially 2 seconds or more. And if you have any concurrency at all — multiple workers, background jobs, web requests — those locks start competing.
What This Looks Like in Production
At Mudflap, we had a background job that ran every morning to update fuel prices and validate a large number of cached objects. This job made multiple third-party API calls and also needed to commit a significant number of database updates. At some point, someone had wired this up with the API calls living inside transactions.
During the morning run, we started seeing cascading database performance issues. Connections were getting held. Queries were backing up. The combination of high-concurrency writes and long-running transactions caused severe lock contention — and the morning price update, which customers depend on at the start of their day, was the worst possible time for it.
The fix was straightforward once we diagnosed the problem: pull the API calls out of the transactions entirely.
The Pre-Transaction Pattern
The solution is to restructure your command object so that all third-party API calls happen in a dedicated pre-transaction step. The results are stored as instance variables on the command object. Then, in a separate step, you open the transaction and do nothing inside it except write to the database.
Here’s what that looks like:
class SchedulePaymentCommand
def call
fetch_stripe_objects # pre-transaction step: all API calls happen here
persist_to_database # transaction step: DB writes only, no network I/O
end
private
def fetch_stripe_objects
@stripe_customer = Stripe::Customer.create(email: user.email)
@stripe_payment_method = Stripe::PaymentMethod.attach(
payment_method_id,
customer: @stripe_customer.id
)
@stripe_charge = Stripe::PaymentIntent.create(
amount: amount_in_cents,
currency: 'usd',
customer: @stripe_customer.id,
payment_method: @stripe_payment_method.id
)
end
def persist_to_database
ActiveRecord::Base.transaction do
db_customer = StripeCustomer.create!(
stripe_id: @stripe_customer.id,
user: user
)
db_payment_method = StripePaymentMethod.create!(
stripe_id: @stripe_payment_method.id,
stripe_customer: db_customer
)
StripeCharge.create!(
stripe_id: @stripe_charge.id,
stripe_payment_method: db_payment_method
)
end
end
end
The transaction is now doing only what it should: coordinating a set of fast, local database writes. Lock hold time drops from ~1.5 seconds to under 10ms.
“But What About Atomicity?”
This is the obvious objection. In the original pattern, if anything fails, the transaction rolls back. With the pre-transaction pattern, what happens if the API calls all succeed but then the database write fails?
It’s a fair concern, but it’s also fully solvable — and handling it explicitly is actually better than relying on a transaction rollback, because a rollback doesn’t undo what already happened on Stripe’s end anyway. If you made three Stripe API calls before the DB write failed, rolling back your database changes leaves you with orphaned Stripe objects. The transaction gives you a false sense of safety.
The right pattern is a structured error handling routine on the command object — something that has a specific job: clean up whatever API-side state was created if the overall operation fails. Since the Stripe responses are stored in instance variables, the error handler still has access to them:
class SchedulePaymentCommand
def call
fetch_stripe_objects
persist_to_database
rescue => e
handle_error(e)
raise
end
private
def fetch_stripe_objects
@stripe_customer = Stripe::Customer.create(email: user.email)
@stripe_payment_method = Stripe::PaymentMethod.attach(
payment_method_id,
customer: @stripe_customer.id
)
@stripe_charge = Stripe::PaymentIntent.create(
amount: amount_in_cents,
currency: 'usd',
customer: @stripe_customer.id,
payment_method: @stripe_payment_method.id
)
end
def persist_to_database
ActiveRecord::Base.transaction do
db_customer = StripeCustomer.create!(
stripe_id: @stripe_customer.id,
user: user
)
db_payment_method = StripePaymentMethod.create!(
stripe_id: @stripe_payment_method.id,
stripe_customer: db_customer
)
StripeCharge.create!(
stripe_id: @stripe_charge.id,
stripe_payment_method: db_payment_method
)
end
end
def handle_error(error)
# Cancel whatever was created on Stripe's end, if anything
Stripe::PaymentIntent.cancel(@stripe_charge.id) if @stripe_charge
Stripe::PaymentMethod.detach(@stripe_payment_method.id) if @stripe_payment_method
Stripe::Customer.delete(@stripe_customer.id) if @stripe_customer
Rails.logger.error("SchedulePaymentCommand failed: #{error.message}")
end
end
The key insight: handle_error is a method designed to be overridden in subclasses. It’s not a catch-all — it’s the specific, intentional cleanup routine for this command. Each command knows what it created and knows how to undo it. That’s a cleaner contract than hoping a database rollback somehow undoes side effects that already happened in an external system.
The Rule
Here’s how I think about it now: a database transaction should contain only database operations. Any call that goes over a network — to Stripe, to a payment processor, to any external service — should live outside the transaction. Make all your external calls first, validate that the results look sane, then commit everything to the database in a fast, tight transaction. Handle failures explicitly with cleanup logic that knows what it actually needs to undo.
Transactions are a powerful primitive. They’re designed for coordinating local, fast, reliable operations. Stretching them to cover slow, unreliable network calls doesn’t make your system more correct — it just makes it slower and more fragile at the same time.

