Scaling Databases & Data Consistency · Change Without Downtime and Eventual Consistency

Designing UX Around Eventual Consistency

Search indexes, read models and caches lag behind — design screens and APIs that stay honest.

Scenario

Example app: a recruiting platform. Recruiters edit a job posting (Postgres), then search for it; search is served by an index updated from CDC events with 1–5 s delay. The job board's "applicant count" comes from a read model updated asynchronously.

What you see in production

  • "I edited the title but search still shows the old one" — recruiters re-save repeatedly, creating duplicate events.
  • Applicant counts briefly go down after a refresh (served by two read-model replicas with different lag).
  • Support can't tell whether data is wrong or just not updated yet.

The naive code

The UI assumes every read reflects the last write:

async function save(job: Job) {
  await api.put(`/jobs/${job.id}`, job);
  navigate(`/search?q=${encodeURIComponent(job.title)}`);   // search index hasn't caught up
}

Reproduce it

A search index that applies updates 2 seconds after the database, and three UI strategies:

Stale search after save: three UI strategiesTry it yourself
db = {"job-7": "Senior Engineer"}
index = {"job-7": "Engineer"}            # still the old title
INDEX_DELAY = 2.0

def save(title, now):
    db["job-7"] = title
    return {"version": 2, "saved_at": now}

def search_view(strategy, now, saved):
    fresh = now - saved["saved_at"] >= INDEX_DELAY
    title = db["job-7"] if fresh else index["job-7"]
    if strategy == "naive":
        return title
    if strategy == "optimistic":          # show the user's own change immediately
        return db["job-7"] + "  (just updated)"
    if strategy == "pending":              # be explicit about propagation
        return title if fresh else f"{title}  — your changes appear in search within a few seconds"

saved = save("Senior Engineer", now=0.0)
for strategy in ("naive", "optimistic", "pending"):
    print(f"{strategy:10} t=0.5s -> {search_view(strategy, 0.5, saved)}")

Output

naive      t=0.5s -> Engineer
optimistic t=0.5s -> Senior Engineer  (just updated)
pending    t=0.5s -> Engineer  — your changes appear in search within a few seconds

Root cause

Derived stores (search indexes, read models, caches, analytics) are updated asynchronously from the source of truth. Their lag is normal, but the product never told users or support, and the UI read from the derived store right after writing.

The fix

  • Read-your-writes in the UI: after a save, show the saved entity from the write response (optimistic update) or route that user's next reads to the source of truth.
  • Make propagation visible: "Publishing… visible in search within a few seconds", status fields (pending, processing, done), and updated_at timestamps.
  • Monotonic views: never show older data than the user already saw (carry a version and ignore older responses).
  • Reconcile: periodic jobs compare derived stores with the source and repair drift.
const saved = await api.put<Job>(`/jobs/${job.id}`, job);        // server returns the new version
queryClient.setQueryData(["job", job.id], saved);               // show it immediately
queryClient.setQueryData(["search", saved.title], (old?: SearchResult) =>
  old && old.version >= saved.version ? old : mergeIntoResults(old, saved));   // never go backwards

Trade-offs

  • Optimistic UI can show something the backend later rejects — handle failures by rolling the UI back with a clear message.
  • Routing reads to the source of truth increases its load; limit it to the writer's own recent changes.
  • Explicit "pending" states add product complexity but massively reduce confused support tickets.

Detect it in production

  • Propagation lag metrics: time from commit to visible in search/read model.
  • Drift found by reconciliation jobs (should trend to zero).
  • Support tickets tagged "not updated" vs actual lag at that time.

Key points

  • Derived stores lag; design for it instead of hoping it's instant.
  • Give users read-your-writes and monotonic views; expose status and versions.
  • Reconcile derived data against the source of truth.

Practice questions

A user edits a job title and immediately searches, seeing the old title. What are two good fixes?

Answer

Show the saved entity from the write response (optimistic update or route the user's next read to the source of truth), and make propagation explicit with a pending state or 'visible in search shortly' message.

How do you prevent counts or lists from 'going backwards' between refreshes?

Answer

Carry a version or timestamp with data and keep the newest seen, ignoring older responses (monotonic reads); or pin the session to one replica.

What is a reconciliation job and why is it needed with eventual consistency?

Answer

A periodic job compares derived data (search index, read models) with the source of truth and repairs differences, catching events that were lost or misapplied.

Open in the app to mark lessons complete, take notes and track your progress.