top of page

Testing Eventual Consistency: What "Eventually" Has to Mean

Shift Quality Editorial
3 minutes ago
8 min read

Composite scenario, built from common failure patterns; not a real organisation. A regional retailer's order service wrote each order to its database and published an OrderPlaced event. A consumer read the event and updated the read model behind the customer's "My orders" page. The team had a test for it, and it was the right shape: place an order, then wait up to five seconds for it to appear in "My orders". No fixed sleep, a deadline, a clear failure message. It passed in CI for a year.


During a holiday sale the consumer fell behind. Lag on the event topic climbed to about forty seconds. Customers placed an order, landed on "My orders", saw nothing, and did what anyone would: they placed it again. Support spent the following week cancelling duplicates and refunding double charges.


The test had been correct. The order did appear eventually. What it never asked was how eventual was acceptable, who was looking during the gap, and what they would do while the data was stale.


This is a companion to Testing the Hard Parts: Async, External Dependencies, and State, which covers awaiting async results, polling instead of sleeping, and testing concurrent access. Here the focus is narrower: systems where a write succeeds now and becomes visible elsewhere later, and what a test has to assert about the "later".


Why "eventually" passes in CI and fails in production


Eventual consistency, as Werner Vogels described it in 2008, guarantees that if no new updates arrive, all reads will eventually return the last written value. It says nothing about how long that takes. Peter Bailis and colleagues measured this for Dynamo-style stores in 2012 and found that in the average case such systems often return consistent data within tens of milliseconds, while in the worst case staleness is unbounded.


That gap between the average and the worst case is why these tests mislead. CI runs with an idle broker, one consumer and no backlog, which is the average case at its best. Production under load is where the tail lives. A test that waits five seconds in CI is measuring the quiet case and calling it a guarantee.


It is also why these tests are a leading source of flakiness. In Luo and colleagues' 2014 study of flaky tests in open-source projects, the largest root-cause category was async wait: 74 of the 161 fixes they classified, or 45%. About a third of those tests had used a fixed time delay to enforce an ordering.


The discovery move. Before writing the test, ask the product owner one question: after a user does X, how long until Y can see it, and what happens if someone acts on the old data in between? The answer is a requirement, a staleness budget, and almost nobody writes it down. The retailer's five seconds came from a developer's guess, not from anyone deciding that five seconds of invisible orders was acceptable.


The test you can run: find every eventually, waitFor or polling helper in your suite and list its timeout. For each one, find the document where someone agreed that number. If none exists, the test enforces a guess.


Assert the budget, and measure the lag


Once there is a staleness budget, the test can do two jobs: check convergence within the budget, and record how close it came.


An order's path to the read model with four tests: crash before publish, budget and gap tests in the lag, and replay

// Staleness budget agreed with product: 2s at p99 for "My orders"
const STALENESS_BUDGET_MS = 2000;

test('order is visible in My orders within the staleness budget', async () => {
  const placedAt = Date.now();
  const { orderId } = await placeOrder(customer, cart);

  await eventually(async () => (await myOrders(customer)).some(o => o.id === orderId),
                   { timeout: STALENESS_BUDGET_MS * 3 });     // diagnose, don't hang

  const lag = Date.now() - placedAt;
  recordMetric('my_orders_visibility_lag_ms', lag);           // trend this in CI
  expect(lag).toBeLessThan(STALENESS_BUDGET_MS);
});

The polling timeout is deliberately longer than the budget. If the order takes three seconds, you want a failure that says "converged in 3,100 ms, budget 2,000", not "timed out", because the first tells you the system works but has drifted, and the second tells you nothing.


The same number belongs in production monitoring: consumer lag on the topic, alerting well before it reaches the budget. A budget that is tested but not monitored only protects you on days that look like CI.


The test you can run: plot the recorded lag from your last 50 CI runs. If the trend is rising, you will breach the budget before the test ever fails.


Test the gap, not just the convergence


The retailer's failure happened inside the gap, so that's where the most valuable tests go. They need the gap to exist on purpose, which means injecting lag.


test('a customer who resubmits during projection lag gets one order', async () => {
  await consumer.pause();                        // simulate 40s of lag

  const key = idempotencyKey(customer, cart);
  const first = await placeOrder(customer, cart, { idempotencyKey: key });
  expect((await myOrders(customer)).length).toBe(0);    // the stale view the customer saw

  const second = await placeOrder(customer, cart, { idempotencyKey: key }); // natural retry
  expect(second.orderId).toBe(first.orderId);

  await consumer.resume();
  await eventually(async () => (await myOrders(customer)).length === 1);
  expect(await chargesFor(customer)).toHaveLength(1);
});

Two fixes made that test pass at the retailer, and they answer different questions.


Read-your-writes for the person who wrote. Vogels' paper lists read-your-writes as one of the practical variations on eventual consistency: a process that wrote a value always sees its own write afterwards. The retailer made it true for the customer by showing the order from the write model on the confirmation page, and by merging the session's just-placed orders into "My orders" until the projection caught up. Other customers and the warehouse could still see stale data. The customer who had just ordered could not.


Idempotency for the retry nobody can prevent. Even with read-your-writes, a customer on a flaky connection will press the button twice. An idempotency key generated on the client and checked on the write path turns the second submission into the first.


The test you can run: pause the consumer behind your most customer-visible read model in a test environment, then perform the natural user action, whether that's a refresh, a resubmit or a back-button retry. Count the side effects. More than one is a bug your convergence test can't see.


Duplicates and reordering are the normal case


Most brokers deliver at least once, so consumers see duplicates after a crash or rebalance, and events for the same entity can arrive out of order across partitions or retries. A projection that is right only when every event arrives once and in order will be wrong in production.


The test for this is cheap and catches a lot: build the projection from the event history, then rebuild it from the same history with every event delivered twice and with independent events shuffled. The three results should be identical.


test('projection is idempotent and order-tolerant', () => {
  const events = fixtureHistory();                       // 200 recorded events
  const once      = project(events);
  const twice     = project(events.flatMap(e => [e, e]));
  const shuffled  = project(shuffleWithinKey(events));   // preserve per-order sequence

  expect(twice).toEqual(once);
  expect(shuffled).toEqual(once);
});

If shuffled differs, decide whether your consumer should reject out-of-order events (using a per-entity version number) or tolerate them by design. Either is defensible. Leaving it to whatever order the broker happens to deliver isn't.


The dual write: where events go missing


The retailer's order service committed to the database and then published the event. If the process dies between those two steps, the order exists and the event never does, and the read model never converges, which no amount of waiting fixes.


The standard remedy is the transactional outbox pattern, documented by Chris Richardson: write the event to an outbox table in the same transaction as the business change, and have a separate relay publish it. To test that the remedy is actually in place, test the crash.


The test you can run: in an integration environment, inject a failure immediately after the database commit and before the publish. Then check whether the event is eventually delivered. If it isn't, you have a dual write. Schedule a reconciliation job comparing source-of-truth counts to read-model counts as a backstop either way.


Consistency contracts change under you


Consistency guarantees belong to dependencies, and dependencies change. Amazon S3 was eventually consistent for some operations for most of its history. On 1 December 2020, AWS announced strong read-after-write consistency for all S3 requests at no extra cost. Code written for the old model carried retries and delays that became dead weight overnight. The reverse is more dangerous: a new region, cache layer or vendor that weakens a guarantee your tests assumed.


The test you can run: for each data store, cache and queue your service reads from, write down the consistency guarantee you rely on, with a link to the vendor's documentation. Review the list whenever a dependency is upgraded or replaced.


Trade-offs


Approach

Gains

Costs

Best suited for

Warning signs

Poll with a deadline only

Simple, cheap

Proves convergence on a quiet day, nothing more

Low-stakes internal views

Timeouts nobody agreed

Staleness budget plus lag metric

Turns "eventually" into a requirement; early drift warning

Needs a product decision and monitoring

Customer-visible read models

Budget tested but not alerted on

Lag injection plus user-action tests

Catches harm done inside the gap

Test harness must control consumers

Flows where users act on what they see

Duplicate orders, double charges

Duplicate and shuffle replay

Cheap; finds non-idempotent projections

Needs recorded event fixtures

Every event-sourced projection

Projection differs on rebuild

History checkers (Jepsen, Elle)

Find isolation anomalies in the data store itself

Specialist effort

Choosing or upgrading a database

Vendor claims nobody has verified


For that last row, Kingsbury and Alvaro's Elle checker (2020) infers isolation anomalies from recorded transaction histories. It found unexpected anomalies in each of the four databases they tested. Most teams don't need to run it, but it's a reminder that a store's documented guarantee is a claim to verify, not a fact to assume.


When strong consistency is the right answer


Sometimes the right test result is "make it consistent". If the staleness budget the product needs is effectively zero (a balance before a withdrawal, stock before the last unit sells), you are looking for a reason to read from the system of record on that path rather than test around a projection. Eventual consistency buys availability and latency. It is worth paying for only where users can tolerate stale data.


A method for one read model


  1. Agree the budget. Ask the product owner the discovery question and record the answer, for example "2s at p99, submitting user sees their own write immediately".

  2. Add the convergence-plus-metric test with a diagnostic timeout of three times the budget.

  3. Add one lag-injection test for the most natural user action during the gap.

  4. Add the duplicate-and-shuffle replay test to the projection's unit suite.

  5. Test the crash between commit and publish once, and keep the reconciliation job.

  6. Monitor consumer lag in production with an alert below the budget.


You're done when each step has a passing test or a dashboard, and the budget is written somewhere a new team member would find it.


What to do next


Pick the read model your customers look at most. Find its eventually timeout and ask who agreed that number. If nobody did, take the discovery question to the product owner this week.


Final takeaway


A test that waits for convergence proves the system gets there eventually. It doesn't prove how long that takes or what users do in the meantime. Write the staleness budget down, test against it, and test what people do inside the gap, because that's where the damage happens.


For flaky waits in general, see Flaky Tests: Diagnosis and Cure. To keep producers and consumers from drifting apart on event shape, see Contract Testing: Keeping Services Honest, and when lag turns into interleaving bugs, work through Tutorial 7: Debug Race Conditions.


Sources


  • Vogels, W. (2008). "Eventually Consistent." ACM Queue 6(6), 14–19; also Communications of the ACM 52(1), 2009. cs.princeton.edu copy

  • Bailis, P., Venkataraman, S., Franklin, M. J., Hellerstein, J. M. and Stoica, I. (2012). "Probabilistically Bounded Staleness for Practical Partial Quorums." PVLDB 5(8). vldb.org

  • Luo, Q., Hariri, F., Eloussi, L. and Marinov, D. (2014). "An Empirical Analysis of Flaky Tests." FSE 2014 (FSE 2024 Test of Time award).

  • Kingsbury, K. and Alvaro, P. (2020). "Elle: Inferring Isolation Anomalies from Experimental Observations." PVLDB 14(3). arxiv.org/abs/2003.10554

  • Richardson, C. "Pattern: Transactional outbox." microservices.io

  • AWS (2020, 1 December). "Amazon S3 now delivers strong read-after-write consistency automatically for all applications." aws.amazon.com

bottom of page