Skip to main content

Why You Should Not Do Slow Work Inside a Database Transaction

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

A row lock lasts until the transaction ends, not until the statement ends. So a payment call, an email or a file upload inside a transaction keeps that row locked the whole time, and everyone who wants it waits. I measured it: moving one slow call out took another request from 2.5 seconds to 0.013.

Prerequisites​

Step 1: Put Slow Work Inside​

This endpoint updates a product, and then does something slow before it commits. Task.Delay stands in for the slow call:

An endpoint
g.MapPost("/slow-inside/{id:int}", async (int id, InventoryDbContext db, ILogger<InventoryDbContext> log, CancellationToken cancellationToken) =>
{
var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(
async ct =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);

await db.Products.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ReorderLevel, p => p.ReorderLevel + 1), ct);

// A slow call to something else: a payment service, an email, a file upload.
await Task.Delay(3000, ct);

await transaction.CommitAsync(ct);
},
cancellationToken);

return Results.Ok();
});

The endpoint in a macOS-style code window, with BeginTransactionAsync, ExecuteUpdateAsync, a three-second Task.Delay labelled as a slow call, and then CommitAsync.

I sent one request, and half a second later a second one for the same product:

Output
first 200 3.019258s
second 200 2.502967s

The second request does almost nothing, and it took 2.5 seconds, because it waited for the first one's lock.

Step 2: Move It Outside​

An endpoint
g.MapPost("/slow-outside/{id:int}", async (int id, InventoryDbContext db, ILogger<InventoryDbContext> log, CancellationToken cancellationToken) =>
{
// The slow call comes first, with no transaction open.
await Task.Delay(3000, cancellationToken);

var strategy = db.Database.CreateExecutionStrategy();

await strategy.ExecuteAsync(
async ct =>
{
await using var transaction = await db.Database.BeginTransactionAsync(ct);

await db.Products.Where(p => p.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.ReorderLevel, p => p.ReorderLevel + 1), ct);

await transaction.CommitAsync(ct);
},
cancellationToken);

return Results.Ok();
});
Output
first 200 3.042505s
second 200 0.013594s

With the slow work inside the transaction, the second request took 2.50 seconds. With it outside, it took 0.013 seconds.

The first request takes the same 3 seconds, and the second doesn't wait at all.

Step 3: Add Up What It Costs​

Slow work insideSlow work outside
First request3.02 s3.04 s
Second request2.50 s0.013 s

And that's with one other request. With a popular product, they queue up, and each may reach the lock wait limit. The transaction also holds a connection the whole time, so the pool can run out.

Step 4: Know What Going Outside Costs​

Moving it out means the commit and the slow call are no longer one thing:

  • Do it after the commit, when the database is the source of truth. If the email fails, the order still exists, and you can send it again.
  • Do it before the transaction, when its result decides the write. Check the card, and then write.
  • For anything that must eventually happen, save a row saying so in the same transaction, and have a background job do the work. That's the outbox pattern.

And it mustn't be in a retried delegate at all, or a retry repeats it.

Step 5: Find the Ones You Already Have​

How to Find Long-Running Transactions in MySQL shows what's holding a transaction open right now. In the log, look for a long gap between Began transaction and Committed transaction.

Common Mistakes​

MistakeWhat happensDo this instead
An HTTP call inside a transactionLocks held for as long as it takes, or longer if it hangsMove it out
A user prompt inside a transactionLocks held until someone answersNever do this
Reading a lot of data before the first writeA SELECT holds no row lock, but the transaction stays openRead before beginning

Conclusion​

A transaction holds its locks until it ends, so whatever it waits for, everyone else waits for too. Keep only database work inside, and do the slow work before or after.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.