An API accepts a write, but the next query still shows the previous state. That can be normal when the read goes through a replica, search index, cache, or event-driven projection. The test becomes unstable when it treats that window as a fixed delay.

To test eventual consistency, define what starts propagation, which read should reflect the change, what condition proves that it arrived, and the total time limit. Then poll that condition. The test should stop when the correct state appears and stop retrying when a failure is not transient.

This is a question about the test boundary. The comparison of unit and integration tests in Node.js helps you decide which components need to stay real. Here, the proof crosses the read path that a user or another service will actually use.

Diagram shows a write, a stale read, polling, and a deadline that ends in ready or timeout.

Short answer

  • Record the write, observed read, success condition, and total deadline.
  • Use bounded polling, not a fixed sleep that restarts its clock on every attempt.
  • Treat transient states as transient and authorization or validation errors as immediate failures.
  • Keep the last observations when the deadline expires.
  • Observe the entire window when the requirement is that something must not appear.

What does the test need to prove?

The test does not need to prove that two reads happen at the same time. It needs to prove that the change becomes visible through the path promised by the public contract, within a known window. Before writing code, name four things: trigger, observation, predicate, and deadline.

  • Trigger: the write or command that starts propagation.
  • Observation: the endpoint, query, index, or screen the consumer will use.
  • Predicate: the state that means the change arrived, such as an ID, version, or status.
  • Deadline: the maximum acceptable time for that condition to appear.

A 200 is not enough. A successful response can still contain an older version. The predicate must compare the value produced by the write. If the flow uses versions, check the expected version too. If it uses states, list the intermediate states that are allowed.

For example, DynamoDB read consistency explains that an eventually consistent read may not reflect a completed write immediately. The same documentation describes when a strong read can be requested and when a global secondary index remains eventually consistent. Your test needs to follow that provider boundary, not a generic assumption.

Why does a fixed sleep make a poor test?

A sleep(1000) does not verify state. It makes a timing bet. If the projection is ready after 50 milliseconds, every run still pays the full second. If it needs longer under load, the same test fails even when the documented consistency window has not ended.

The problem also appears when each attempt restarts the clock. A helper that waits one second after every failed read can stay alive far longer than its author intended. Calculate the deadline once and make every request spend from the same budget.

The Playwright Assertions documentation recommends retrying assertions for asynchronous interfaces and documents expect.poll for repeating a function until its matcher passes or the timeout expires. That does not make every error an acceptable delay. The callback still needs to classify responses.

The How to Test Eventually Consistent APIs with Polling Instead of Fixed Sleeps guide, retrieved on October 13, 2026, makes the same separation between expected states, a total deadline, and terminal errors. It is practical synthesis, not the definition of your system's contract.

How do you use expect.poll in Playwright?

Use expect.poll when the thing you need to repeat is an asynchronous read and the final matcher can express convergence. The example below is illustrative: replace the route, state, and 404 classification with your API's contract.

import { test, expect } from "@playwright/test";

test("the projection becomes visible after the write", async ({ request }) => {
  const create = await request.post("/orders", {
    data: { reference: "order-for-test" },
  });
  expect(create.ok()).toBeTruthy();

  const { id, version } = await create.json();

  await expect.poll(
    async () => {
      const response = await request.get(`/order-search/${id}`);

      // In this contract, 404 means "not indexed yet".
      if (response.status() === 404) {
        return { state: "not_visible" };
      }

      // For this API, these statuses are outside normal propagation.
      if ([401, 403, 422].includes(response.status())) {
        throw new Error(`terminal failure: ${response.status()}`);
      }

      if (!response.ok()) {
        throw new Error(`read endpoint failed: ${response.status()}`);
      }

      const body = await response.json();
      return { state: body.state, version: body.version };
    },
    {
      intervals: [100, 250, 500, 1_000],
      timeout: 10_000,
      message: "the projection must converge within the contract",
    },
  ).toEqual({ state: "ready", version });
});

The code does not wait until the end when the correct version appears. It also does not turn an authorization response into another attempt. The intervals and timeout are example settings, not universal guarantees. Derive them from the service objective and the load your suite can create.

This test proves a projection read. It does not prove that the whole system is fast or that every replica has converged. That distinction keeps a functional assertion from becoming an accidental benchmark.

How do you separate expected delay from a real failure?

Classify the observation before choosing the interval. A missing record can be transient in a newly updated index. The same 404 can be permanent on an endpoint that should read the primary record. HTTP statuses help, but the resource contract decides.

A useful classification might include not_visible, indexing, ready, and failed. The test can continue on not_visible and indexing, stop on ready, and fail on failed. For a response such as 503, choose explicitly between a retry policy and an immediate failure. Do not hide an incident by repeating every status until the timeout.

Keep one compact observation per attempt: relative time, status, state, version, correlation ID, and a sanitized message. Do not record tokens, sensitive bodies, or personal data. When the deadline expires, the failure should show the last condition observed, not only "timeout".

This also separates the problem from testing retry logic in JavaScript without waiting. Retry testing checks when a call is made again. Eventual-consistency testing checks when the effect of a write becomes visible through another read. One flow may need both tests, but they answer different questions.

How should you test intermediate states and versions?

Eventual consistency does not mean that every sequence is acceptable before convergence. If a resource should move from queued to indexing and then ready, the test should reject a move from ready back to queued when the API promises monotonic progress.

A simple way to express this rule is to keep the last allowed state and inspect every new observation. For numeric versions, reject a version lower than the previous one. For states without a total order, list allowed transitions instead of comparing strings by accident.

If the instability comes from two local operations competing for the same resource, the boundary is different. The fixture for testing race conditions in asynchronous JavaScript helps control operation order. This article's polling applies when the internal ordering has finished but the result is not visible through the read path yet.

How do you test that a forbidden state never appears?

A positive assertion can stop at the first correct read. A negative assertion needs to observe the full window. Seeing a resource absent once does not prove it will not appear after a queue or index processes the event.

For revocation, deletion, or tenant-isolation tests, define an observation window and fail as soon as the forbidden state appears. Declare success only after the full window passes. If the system provides a deterministic authorization check, prefer that proof to repeatedly querying a delayed projection.

Do not reuse the positive assertion's timeout just for convenience. "Will the record appear?" and "will the record never be visible?" have different risks. The first can stop early. The second needs to complete the period that the contract actually covers.

When is polling not the best test?

Use a strong read when the question is only whether the write was persisted and the provider offers that guarantee on the chosen path. In DynamoDB, for example, global secondary indexes do not support the same strong-read option documented for tables and local secondary indexes. Changing the query can change the object of the test.

Use an event, webhook, or queue subscription when that is the consumer's public contract. The test should then check correlation, duplicates, and subscriber readiness. After the event, it can still make sense to query the public read path to prove the state a user will see.

Finally, do not use a functional convergence test to measure latency percentiles. It shows whether the condition appeared within a limit. A performance test needs separate control of load, concurrency, and duration collection. Mixing the questions creates a timeout that is hard to interpret.

Checklist for putting this test in CI

Before adding the test to the main execution path, check that:

  • the write produces an identifier or version the read can find;
  • the query uses the same path a real consumer uses;
  • the predicate checks the right value, not only 200;
  • transient responses are listed and terminal failures stop early;
  • there is one deadline, without renewing it on every attempt;
  • the final observations are sanitized and useful for diagnosis;
  • forbidden states and version regressions have their own assertions;
  • a negative assertion's observation window is written in the requirement;
  • polling is not replacing an event test or a load test;
  • the suite runs with isolated data and known cleanup.

The goal is not to make the test wait longer. It is to make the wait represent the contract the system actually offers. When the read converges, the test ends. When it does not, the failure keeps enough evidence to investigate whether the defect is in the write, propagation, or read path.

Frequently asked questions

Can I use sleep in an eventual-consistency test?

A short sleep can be part of a controlled fixture, but it should not be the main proof of convergence. Playwright documents expect.poll with configurable timeouts and intervals. Use an observable condition and one total deadline so the test ends early when the correct state appears.

Should I retry every error until the timeout?

No. Retry only states the contract classifies as transient. Authentication, authorization, validation, or schema failures should stop early. A server response can be retryable in one system and terminal in another, so the classification belongs beside the test.

How do I choose the timeout?

Start with the propagation objective the system promises, not a convenient number for CI. Use one total deadline, record the last observation, and investigate when the test needs more time. If the value comes only from local attempts, treat it as a hypothesis rather than a provider guarantee.

Does polling replace an event test?

No. Polling checks an observable condition when that is the client path or when delayed visibility is the object of the proof. If the product promises a webhook or event, test delivery, correlation, duplicates, and recovery separately.

Conclusion

Testing eventual consistency is not about choosing a sleep that is large enough. It is about writing the visibility contract and giving the test one time budget. Polling checks the right condition, ends early on success, and does not hide failures that do not belong to normal propagation.

That design makes the failure useful. You know which write started the flow, which read was delayed, which state was observed last, and which limit was exceeded. The same discipline applies to replicas, caches, search indexes, and event-driven projections.

How this article was produced

Samuel Fajreldines is the accountable author. The research combined official Playwright documentation, DynamoDB documentation, the Node.js timing API, current results in Portuguese, English, and Spanish, and public developer discussions. The Playwright snippets are illustrative contract examples, not measurements from a production system.

I used AI assistance for source discovery, organization, drafting, image generation, translation, and consistency review. That assistance did not execute a real distributed architecture, measure propagation latency, or replace verification of official sources.

Sources consulted