---
title: Retrying EF Core concurrency conflicts with Polly
date: 2025-03-24
tags: dotnet, ef-core, polly, resilience
summary: A small helper that re-runs a unit of work on DbUpdateConcurrencyException, and the one detail that makes the retries useless if you get it wrong.
---

With [concurrency tokens](/p/2025-02-25-ef-core-concurrency-temporal-tables/) in place, a conflicting save throws a `DbUpdateConcurrencyException`. For a form the user filled in, that's what you want: show a message and let them reload.

For work the code does on its own, like recalculating an account balance, the right answer is usually simpler. Load the fresh data and do it again. Polly can do the "again" part.

## The helper

Services in my project get an `IDbContextFactory<T>`, not a scoped context. So the helper is an extension method on the factory:

```csharp title="DbContextFactoryExtensions.cs"
public static class DbContextFactoryExtensions
{
    private static readonly ResiliencePipeline _retry = new ResiliencePipelineBuilder()
        .AddRetry(new RetryStrategyOptions
        {
            ShouldHandle = new PredicateBuilder().Handle<DbUpdateConcurrencyException>(),
            BackoffType = DelayBackoffType.Exponential,
            MaxDelay = TimeSpan.FromSeconds(3),
            MaxRetryAttempts = 5,
        })
        .Build();

    public static async Task<TResult> RetryOnConcurrencyException<TContext, TResult>(
        this IDbContextFactory<TContext> factory,
        Func<TContext, Task<TResult>> unitOfWork,
        CancellationToken ct = default) where TContext : DbContext
    {
        await using var db = await factory.CreateDbContextAsync(ct);
        return await _retry.ExecuteAsync(async _ => await unitOfWork(db), ct);
    }
}
```

And the call site:

```csharp title="AccountService.cs"
var balance = await factory.RetryOnConcurrencyException(async db =>
{
    var account = await db.Accounts.FirstAsync(a => a.Id == id);
    account.Balance = await CalculateBalance(db, id);
    await db.SaveChangesAsync();
    return account.Balance;
});
```

It looks right. It isn't.

## The trap: one context for all attempts

Look at where the context is created: once, **outside** the retry. Here is what happens on a conflict:

1. Attempt 1 loads the account with `RowVersion` A. Someone else saves, so the row now has `RowVersion` B. `SaveChangesAsync` throws.
2. Attempt 2 runs `FirstAsync` again, on the same context. The account is already tracked, so EF returns the tracked instance and keeps its values. It doesn't overwrite them with what came back from the database. The `RowVersion` is still A.
3. `SaveChangesAsync` sends `WHERE RowVersion = A` again, and it fails again.

Five retries, five times the same failure, and a few seconds of backoff on top.

> TIL: a retry on a concurrency conflict only helps if the retry sees fresh data. A context that still tracks the stale entity will fail the same way every time.

## The fix

Create the context **inside** the retry, so every attempt starts with an empty change tracker:

```csharp title="DbContextFactoryExtensions.cs"
public static async Task<TResult> RetryOnConcurrencyException<TContext, TResult>(
    this IDbContextFactory<TContext> factory,
    Func<TContext, Task<TResult>> unitOfWork,
    CancellationToken ct = default) where TContext : DbContext
{
    return await _retry.ExecuteAsync(async token =>
    {
        await using var db = await factory.CreateDbContextAsync(token);
        return await unitOfWork(db);
    }, ct);
}
```

If you have to keep one context, call `db.ChangeTracker.Clear()` at the start of each attempt. It has the same effect, but it's easier to forget.

## Things to keep in mind

- **The whole unit of work runs again.** Keep it to database work. If the lambda sends an email or calls an API, that happens on every attempt.
- **Don't use it for user input.** If two people edited the same form, retrying just overwrites one of them. Let the user decide.
- **Add jitter if many callers conflict at once.** `UseJitter = true` on the retry options spreads out the attempts, so they don't collide again on the same tick.
- **It's separate from the Aspire retry.** Aspire's execution strategy retries transient SQL errors, like a dropped connection. It doesn't handle concurrency conflicts. You need both.
