When two IIS requests update the same rows in opposite order, SQL Server aborts one session with error 1205. That victim request usually surfaces as a 500 to the browser even though the other request committed cleanly. The failure is not random flakiness in your host—it is a lock cycle, and web apps create them constantly around carts, inventory, profile writes, and any multi-statement transaction from EF Core or ADO.NET.

On SQL Server 2022 and SQL Server 2025 the engine behavior is the same for classic row-level deadlocks: one session is chosen as the victim, its transaction rolls back, and your app must retry the whole unit of work. Hoping the next deploy “makes locks better” is not a strategy. Retry at the data layer, keep transactions short, and remove the access-order bugs that manufacture cycles.

#Let EF Core retry 1205 for you

For ASP.NET Core apps on IIS, the cleanest default is the SQL Server provider’s execution strategy. EnableRetryOnFailure treats deadlock (and other transient SQL errors) as retryable. Pair it with .NET 10 / EF Core 10 on new work; the same pattern applies on earlier LTS lines still running in production.

csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sql =>
    {
        sql.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);
    }));

Two hosting-floor details matter. First, retries only wrap operations EF Core executes; if you open an explicit transaction yourself, you must run the full unit through IExecutionStrategy.ExecuteAsync so a mid-transaction 1205 restarts from the beginning, not from a half-applied state. Second, do not catch SqlException and swallow it higher in the MVC/minimal API pipeline after the strategy already exhausted retries—log and fail the request once, with a stable error id for support.

#ADO.NET and Dapper need an explicit loop

If the page still uses raw SqlConnection, Dapper, or a legacy DAL inside an IIS app pool, add a narrow retry around the transaction boundary. Only retry 1205 (and optionally a short list of true transients). Do not blindly retry unique-key violations or your own business errors.

csharp
async Task ExecuteWithDeadlockRetryAsync(Func<Task> action, int maxAttempts = 5)
{
    for (var attempt = 1; ; attempt++)
    {
        try
        {
            await action();
            return;
        }
        catch (SqlException ex) when (ex.Number == 1205 && attempt < maxAttempts)
        {
            await Task.Delay(TimeSpan.FromMilliseconds(50 * attempt * attempt));
        }
    }
}

Keep the callback idempotent at the business level: re-read the row versions you care about, re-check stock, and re-apply the write. A blind re-INSERT after a partial failure is how you get duplicate orders. Prefer one SqlTransaction (or TransactionScope with care) that covers the whole unit, then dispose connections so they return to the pool promptly under the IIS app pool identity.

#Reduce how often deadlocks happen

Retries hide pain; schema and T-SQL habits remove it. Most ASP.NET deadlocks I still see on Windows-hosted SQL are boring: two procedures touch TableA then TableB in different orders, or a read under the default isolation level blocks a writer long enough for a second writer to close the cycle.

  • Access tables in a fixed order across every stored procedure and EF transaction that can run concurrently.
  • Keep transactions short: no outbound HTTP, no blob uploads, no Razor rendering inside BeginTransaction.
  • Index the exact filter and join columns so writers take row locks instead of scanning large ranges.
  • Prefer READ COMMITTED SNAPSHOT (RCSI) on databases you control so ordinary reads stop taking shared locks that collide with writers.
  • Claim a row with a single UPDATE … OUTPUT (or SELECT with UPDLOCK, ROWLOCK in a tight transaction) instead of read-then-update round trips.

RCSI is a database option, not a connection-string switch. On a Windows VPS or dedicated SQL instance you manage, enable it in a maintenance window and measure tempdb version-store growth. On shared SQL hosting, ask whether RCSI is already on for your database before assuming every SELECT is lock-free.

tsql
-- Run during a quiet window; watch tempdb afterward.
ALTER DATABASE [YourAppDb] SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;

-- Confirm
SELECT name, is_read_committed_snapshot_on
FROM sys.databases
WHERE name = N'YourAppDb';

#When retries fire too often, capture the graph

If the app pool event log or your ILogger sink shows repeated 1205s after retry is enabled, stop guessing which statements collide. SQL Server already records deadlock graphs in the system_health extended events session on current builds. Pull recent graphs, identify the two resource lists, and fix ordering or indexing at the source.

tsql
SELECT TOP (20)
    xed.value('@timestamp', 'datetime2') AS deadlock_time,
    xed.query('(data/value/deadlock)[1]') AS deadlock_graph
FROM (
    SELECT CAST(target_data AS xml) AS target_data
    FROM sys.dm_xe_session_targets t
    JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
    WHERE s.name = N'system_health'
      AND t.target_name = N'ring_buffer'
) AS src
CROSS APPLY target_data.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS x(xed)
ORDER BY deadlock_time DESC;

Set Application Name in the SQL connection string used by each IIS site or app pool. Graphs and Query Store then show which site participated, which is far more useful than a generic .NET SqlClient entry when several apps share one SQL instance. Keep MAXDOP and heavy maintenance off peak request hours so a rebuild does not widen lock windows under live traffic.

Practical takeaway: treat 1205 as expected under concurrency, not as a once-a-year surprise. Turn on EF Core’s EnableRetryOnFailure (or a tight ADO.NET retry for 1205 only), never leave a transaction open across I/O outside SQL, enable RCSI where you control the database, and use system_health deadlock graphs when retries become frequent. That combination keeps ASP.NET apps on IIS stable on SQL Server 2022 and 2025 without turning every contention blip into a customer-visible 500.