A user renames a hero and a spinner appears. Half a second later the list comes back. They rename the next hero and wait again. Across 20 heroes that's 20 waits for saves that almost always succeed, and the app feels slow even though the API is fine.
With an optimistic update, the UI shows the change the moment the user makes it and saves in the background. Most saves succeed, so the user never waits. When one fails, the UI rolls back to what it was and tells the user.
Optimistic updates make the UI assume success, so the API has to catch the cases where it shouldn't. Pair them with server-side concurrency control, so a save based on stale data is rejected instead of silently overwriting someone else's change.
Optimistic updates suit small, quick, frequent changes such as renaming, toggling, reordering, assigning or marking something done. Wait for the server instead when:
While an optimistic update is in flight, don't let the user start a change that depends on it, such as deleting a hero whose rename hasn't been saved yet. Disable those actions until the save settles.
function HeroAliasCell({ hero }: { hero: Hero }) {const queryClient = useQueryClient();const updateAlias = useMutation({mutationFn: (alias: string) => updateHeroAlias(hero.id, alias, hero.version),onSuccess: () => queryClient.invalidateQueries({ queryKey: heroesKey }),});if (updateAlias.isPending) return <Spinner />;return <AliasInput value={hero.alias} onSave={(alias) => updateAlias.mutate(alias)} />;}
❌ Figure: Bad example - Every rename waits for the API and then a refetch before the user sees their change
const updateAlias = useMutation({mutationFn: ({ hero, alias }: UpdateHeroAliasVariables) =>updateHeroAlias(hero.id, alias, hero.version),onMutate: ({ hero, alias }) => {queryClient.setQueryData<Hero[]>(heroesKey, (heroes) =>heroes?.map((h) => (h.id === hero.id ? { ...h, alias } : h)),);},});
❌ Figure: Bad example - The UI feels instant, but if the save fails the list keeps showing an alias that was never saved
With TanStack Query, an optimistic update has 4 steps:
onMutate - Cancel in-flight fetches so they can't overwrite the change, snapshot the current data, then apply the change to the cacheonError - Put the snapshot back and tell the user what happenedonSuccess - Replace the optimistic item with the one the server saved, so it has the new versiononSettled - Refetch, so the cache matches the server whatever happenedtype UpdateHeroAliasVariables = { hero: Hero; alias: string };export function useUpdateHeroAlias() {const queryClient = useQueryClient();return useMutation({mutationFn: ({ hero, alias }: UpdateHeroAliasVariables) =>updateHeroAlias(hero.id, alias, hero.version),onMutate: async ({ hero, alias }) => {await queryClient.cancelQueries({ queryKey: heroesKey });const previousHeroes = queryClient.getQueryData<Hero[]>(heroesKey);queryClient.setQueryData<Hero[]>(heroesKey, (heroes) =>heroes?.map((h) => (h.id === hero.id ? { ...h, alias } : h)),);return { previousHeroes };},onError: (error, _variables, context) => {queryClient.setQueryData(heroesKey, context?.previousHeroes);toast.error(error instanceof ConcurrencyConflictError? "Someone else changed this hero. Their latest changes are now showing.": "Couldn't save the alias. Your change has been undone.",);},onSuccess: (savedHero) => {queryClient.setQueryData<Hero[]>(heroesKey, (heroes) =>heroes?.map((h) => (h.id === savedHero.id ? savedHero : h)),);},onSettled: () => queryClient.invalidateQueries({ queryKey: heroesKey }),});}
✅ Figure: Good example - The alias changes instantly, rolls back if the save fails, and the cache is re-synced with the server afterwards
The same pattern works with any client-side state store (e.g. Redux, Zustand, or Fluxor in Blazor): apply the change, keep what you need to undo it, and revert when the API call fails.
The UI is now optimistic, but 2 people can still edit the same hero. Without a concurrency check, whoever saves last wins, and the other person's change disappears without anyone knowing.
group.MapPut("/{heroId:guid}/alias", async (Guid heroId,UpdateHeroAliasRequest request,ApplicationDbContext db,CancellationToken ct) =>{var hero = await db.Heroes.FindAsync([heroId], ct);if (hero is null) return Results.NotFound();hero.Alias = request.Alias;await db.SaveChangesAsync(ct);return Results.Ok(HeroDto.FromEntity(hero));});
❌ Figure: Bad example - Last write wins, so a save based on stale data silently overwrites someone else's change
Even with a rowversion column, this endpoint only catches a change made in the few milliseconds between loading and saving. The conflict that matters is with the version the user saw when they made their change. So the client has to send that version back, and the API has to compare against it.
rowversion column (see Do you have a rowversion column?)version property on the DTO and as an ETag header (ETag.FromVersion)If-Match header when it saves412 Precondition Failed when the version no longer matches, and 428 Precondition Required when it's missingpublic class HeroConfiguration : IEntityTypeConfiguration<Hero>{public void Configure(EntityTypeBuilder<Hero> builder){builder.Property(h => h.Version).IsRowVersion();}}
group.MapPut("/{heroId:guid}/alias", async (Guid heroId,UpdateHeroAliasRequest request,[FromHeader(Name = "If-Match")] string? ifMatch,ApplicationDbContext db,CancellationToken ct) =>{if (!ETag.TryParse(ifMatch, out var expectedVersion))return Results.StatusCode(StatusCodes.Status428PreconditionRequired);var hero = await db.Heroes.FindAsync([heroId], ct);if (hero is null) return Results.NotFound();// Check against the version the user saw, not the one just loadeddb.Entry(hero).Property(h => h.Version).OriginalValue = expectedVersion;hero.Alias = request.Alias;try{await db.SaveChangesAsync(ct);}catch (DbUpdateConcurrencyException){return Results.Problem(statusCode: StatusCodes.Status412PreconditionFailed,title: "This hero has been changed by someone else");}return Results.Ok(HeroDto.FromEntity(hero));});public static class ETag{public static string FromVersion(byte[] version) => $"\"{Convert.ToBase64String(version)}\"";public static bool TryParse(string? header, out byte[] version){version = [];if (string.IsNullOrWhiteSpace(header)) return false;try{version = Convert.FromBase64String(header.Trim().Trim('"'));return version.Length > 0;}catch (FormatException){return false;}}}
✅ Figure: Good example - EF Core only saves if the hero still has the version the user saw, otherwise the API returns 412
On the client, turn a 412 into an error the optimistic update can react to, so onError rolls back and onSettled loads the other person's change:
export class ConcurrencyConflictError extends Error {}export async function updateHeroAlias(heroId: string, alias: string, version: string): Promise<Hero> {const response = await fetch(`/api/heroes/${heroId}/alias`, {method: "PUT",headers: { "Content-Type": "application/json", "If-Match": `"${version}"` },body: JSON.stringify({ alias }),});if (response.status === 412) throw new ConcurrencyConflictError("Hero was changed by someone else");if (!response.ok) throw new Error(`Failed to update hero alias (${response.status})`);return response.json();}
✅ Figure: Good example - The client sends the version it last saw and treats a 412 as a conflict, not a generic failure
If your API takes the version in the request body instead of an If-Match header, return 409 Conflict for a mismatch. Either way, the important part is that a conflicting write is detected and rejected, not silently applied.
Optimistic updates make the UI fast. Concurrency control keeps the data correct. Use both together.