Queues, events and async systems
Once work happens in the background, "assert immediately after the click" stops being valid. Learn at-least-once delivery, ordering guarantees and eventual consistency, or you will spend years adding sleeps to hide the symptoms.
Nearly every ticket labeled "flaky UI test" is not actually about the test framework at all, and the same misunderstanding shows up around queues, covered in the browser platform for the rendering side of the same problem. The moment a system moves work into the background, "click the button and assert immediately" stops being a valid test pattern. A queue picks up the job seconds later, an event fires and three services react to it in an order nobody guaranteed, and a database read a moment too soon returns the state from before the work finished. This post covers what changes once queues and events enter the picture: at-least-once delivery, ordering guarantees, and eventual consistency, the three concepts that explain most of what looks like flakiness in an async system. Skip them and you end up adding sleeps to hide the symptom instead of testing the actual behavior.
At-least-once delivery means duplicates are normal, not a bug
Most message queues, SQS, RabbitMQ, Kafka in its common configurations, guarantee a message is delivered at least once, not exactly once. A consumer can receive the same message twice if it crashes after processing but before acknowledging, or if a network blip causes a redelivery. A test that assumes single delivery and asserts "the email was sent exactly once" is testing an assumption the system never actually promised.
Ordering guarantees vary by queue, and most give you none
A standard SQS queue does not guarantee order at all; messages can arrive out of sequence. Kafka guarantees order only within a single partition, so two events for the same entity land in order only if they are routed to the same partition key. A test asserting "event A's effect appears before event B's" needs to know which of these guarantees the actual system provides, because asserting an ordering the queue never promised is asserting a bug that does not exist.
// Poll for the expected state instead of sleeping a fixed duration
await expect
.poll(async () => await api.getOrderStatus(orderId), { timeout: 10_000, intervals: [250, 500, 1000] })
.toBe('fulfilled')Eventual consistency: the read that runs too soon
A write to one service and a read from another, or even a read from a replica of the same database, can be separated by a window where the system has not caught up yet. This is eventual consistency: the system will reach the correct state, but not instantly, and a test that reads immediately after a write can catch it mid-transition.
A team running an order fulfillment system saw one integration test fail intermittently after they added an event-driven inventory sync, the kind of intermittent failure covered more broadly in flakiness. The test placed an order, then immediately checked the inventory count, and the assertion failed about one run in five. The inventory service consumed the order-placed event asynchronously and usually finished within 200 milliseconds, but under load it sometimes took over a second. The fix replaced the fixed-delay sleep with a poll that retried the inventory check every 250 milliseconds up to a 10-second timeout, which made the test both faster on the common path and reliable on the slow one.
Testing patterns that hold up against async systems
Sleeping a fixed duration and hoping it is long enough is the pattern that produces the worst of both worlds: too short and the test is flaky, too long and the whole suite crawls. Polling with a timeout, the pattern shown above, waits exactly as long as needed and fails fast when something is actually broken rather than just slow.
- Poll for the expected state with a bounded timeout instead of sleeping a fixed duration.
- Test idempotency directly by sending the same message twice and asserting the side effect happened once, not zero or twice.
- Know which ordering guarantee your queue actually provides before writing an assertion that depends on order.
- Watch a dead letter queue in tests that intentionally trigger failures, since a message that keeps failing usually lands there instead of disappearing.
This all sits on top of confirming what actually landed in the database, since an async consumer's real effect is usually a row that changed, not just an event that fired. Reading that row directly is often faster than trusting a second event to confirm the first one worked.
FAQ
Questions people ask
Why does my test fail intermittently even though the async job always finishes eventually?
The test is probably reading state before the background job has finished. Replace a fixed sleep with a poll that retries until the expected state appears or a timeout is hit.
How do I test that a consumer handles duplicate messages correctly?
Send the same message to the queue twice in the test, then assert the side effect, a database row, an email sent, happened exactly once rather than twice.
Does Kafka guarantee message order?
Only within a single partition. Messages with the same partition key arrive in order; messages across different partitions have no ordering guarantee relative to each other.
What is a dead letter queue and why does it matter for testing?
It is where a message lands after repeatedly failing to process. Tests that intentionally trigger a processing failure should assert the message ends up there, not just that it disappeared.