AngularAtlased. 22.2

Ch. 15Real time & offline66 / 75

Optimistic updates & rollback

An instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.

architecture ~0 min read

Waiting for the server before changing the screen is honest, but slow: a like that takes 400 ms to appear feels broken. An optimistic update changes the interface immediately, assumes the request will succeed and undoes the change if it doesn't.

The idea fits in one sentence. The details are where apps break: what exactly you restore on failure, what happens when two answers arrive in the wrong order, and how the server avoids applying the same action twice when you retry.

Why it matters
Almost all requests succeed. Making every user wait for the few that fail is a bad trade — but an optimistic interface that lies when one does fail is worse: the user leaves believing the change was saved.

The pattern: remember, apply, confirm or undo

Three steps. Remember the current value, change it locally, send the request. On success the server's answer replaces your guess — it may differ (an id, a timestamp, trimmed text). On failure restore what you remembered and tell the user: a value that silently jumps back looks like a bug.

import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { TodoApi, type Todo } from './todo-api';

@Service()
export class TodoStore {
  private readonly api = inject(TodoApi);
  readonly todos = signal<readonly Todo[]>([]);
  readonly error = signal<string | null>(null);

  async toggle(id: string): Promise<void> {
    const before = this.todos().find((t) => t.id === id);
    if (!before) return;

    this.patch(id, { done: !before.done }); // 1. the screen changes NOW
    try {
      const saved = await firstValueFrom(this.api.update(id, { done: !before.done }));
      this.patch(id, saved); // 2. the server's version wins (timestamps, normalised text...)
    } catch {
      this.patch(id, { done: before.done }); // 3. undo ONLY this item...
      this.error.set(`Couldn't save “${before.title}”. Undone.`); // ...and say so
    }
  }

  private patch(id: string, change: Partial<Todo>): void {
    this.todos.update((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t)));
  }
}
LiveOptimistic vs pessimistic, with a server that fails

The interface

Saved on the server

  • Write the release notes
  • Review the pull request
  • Update the dependencies
  • Fix the flaky test
feedback after
—
requests
0
failed
0
interface ≠ server
0
Tick tasks in both modes and compare the feedback time. Then choose “whole list” and press “Play the scenario”.

Undo what you changed, not the whole list

Saving a snapshot of the whole list is the most tempting version and it's wrong as soon as two requests are in flight. The snapshot taken by the first request doesn't contain the second change; when the first one fails, restoring it also erases a change the server has already saved. The scenario in the demo does exactly that: the interface and the server end up disagreeing, with nothing left to fix it. Undo per item, or apply the inverse operation.

// ✗ A snapshot of the WHOLE list also captures the other changes still in flight
async toggle(id: string) {
  const snapshot = this.todos(); // taken before the user's next click...
  this.patch(id, { done: !this.find(id).done });
  try {
    await firstValueFrom(this.api.update(id, ...));
  } catch {
    this.todos.set(snapshot); // ...so this also undoes a change the server has ALREADY saved
  }
}

Optimistic creation needs one more step. The new item doesn't have a real id yet, so give it a temporary one and replace it when the server answers. Until then, edits to that item have to wait or use the temporary id, which the server doesn't know.

async add(title: string): Promise<void> {
  const temp: Todo = { id: `tmp-${crypto.randomUUID()}`, title, done: false };
  this.todos.update((list) => [...list, temp]);
  try {
    const saved = await firstValueFrom(this.api.create({ title }));
    // the temporary id disappears: every later edit uses the real one
    this.todos.update((list) => list.map((t) => (t.id === temp.id ? saved : t)));
  } catch {
    this.todos.update((list) => list.filter((t) => t.id !== temp.id));
    this.error.set(`“${title}” was not added.`);
  }
}

Answers that arrive in the wrong order

Switch a setting on, off and on again, quickly. Three requests leave, and nothing guarantees the order in which their answers come back. If every answer overwrites the state, the winner is the last answer to arrive — not the last choice the user made.

LiveThree quick clicks, random latency

Every click sends a request. Its bar ends when the answer arrives.

You chose
off
Last answer wins
off …
Newest request only
off …

“Last answer wins” got it wrong 0 times out of 0.

Press “Click three times” a few times. Each request takes 0.3–2 s; watch the arrival order and the score below.

Two fixes, for two different problems. Newest only: every request gets a number and only the answer to the newest one may write — that fixes the interface. But the server may also have received the requests out of order. If the final state on the server matters, serialise the writes with concatMap (one request at a time, in the order of the clicks), or send a version the server checks. For searches, where you only care about the last one, switchMap cancels the old ones; see higher-order operators.

private latest = 0;
readonly enabled = signal(false); // what the user sees (optimistic)
private readonly confirmed = signal(false); // what the server last confirmed

async setNotifications(on: boolean): Promise<void> {
  const id = ++this.latest;
  this.enabled.set(on);
  try {
    const saved = await firstValueFrom(this.api.saveSettings({ notifications: on }));
    this.confirmed.set(saved.notifications);
    // an older answer that arrives late must not overwrite a newer choice
    if (id === this.latest) this.enabled.set(saved.notifications);
  } catch {
    if (id === this.latest) this.enabled.set(this.confirmed());
  }
}

Idempotency: one action, applied once

Optimistic interfaces retry, and here's the trap: the request reached the server, the order was created, and only the answer got lost. A retry creates a second order. The fix is an Idempotency-Key: a UUID generated once per user action and sent with every retry. The server remembers the keys it has processed and, for a repeat, returns the original result instead of doing the work again.

import { HttpClient, HttpContext, HttpContextToken, HttpInterceptorFn } from '@angular/common/http';
import { retry } from 'rxjs';

export const IDEMPOTENCY_KEY = new HttpContextToken<string | null>(() => null);

/** The key travels in the request's context and becomes a header. */
export const idempotencyInterceptor: HttpInterceptorFn = (req, next) => {
  const key = req.context.get(IDEMPOTENCY_KEY);
  return next(key ? req.clone({ setHeaders: { 'Idempotency-Key': key } }) : req);
};

// One key per USER ACTION, reused by every retry of that action.
placeOrder(order: Order) {
  const key = crypto.randomUUID();
  return this.http
    .post<OrderConfirmation>('/api/orders', order, {
      context: new HttpContext().set(IDEMPOTENCY_KEY, key),
    })
    .pipe(retry({ count: 3, delay: 1000 })); // same observable → same context → same key
}

The key belongs to the user's action, not to the HTTP request: generate it when the user presses the button, not in an interceptor that would make a new one on every retry. Retries themselves are covered in HttpClient and interceptors.

When YES, when NO

When YES
  • Frequent, cheap actions that almost always succeed: likes, ticks, marking as read.
  • Changes that are easy to undo: reordering, renaming, the quantity in a cart.
  • Interfaces where latency is felt on every click: lists, boards, editors.
When NO
  • Payments and anything with money: the user needs a real confirmation.
  • Irreversible actions: sending an email, deleting for good, publishing.
  • Validation that often fails on the server (a username that's taken): you'd be undoing all the time.

Common pitfalls

  • Silent rollback. The tick disappears with no message; the user thinks the app is broken or doesn't notice and moves on.
  • A snapshot of the whole list. It also undoes other changes still in flight, including some the server already saved.
  • The last answer wins. An old answer arriving late overwrites the user's newest choice. Number the requests.
  • Retrying without an idempotency key. A lost answer turns into a duplicate order.
  • Never reconciling with the server. Temporary ids that are never replaced, normalised text that never comes back: the interface slowly drifts away from the data.

Cheat sheet

optimistic update     snapshot → apply now → send → server version | roll back + message
roll back             only what this request changed (the item / the inverse operation)
not the whole list    a list snapshot also undoes other requests still in flight
server's answer       replaces the local guess (ids, timestamps, normalised text)
temporary ids         tmp-uuid until "create" answers, then swap for the real id
out-of-order answers  id per request: only the newest may write (newest-only)
writes in order       concatMap: one at a time, in the order of the clicks
reads                 switchMap: cancel the old search, keep the new one
retries               Idempotency-Key = one UUID per user action, same on every retry
never optimistic      payments, irreversible actions, validation that often fails
rollback without a message = a UI that looks broken

Ch. 15 · 0 of 3 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. WebSockets: real time that survives the network
  2. Optimistic updates & rollback
  3. Offline-first: Service Worker & caching
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation