Mert Özen Further With Every Line
Software Design Solution Patterns — #20

Optimistic Concurrency End to End: From rowversion to ETag, If-Match, 412 and a Conflict UI

Mert Özen Nov 7, 2026 15 min 3 views
Optimistic Concurrency End to End: From rowversion to ETag, If-Match, 412 and a Conflict UI

Building optimistic concurrency end to end on the web means exposing rowversion as an ETag, requiring If-Match on updates (428 otherwise), returning 412 with the current values on a mismatch and doing a field-level three-way merge on the client. As a follow-up to the EF Core Concurrency post, this post shows the HTTP and Angular side with real output.

Version note: The C# code in this post was tested with .NET 10 and EF Core 10, and the SQL examples with SQL Server 2025.

Guess First: rowversion Is in the Model, But Who Uses It?

In HR Portal the Employee entity has a rowversion column; the team read "Concurrency in EF Core: Tokens, rowversion and DbUpdateConcurrencyException" in the EF Core series and added it from day one:

public class Employee
{
    public int Id { get; set; }
    public int CompanyId { get; set; }
    public string FullName { get; set; } = "";
    public string Title { get; set; } = "";
    public string Phone { get; set; } = "";
    public int DepartmentId { get; set; }
    public byte[] Version { get; set; } = [];
}

// OnModelCreating: a rowversion column in SQL Server
modelBuilder.Entity<Employee>().Property(e => e.Version).IsRowVersion();

The endpoint that updates the employee card is a plain PUT:

app.MapPut("/naive/employees/{id:int}", async (int id, UpdateEmployeeRequest request,
    HrPortalDbContext db, CancellationToken ct) =>
{
    Employee? employee = await db.Employees.SingleOrDefaultAsync(e => e.Id == id, ct);
    if (employee is null)
    {
        return Results.NotFound();
    }

    employee.FullName = request.FullName;
    employee.Title = request.Title;
    employee.Phone = request.Phone;
    employee.DepartmentId = request.DepartmentId;
    await db.SaveChangesAsync(ct);
    return Results.Ok(EmployeeDto.From(employee));
});

Two HR specialists, Alice and Bob, open Grace Hopper's card at the same time. Bob changes the title to "Senior Payroll Specialist" and saves. A few minutes later Alice, still looking at the old card, changes only the phone number and saves:

(EmployeeDto a, _) = await Get("Alice");
(EmployeeDto b, _) = await Get("Bob");
await client.PutAsJsonAsync("/naive/employees/1", b.ToRequest() with { Title = "Senior Payroll Specialist" });
await client.PutAsJsonAsync("/naive/employees/1", a.ToRequest() with { Phone = "+90 212 555 0199" });
EmployeeDto final = (await client.GetFromJsonAsync<EmployeeDto>("/employees/1"))!;
Console.WriteLine($"Naive result: {final.Title} / {final.Phone}");

The rowversion is there. Does Alice's save throw DbUpdateConcurrencyException? If it doesn't, which title stays on the card?

Context and Problem: The EF Side Is Solved, the HTTP Side Is Empty

The EF Core mechanics of optimistic concurrency are well known: the rowversion column changes on every update, EF Core adds WHERE Version = @original to the UPDATE statement, and if zero rows are affected it throws DbUpdateConcurrencyException. This post is not about those mechanics; it is about how they work end to end in a web application.

On the web there are minutes, sometimes hours, between "read" and "write". The user opens the form with GET, gets a coffee, comes back and sends it with PUT. These two requests run in different DbContext instances, even on different servers. If you read the record again in the PUT request, the Version EF Core knows as the original value is the one just read; it never notices the change in between. If the concurrency token does not carry the version the client saw, the rowversion in the model is just decoration.

HTTP solved this problem long before EF Core: conditional requests in RFC 9110. The server returns an ETag for each representation; when updating, the client says "apply this only if the resource is still at this version" with If-Match; if the version has changed, the server returns 412 Precondition Failed. RFC 6585 defines 428 Precondition Required for exactly this purpose: to reject unconditional requests in order to avoid the lost update problem.

Symptoms

Users complaining "I saved but my change is gone", especially on screens where several people edit the same record: the employee card, a purchase request, supplier details. Two updates a few minutes apart in the audit records, the second one setting a field the first one changed back to its old value. A concurrency token in the model but no DbUpdateConcurrencyException in the logs, ever. Update endpoints in the API that take the whole record, with neither a Version field in the DTO nor an If-Match check.

The Naive Solution and Why It Breaks

The real output of the opening flow:

Alice GET: 200 ETag: "AAAAAAAAB9E="
Bob GET: 200 ETag: "AAAAAAAAB9E="
Naive result: Payroll Specialist / +90 212 555 0199

No exception. Bob's title change was silently lost: the card says "Payroll Specialist" again, because Alice's form sent all the fields back, including the old title. Because the endpoint read the record again, the original Version EF Core held was the current one and the WHERE Version = @original condition was satisfied. This is last write wins, and it leaves no trace. Bob will only notice a few days later when he sees the old title on the payroll.

Options

Last Write Wins

Doing no check at all. Acceptable for records edited by a single person (a user's own profile settings, for example). For business records edited by several people it means data loss.

Carrying the Version in the Body

Adding a Version field to the DTO and passing it as the original value in PUT. This is the approach from the "The API Scenario: The Token Comes from the Client" section of the EF Core Concurrency post. It works and it is simple; its limit is that the contract lives outside the HTTP standard. Caches, proxies, conditional GET with If-None-Match and generic HTTP tooling do not know this field; every endpoint invents its own field name and its own error code (usually 409).

ETag and If-Match

Carrying the version with HTTP's own mechanism: ETag in the GET response, If-Match on update, 412 on a mismatch, 428 if the header is missing altogether. The same ETag also saves bandwidth on a conditional GET (If-None-Match → 304). This is the option we choose.

Pessimistic Lock (Check-out)

Locking the record for the user who opens it for editing: LockedBy and LockedUntil columns; when someone else tries to open it, a "Bob is editing right now" message. This is the Pessimistic Offline Lock from Fowler's PoEAA catalog. It prevents the conflict before it happens, but brings its own problems: users who close the tab without releasing the lock, expiring locks and a "break the lock" permission.

Implementation: From rowversion to ETag

Producing an ETag and Reading If-Match

rowversion is an 8-byte value that increases across the whole database. Converting it to Base64 and wrapping it in quotes produces a valid strong ETag (the opaque-tag definition in RFC 9110 is a quoted string). On the If-Match side we distinguish four cases:

using Microsoft.Net.Http.Headers;

namespace HrPortal.Api.Concurrency;

public static class ETags
{
    public static string From(byte[] version) => $"\"{Convert.ToBase64String(version)}\"";

    // If-Match: absent -> Missing, "*" -> Any, one strong ETag -> Version, anything else -> Invalid
    public static IfMatch ParseIfMatch(HttpRequest request)
    {
        IList<EntityTagHeaderValue> tags = request.GetTypedHeaders().IfMatch;
        if (tags.Count == 0)
        {
            return IfMatch.Missing;
        }
        if (tags.Count == 1 && tags[0].Equals(EntityTagHeaderValue.Any))
        {
            return IfMatch.Any;
        }
        if (tags.Count == 1 && !tags[0].IsWeak && TryDecode(tags[0].Tag.Value, out byte[] version))
        {
            return new IfMatch(IfMatchKind.Version, version);
        }
        return IfMatch.Invalid;
    }

    private static bool TryDecode(string? quoted, out byte[] version)
    {
        version = [];
        Span<byte> buffer = stackalloc byte[8];
        if (quoted is null || quoted.Length < 2 || !Convert.TryFromBase64String(quoted[1..^1], buffer, out int written) || written != 8)
        {
            return false;
        }
        version = buffer.ToArray();
        return true;
    }
}

public enum IfMatchKind { Missing, Any, Version, Invalid }

public sealed record IfMatch(IfMatchKind Kind, byte[]? Version = null)
{
    public static readonly IfMatch Missing = new(IfMatchKind.Missing);
    public static readonly IfMatch Any = new(IfMatchKind.Any);
    public static readonly IfMatch Invalid = new(IfMatchKind.Invalid);
}

We deliberately treat weak ETags (W/"...") as Invalid. RFC 9110 requires strong comparison when evaluating If-Match; a weak ETag means "semantically equivalent" and cannot be the basis of an update. * means "if the resource exists"; no version check is done in that case.

GET: The ETag Header and 304

app.MapGet("/employees/{id:int}", async (int id, HttpContext http, HrPortalDbContext db, CancellationToken ct) =>
{
    Employee? employee = await db.Employees.AsNoTracking().SingleOrDefaultAsync(e => e.Id == id, ct);
    if (employee is null)
    {
        return Results.NotFound();
    }

    string etag = ETags.From(employee.Version);
    if (http.Request.Headers.IfNoneMatch.ToString() == etag)
    {
        return Results.StatusCode(StatusCodes.Status304NotModified);
    }

    http.Response.Headers.ETag = etag;
    return Results.Ok(EmployeeDto.From(employee));
});

If a request comes with If-None-Match carrying the same version, no body is sent. For simplicity we compare a single ETag as a string here; RFC 9110 also defines weak comparison and a comma-separated list of ETags for If-None-Match. One thing to watch: Version is only the version of the Employees row. If you add a field from another table to the DTO, such as the department name, the ETag does not change when the department is renamed, and a 304 confirms a stale representation. For conditional GET the ETag has to be derived from the whole representation (for example from the versions of the related rows); for If-Match, the version of the updated row is enough.

PUT: 428, 412 and the Current Values

app.MapPut("/employees/{id:int}", async (int id, UpdateEmployeeRequest request, HttpContext http,
    HrPortalDbContext db, CancellationToken ct) =>
{
    IfMatch ifMatch = ETags.ParseIfMatch(http.Request);
    if (ifMatch.Kind == IfMatchKind.Missing)
    {
        return Results.Problem(statusCode: StatusCodes.Status428PreconditionRequired,
            title: "If-Match header is required",
            detail: "GET the employee first and send its ETag in If-Match.");
    }

    Employee? employee = await db.Employees.SingleOrDefaultAsync(e => e.Id == id, ct);
    if (employee is null)
    {
        return Results.NotFound();
    }

    if (ifMatch.Kind == IfMatchKind.Invalid)
    {
        return PreconditionFailed(employee);
    }
    if (ifMatch.Kind == IfMatchKind.Version)
    {
        // the version the client saw becomes UPDATE ... WHERE Version = @original
        db.Entry(employee).Property(e => e.Version).OriginalValue = ifMatch.Version!;
    }

    employee.FullName = request.FullName;
    employee.Title = request.Title;
    employee.Phone = request.Phone;
    employee.DepartmentId = request.DepartmentId;

    try
    {
        await db.SaveChangesAsync(ct);
    }
    catch (DbUpdateConcurrencyException ex)
    {
        EntityEntry entry = ex.Entries.Single();
        PropertyValues? databaseValues = await entry.GetDatabaseValuesAsync(ct);
        if (databaseValues is null)
        {
            return Results.NotFound();
        }
        entry.OriginalValues.SetValues(databaseValues);
        entry.CurrentValues.SetValues(databaseValues);
        return PreconditionFailed((Employee)entry.Entity);
    }

    http.Response.Headers.ETag = ETags.From(employee.Version);
    return Results.Ok(EmployeeDto.From(employee));
});

static IResult PreconditionFailed(Employee current) =>
    Results.Problem(statusCode: StatusCodes.Status412PreconditionFailed,
        title: "The employee was changed by someone else",
        detail: "Compare your changes with the current values and retry with the new ETag.",
        extensions: new Dictionary<string, object?>
        {
            ["currentETag"] = ETags.From(current.Version),
            ["current"] = EmployeeDto.From(current)
        });

The flow is: no If-Match means 428 and a ProblemDetails explaining what to do. If the ETag cannot be read (weak, more than one, malformed), 412. If it can be read, the version the client saw is passed to EF Core as the original value; from here on the database performs the conflict check. When returning 412 on a conflict we add two extensions to the ProblemDetails: the current ETag and the current values. The client can build the comparison screen without a second GET. For ProblemDetails itself see "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails" in the ASP.NET Core Web API series.

Why 412 and not 409? 409 Conflict says the request conflicts with the current state of the resource and is a general code. 412 says something more precise: the precondition the client set (If-Match) was not met. A client seeing this code can say "my data is stale, read again"; it has no other meaning.

The Same Scenario, This Time with ETags

(EmployeeDto aliceView, string aliceETag) = await Get("Alice");
(EmployeeDto bobView, string bobETag) = await Get("Bob");

await Put("Bob", bobETag, bobView.ToRequest() with { Title = "Senior Payroll Specialist" });
await Put("Alice", aliceETag, aliceView.ToRequest() with { Phone = "+90 212 555 0199" });
await Put("Alice", null, aliceView.ToRequest() with { Phone = "+90 212 555 0199" });
await Put("Alice", "W/" + aliceETag, aliceView.ToRequest() with { Phone = "+90 212 555 0199" });
Alice GET: 200 ETag: "AAAAAAAAB9Q="
Bob GET: 200 ETag: "AAAAAAAAB9Q="
Bob PUT: 200 ETag: "AAAAAAAAB9U=" {"id":1,"fullName":"Grace Hopper","title":"Senior Payroll Specialist","phone":"+90 212 555 0101","departmentId":4}
Alice PUT: 412 ETag:  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.13","title":"The employee was changed by someone else","status":412,"detail":"Compare your changes with the current values and retry with the new ETag.","currentETag":"\"AAAAAAAAB9U=\"","current":{"id":1,"fullName":"Grace Hopper","title":"Senior Payroll Specialist","phone":"+90 212 555 0101","departmentId":4},"traceId":"0HNPSVGRKCPGB:0000000A"}
Alice PUT: 428 ETag:  {"title":"If-Match header is required","status":428,"detail":"GET the employee first and send its ETag in If-Match.","traceId":"0HNPSVGRKCPGB:0000000B"}
Alice PUT: 412 ETag:  {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.13","title":"The employee was changed by someone else","status":412,"detail":"Compare your changes with the current values and retry with the new ETag.","currentETag":"\"AAAAAAAAB9U=\"","current":{"id":1,"fullName":"Grace Hopper","title":"Senior Payroll Specialist","phone":"+90 212 555 0101","departmentId":4},"traceId":"0HNPSVGRKCPGB:0000000C"}

Bob's PUT moved the ETag from B9Q to B9U. Alice's request with the old ETag got 412; the body carries Bob's title and the new ETag. The request without If-Match got 428, the one with a weak ETag got 412. When Alice first does a conditional GET and then saves with the current ETag:

Alice GET If-None-Match old: 200
Alice GET: 200 ETag: "AAAAAAAAB9U="
Alice GET If-None-Match current: 304
Alice PUT: 200 ETag: "AAAAAAAAB9Y=" {"id":1,"fullName":"Grace Hopper","title":"Senior Payroll Specialist","phone":"+90 212 555 0199","departmentId":4}

If-None-Match with the old ETag returned 200 and the full body, with the current ETag 304. Alice's last PUT produced a record containing both Bob's title and her own phone number.

CORS: Exposing the ETag Header

If the Angular application runs on a different origin from the API, the browser hides from JavaScript every response header except the few CORS considers safe; ETag is not on that list. If the header is not explicitly exposed, res.headers.get('ETag') returns null and the whole design silently collapses on the client:

builder.Services.AddCors(o => o.AddPolicy("portal", p => p
    .WithOrigins("https://portal.example.com")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .WithExposedHeaders("ETag")));

For CORS details see "CORS and Security in ASP.NET Core Web API: HTTPS, HSTS, Headers and Secrets".

Angular: A Three-Way Merge

A client that receives 412 holds three versions: the base from when the form was opened, what the user changed (mine) and the current value on the server (theirs). The field-level three-way merge rule is simple: if only I changed it, mine; if only they changed it, theirs; if we both changed it to different values, a conflict.

export interface MergeResult<T> {
  merged: T;
  conflicts: (keyof T)[];
}

export function threeWayMerge<T extends object>(base: T, mine: T, theirs: T): MergeResult<T> {
  const merged = { ...theirs };
  const conflicts: (keyof T)[] = [];
  for (const field of Object.keys(base) as (keyof T)[]) {
    const iChanged = mine[field] !== base[field];
    const theyChanged = theirs[field] !== base[field];
    if (iChanged && !theyChanged) {
      merged[field] = mine[field];
    } else if (iChanged && theyChanged && mine[field] !== theirs[field]) {
      conflicts.push(field);
    }
  }
  return { merged, conflicts };
}

const base = { fullName: "Grace Hopper", title: "Payroll Specialist", phone: "+90 212 555 0101", departmentId: 4 };
const mine = { ...base, phone: "+90 212 555 0199" };
const theirs = { ...base, title: "Senior Payroll Specialist" };
console.log(threeWayMerge(base, mine, theirs));
console.log(threeWayMerge(base, { ...mine, title: "Payroll Lead" }, theirs));
{
  merged: {
    fullName: 'Grace Hopper',
    title: 'Senior Payroll Specialist',
    phone: '+90 212 555 0199',
    departmentId: 4
  },
  conflicts: []
}
{
  merged: {
    fullName: 'Grace Hopper',
    title: 'Senior Payroll Specialist',
    phone: '+90 212 555 0199',
    departmentId: 4
  },
  conflicts: [ 'title' ]
}

In the first scenario Alice changed the phone and Bob the title; there is no conflict and the merged result contains both. In the second both changed the title to different values; title is marked as a conflict and the user should be shown a "your value / current value" choice. The service and the editor:

import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { Observable, map } from 'rxjs';
import { threeWayMerge } from './merge.js';

export interface EmployeeChanges { fullName: string; title: string; phone: string; departmentId: number; }
export interface Employee extends EmployeeChanges { id: number; }
export interface Versioned<T> { value: T; etag: string; }
export interface ConflictProblem { title: string; currentETag: string; current: Employee; }

const versioned = (res: HttpResponse<Employee>): Versioned<Employee> =>
  ({ value: res.body!, etag: res.headers.get('ETag')! });

@Injectable({ providedIn: 'root' })
export class EmployeeApi {
  private readonly http = inject(HttpClient);

  load(id: number): Observable<Versioned<Employee>> {
    return this.http.get<Employee>(`/api/employees/${id}`, { observe: 'response' }).pipe(map(versioned));
  }

  save(id: number, changes: EmployeeChanges, etag: string): Observable<Versioned<Employee>> {
    return this.http
      .put<Employee>(`/api/employees/${id}`, changes, { observe: 'response', headers: { 'If-Match': etag } })
      .pipe(map(versioned));
  }
}

export class EmployeeEditor {
  private readonly api = inject(EmployeeApi);
  base!: Versioned<Employee>; // the version at the moment the form was opened
  readonly conflicts = signal<(keyof EmployeeChanges)[]>([]);

  save(mine: EmployeeChanges, onMerged: (merged: EmployeeChanges) => void): void {
    this.api.save(this.base.value.id, mine, this.base.etag).subscribe({
      next: saved => (this.base = saved),
      error: (e: HttpErrorResponse) => {
        if (e.status !== 412) {
          throw e;
        }
        const problem = e.error as ConflictProblem;
        const { id, ...baseChanges } = this.base.value;
        const { id: _, ...theirs } = problem.current;
        const result = threeWayMerge<EmployeeChanges>(baseChanges, mine, theirs);
        // new base: the current version on the server; the user reviews the merged form and saves again
        this.base = { value: problem.current, etag: problem.currentETag };
        this.conflicts.set(result.conflicts);
        onMerged(result.merged);
      }
    });
  }
}

We do not save the merged values automatically. The user sees the form again with the conflicting fields marked and saves with the new ETag. Saving automatically is tempting, but it means approving on the user's behalf a value they never saw.

Trade-off Table

CriterionLast write winsVersion in bodyETag + If-MatchPessimistic lock
Data integritySilent data lossCompleteCompleteComplete (while locked)
Query complexityNoneOriginalValue assignmentOriginalValue + header parsingLock columns, expiry, lock breaking
PerformanceBestGoodGood, 304 savings with If-None-MatchExtra writes for the lock, waiting users
ReversibilityEasyDTO contract changes428 can start in warning modeHard, changes the workflow
Team cognitive loadNoneLowMedium (RFC semantics, CORS, merge UI)High (abandoned locks, permissions)

When Not to Use It

Making If-Match mandatory (428) breaks existing clients overnight. On an API already in production it is safer to accept the header as optional first, log requests that arrive without it, and switch to 428 once clients have been updated.

If you do partial updates with PATCH, you need the ETag too; the thought "I only send the phone, there can be no conflict" is wrong. If Alice and Bob change the phone at the same time, one of them is still lost; and if a business rule ties fields together (title and salary band, for example), different fields can conflict as well. A PATCH without an ETag is acceptable only for fields that are truly independent and have a single owner.

Bulk editing from a list screen (changing the department of 20 employees in one go) cannot be modeled with a single ETag. You need a batch endpoint that carries the version of each row separately, or an operation that returns a result per row; what happens to the other 17 rows if 3 of 20 get 412 is a business decision.

Where conflicts cannot be merged, the optimistic approach wears users out. For work such as closing a payroll period, editing a long contract text or budget planning that takes hours, a pessimistic lock (Fowler's Pessimistic Offline Lock) is more honest than throwing half an hour of effort away with a 412. The rough rule: optimistic if conflicts are rare and cheap to merge, pessimistic if they are frequent or expensive to merge.

Checklist

  • Every resource edited by several people has a concurrency token, and that token is exposed as an ETag.
  • PUT/PATCH/DELETE requests get 428 without If-Match and 412 with a mismatched ETag.
  • The ETag in If-Match is passed to EF Core as OriginalValue; the check is not lost when the record is read again.
  • Weak ETags are not accepted in If-Match; strong comparison is used.
  • The 412 response carries the current values and the current ETag as ProblemDetails extensions.
  • ETag is exposed in the CORS policy; Angular reads the header with observe: 'response'.
  • On 412 the client does a field-level three-way merge, shows the conflicting fields to the user and does not save automatically.
  • For conditional GET the ETag covers the whole representation; if fields come from other tables, their versions are taken into account too.

This post continues "Concurrency in EF Core: Tokens, rowversion and DbUpdateConcurrencyException" from the EF Core series; the mechanics are there, the HTTP and UI side is here. The previous post, "Idempotency Keys: Not Creating the Same Order Twice on Double Clicks and Retries", is the other member of the same family: it stops the same request from running twice, this post stops different users from overwriting each other. "When the Status Column Is Updated from Everywhere: Transition Tables, State Machines and Status History" in this series also uses concurrency to prevent double approvals. For the HTTP side see "Global Error Handling in ASP.NET Core: Exception Middleware and ProblemDetails" and "CORS and Security in ASP.NET Core Web API: HTTPS, HSTS, Headers and Secrets" in the ASP.NET Core Web API series. Sources: RFC 9110 (HTTP Semantics; ETag, If-Match, If-None-Match, 412 and strong/weak comparison); RFC 6585 (428 Precondition Required); RFC 9457 for problem details; Optimistic Offline Lock and Pessimistic Offline Lock in Martin Fowler's Patterns of Enterprise Application Architecture catalog; and the "Handling Concurrency Conflicts" (EF Core) page on Microsoft Learn.

A Small Experiment

★☆☆ Write a DELETE /employees/{id} endpoint with the same rules: 428 without If-Match, 412 on a version mismatch. What should a client get when it tries to delete an already deleted record a second time?

★★☆ Add the department name to the GET /employees/{id} response. Show that after the department is renamed, an If-None-Match request with the old ETag wrongly gets 304; then fix it by deriving the ETag from both the employee and department versions.

★★★ Support JSON Merge Patch (RFC 7396) for PATCH /employees/{id}: only the fields sent are updated and If-Match is required. What would you add to the ProblemDetails so that on 412 the client can show a conflict screen only for the fields it sent?

We can now update a single record safely. But HR Portal's purchasing screen has an order list of 2 million rows, page 5000 takes seconds, and while the user pages through, newly added orders make some rows appear twice. In the next post we look at pagination strategies in depth: offset, keyset and opaque cursor tokens.