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:
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), andupdated_attimestamps. - 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
@app.put("/jobs/{job_id}")
def update_job(job_id: int, body: JobIn):
job = jobs.update(job_id, body) # commits + outbox event
return {**job.dict(), "version": job.version, "search_status": "pending"}
@Scheduled(fixedDelay = 600_000)
public void reconcileSearch() {
for (Job job : jobs.updatedSince(Instant.now().minus(Duration.ofHours(1)))) {
if (search.version(job.getId()) < job.getVersion()) search.reindex(job); // repair drift
}
}
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.