How do you reliably test async/concurrent code without flaky sleeps?
Fixed sleeps in tests are a coin flip that wastes time on fast machines and fails on slow ones — virtual clocks and deterministic polling replace the guess with an actual guarantee.
LEARN BY DOINGPut your intuition to work3 steps
How do you reliably test async/concurrent code without flaky sleeps?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why sleep() in a Test Is a Coin Flip
A test that does await sleep(500); expect(orderWasProcessed).toBe(true); encodes a guess about how long an asynchronous operation takes, and that guess is wrong in both directions at once. Run it on a fast, idle machine and 500ms is way more than needed — you've made the suite slower for no reason. Run it on a loaded CI runner, a machine under memory pressure, or right after a garbage collection pause, and 500ms isn't enough — the test fails, someone re-runs it, it passes, and the team learns to ignore red CI instead of investigating it. Neither the false negative nor the wasted time is a testing problem in the traditional sense; it's a timing problem, and it needs a timing solution, not a bigger number.
There are two real, deterministic alternatives depending on who owns the delay: virtual time when your own code is the one calling delay/sleep/scheduling a timer, and a deterministic polling helper when the delay is happening somewhere you don't control — a thread pool, a real network round trip, a message broker.
#Option 1: Virtual Time, When You Own the Delay
If the code under test calls delay() itself — for example, implementing retry-with-backoff — a test runtime that controls its own clock can fast-forward through that delay instead of actually waiting. Kotlin's coroutine test library is a clean example of this because it does it automatically: any delay() call made from a coroutine running on the test dispatcher advances a virtual clock instead of blocking real time.
suspend fun <T> retryWithBackoff(maxAttempts: Int,initialDelay: Duration = 100.milliseconds,block: suspend (attempt: Int) -> T,): T {var currentDelay = initialDelayrepeat(maxAttempts - 1) { attempt ->try {return block(attempt)} catch (e: IOException) {delay(currentDelay)currentDelay *= 2}}return block(maxAttempts - 1)}
import kotlinx.coroutines.test.runTestimport kotlinx.coroutines.test.currentTimeimport kotlin.time.Duration.Companion.millisecondsimport java.io.IOExceptionimport kotlin.test.assertEqualsimport org.junit.jupiter.api.Testclass RetryWithBackoffTest {@Testfun `retries with exponential backoff and succeeds on the third attempt`() = runTest {var attempts = 0val result = retryWithBackoff(maxAttempts = 4, initialDelay = 100.milliseconds) {attempts++if (attempts < 3) throw IOException("connection reset")"ok"}assertEquals("ok", result)assertEquals(3, attempts)// Two delays fired -- 100ms then 200ms -- but only in *virtual* time.// The test itself completes in a few real milliseconds.assertEquals(300L, currentTime)}}
Nothing in this test is racing a clock. runTest runs the coroutine on a scheduler that auto-advances past any delay() the moment there's nothing else runnable, so two backoff delays that would take 300ms of wall-clock time in production complete instantly in the test, and currentTime reports exactly how much virtual time elapsed, letting you assert on the backoff schedule itself as a first-class outcome. If a future change makes the backoff cap at 5 seconds instead of growing unbounded, this same style of test catches it precisely, without your suite actually taking 5 seconds.
#Option 2: Deterministic Polling, When You Don't Own the Delay
Virtual time only works when your test runtime controls every clock the code under test touches. It can't help you when the delay is a real network round trip, a message landing in a real queue, or a background worker on its own thread pool — there, delay() isn't being called by code you can swap onto a virtual scheduler. The fix isn't "sleep, but shorter" — it's replacing a fixed wait with a bounded poll: check the condition repeatedly, on a short interval, up to a generous timeout, and fail fast the moment the condition is true rather than always waiting the full duration.
async function waitFor(predicate: () => boolean | Promise<boolean>,{ timeoutMs = 2000, intervalMs = 20 }: { timeoutMs?: number; intervalMs?: number } = {}): Promise<void> {const deadline = Date.now() + timeoutMs;while (Date.now() < deadline) {if (await predicate()) return;await new Promise((resolve) => setTimeout(resolve, intervalMs));}throw new Error(`waitFor: condition not met within ${timeoutMs}ms`);}test('consumer processes the message and updates the read model', async () => {const readModel = new InMemoryReadModel();const consumer = startConsumer(queue, readModel);await queue.publish({ type: 'OrderPlaced', orderId: 'o-1', total: 4999 });await waitFor(() => readModel.get('o-1')?.status === 'PLACED');expect(readModel.get('o-1')?.total).toBe(4999);await consumer.stop();});
This test still uses real wall-clock time, but it's no longer gambling with it: on a fast machine, waitFor returns as soon as the condition is true, often within a single 20ms polling interval, no wasted time. On a slow, loaded CI box, it keeps polling for up to two full seconds before giving up, which is far more headroom than a fixed sleep(200) would give. It fails only when the condition genuinely never becomes true within a generous budget, which is a real bug, not a scheduling coincidence.
#Comparing the Two Approaches
| Approach | Use when | Test duration | Determinism |
|---|---|---|---|
Fixed sleep(ms) | Never, as a first choice | Wastes time or isn't enough — both, on different machines | None — pure luck |
| Virtual/test clock | Your code owns the delay/timer/scheduling logic | Instant, regardless of the real delay length | Full — same result every run, every machine |
Deterministic polling (waitFor) | The delay is outside your control (network, external queue, real thread pool) | Fast when the system is healthy, bounded when it isn't | High — timeout is a safety net, not a guess |