Learn

/

Async & Timing

Async & Timing

8 patterns

Async assertions, timers, flaky tests, race conditions, and waitFor patterns. You'll hit this when tests pass locally but fail in CI, or when you add sleep calls to make tests green.

Avoid
// Testing an async user fetch
test("loads user data", (done) => {
  fetchUser("u-42").then((user) => {
    expect(user).toEqual({
      id: "u-42",
      name: "Alice",
      role: "admin",
    });
    done();
  });
});
// Testing an async user fetch
test("loads user data", (done) => {
  fetchUser("u-42").then((user) => {
    expect(user).toEqual({
      id: "u-42",
      name: "Alice",
      role: "admin",
    });
    done();
  });
});

Prefer
// Testing an async user fetch
test("loads user data", async () => {
  const user = await fetchUser("u-42");

  expect(user).toEqual({
    id: "u-42",
    name: "Alice",
    role: "admin",
  });
});
// Testing an async user fetch
test("loads user data", async () => {
  const user = await fetchUser("u-42");

  expect(user).toEqual({
    id: "u-42",
    name: "Alice",
    role: "admin",
  });
});
Why avoid

The done callback pattern silently passes when the promise rejects, because the .then handler never runs and done() is never called. The test eventually times out with a generic timeout error instead of showing the actual failure reason.

Why prefer

Using async/await lets the test runner detect unhandled rejections automatically. If the promise rejects, the test fails immediately with a clear stack trace pointing to the failing line.

Vitest: Testing async code
Avoid
// Verifying that login rejects
test("rejects invalid credentials", async () => {
  try {
    await login("bad-user", "bad-pass");
  } catch (err) {
    expect(err).toBeInstanceOf(AuthError);
    expect(err.message).toBe("Invalid credentials");
  }
});
// Verifying that login rejects
test("rejects invalid credentials", async () => {
  try {
    await login("bad-user", "bad-pass");
  } catch (err) {
    expect(err).toBeInstanceOf(AuthError);
    expect(err.message).toBe("Invalid credentials");
  }
});

Prefer
// Verifying that login rejects
test("rejects invalid credentials", async () => {
  await expect(
    login("bad-user", "bad-pass")
  ).rejects.toThrow(AuthError);

  await expect(
    login("bad-user", "bad-pass")
  ).rejects.toThrow("Invalid credentials");
});
// Verifying that login rejects
test("rejects invalid credentials", async () => {
  await expect(
    login("bad-user", "bad-pass")
  ).rejects.toThrow(AuthError);

  await expect(
    login("bad-user", "bad-pass")
  ).rejects.toThrow("Invalid credentials");
});
Why avoid

The try/catch pattern silently passes when the promise resolves successfully, because the catch block is simply skipped. The test reports success even though the expected rejection never happened. You would need to add an explicit fail() call or expect.assertions() to guard against this.

Why prefer

The .rejects.toThrow() matcher guarantees the test fails if the promise resolves instead of rejecting. The assertion is declarative and the test runner enforces that a rejection actually occurs.

Vitest: expect.rejects
Avoid
// Testing a delayed notification
test("shows alert after delay", async () => {
  const onAlert = vi.fn();
  scheduleAlert("Server restarting", 5000, onAlert);

  // Wait for the real timer to fire
  await new Promise((r) => setTimeout(r, 5000));

  expect(onAlert).toHaveBeenCalledWith(
    "Server restarting"
  );
});
// Testing a delayed notification
test("shows alert after delay", async () => {
  const onAlert = vi.fn();
  scheduleAlert("Server restarting", 5000, onAlert);

  // Wait for the real timer to fire
  await new Promise((r) => setTimeout(r, 5000));

  expect(onAlert).toHaveBeenCalledWith(
    "Server restarting"
  );
});

Prefer
// Testing a delayed notification
test("shows alert after delay", async () => {
  vi.useFakeTimers();

  const onAlert = vi.fn();
  scheduleAlert("Server restarting", 5000, onAlert);

  // Advance time and flush microtasks
  await vi.advanceTimersByTimeAsync(5000);

  expect(onAlert).toHaveBeenCalledWith(
    "Server restarting"
  );

  vi.useRealTimers();
});
// Testing a delayed notification
test("shows alert after delay", async () => {
  vi.useFakeTimers();

  const onAlert = vi.fn();
  scheduleAlert("Server restarting", 5000, onAlert);

  // Advance time and flush microtasks
  await vi.advanceTimersByTimeAsync(5000);

  expect(onAlert).toHaveBeenCalledWith(
    "Server restarting"
  );

  vi.useRealTimers();
});
Why avoid

Waiting for real timers makes the test slow and introduces flakiness from scheduling jitter. A 5-second wait in every timer test compounds into minutes of wasted CI time. Fake timers eliminate both the slowness and the timing uncertainty.

Why prefer

Fake timers let you advance time instantly without actually waiting. The test runs in milliseconds instead of 5 seconds, and you have precise control over when each timer fires. advanceTimersByTimeAsync also flushes promise-based microtasks.

Vitest: Fake Timers
Avoid
// Testing a status poller
test("detects deployment completion", async () => {
  const status = await getDeployStatus("d-1");
  expect(status).toBe("pending");

  // Wait a fixed amount of time
  await new Promise((r) => setTimeout(r, 3000));

  const updated = await getDeployStatus("d-1");
  expect(updated).toBe("complete");
});
// Testing a status poller
test("detects deployment completion", async () => {
  const status = await getDeployStatus("d-1");
  expect(status).toBe("pending");

  // Wait a fixed amount of time
  await new Promise((r) => setTimeout(r, 3000));

  const updated = await getDeployStatus("d-1");
  expect(updated).toBe("complete");
});

Prefer
// Testing a status poller
test("detects deployment completion", async () => {
  const status = await getDeployStatus("d-1");
  expect(status).toBe("pending");

  // Poll until the condition is met
  await vi.waitFor(async () => {
    const updated = await getDeployStatus("d-1");
    expect(updated).toBe("complete");
  });
});
// Testing a status poller
test("detects deployment completion", async () => {
  const status = await getDeployStatus("d-1");
  expect(status).toBe("pending");

  // Poll until the condition is met
  await vi.waitFor(async () => {
    const updated = await getDeployStatus("d-1");
    expect(updated).toBe("complete");
  });
});
Why avoid

A fixed delay is a guess. If the system is faster, the test wastes time. If the system is slower (common under CI load), the test fails intermittently. There is no retry mechanism, so a single slow response causes a false failure.

Why prefer

vi.waitFor() retries the assertion on a short interval until it passes or times out. This adapts to the actual speed of the system under test, finishing as soon as the condition is met rather than waiting a fixed duration.

Vitest: vi.waitFor
Avoid
// Testing debounced search
test("debounces search input", async () => {
  const search = vi.fn();
  const debounced = debounce(search, 300);

  debounced("h");
  debounced("he");
  debounced("hel");

  // Wait for debounce to settle
  await new Promise((r) => setTimeout(r, 500));

  expect(search).toHaveBeenCalledTimes(1);
  expect(search).toHaveBeenCalledWith("hel");
});
// Testing debounced search
test("debounces search input", async () => {
  const search = vi.fn();
  const debounced = debounce(search, 300);

  debounced("h");
  debounced("he");
  debounced("hel");

  // Wait for debounce to settle
  await new Promise((r) => setTimeout(r, 500));

  expect(search).toHaveBeenCalledTimes(1);
  expect(search).toHaveBeenCalledWith("hel");
});

Prefer
// Testing debounced search
test("debounces search input", async () => {
  vi.useFakeTimers();
  const search = vi.fn();
  const debounced = debounce(search, 300);

  debounced("h");
  debounced("he");
  debounced("hel");

  // Nothing fired yet
  expect(search).not.toHaveBeenCalled();

  // Advance past the debounce window
  await vi.advanceTimersByTimeAsync(300);

  expect(search).toHaveBeenCalledTimes(1);
  expect(search).toHaveBeenCalledWith("hel");

  vi.useRealTimers();
});
// Testing debounced search
test("debounces search input", async () => {
  vi.useFakeTimers();
  const search = vi.fn();
  const debounced = debounce(search, 300);

  debounced("h");
  debounced("he");
  debounced("hel");

  // Nothing fired yet
  expect(search).not.toHaveBeenCalled();

  // Advance past the debounce window
  await vi.advanceTimersByTimeAsync(300);

  expect(search).toHaveBeenCalledTimes(1);
  expect(search).toHaveBeenCalledWith("hel");

  vi.useRealTimers();
});
Why avoid

Using a real setTimeout with an extra 200ms buffer works but is fragile. You cannot assert the intermediate state reliably, the test takes 500ms of real time, and under heavy CI load the timing margin may not be enough.

Why prefer

Fake timers give you precise control over when the debounce window expires. You can verify the intermediate state (nothing called yet) and then advance time to the exact debounce threshold. The test runs instantly and deterministically.

Vitest: advanceTimersByTimeAsync
Avoid
// Testing concurrent cart updates
test("handles concurrent additions", async () => {
  const cart = createCart();

  // Run requests sequentially
  await cart.addItem("sku-a", 1);
  await cart.addItem("sku-b", 2);

  const items = await cart.getItems();
  expect(items).toHaveLength(2);
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-a" })
  );
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-b" })
  );
});
// Testing concurrent cart updates
test("handles concurrent additions", async () => {
  const cart = createCart();

  // Run requests sequentially
  await cart.addItem("sku-a", 1);
  await cart.addItem("sku-b", 2);

  const items = await cart.getItems();
  expect(items).toHaveLength(2);
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-a" })
  );
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-b" })
  );
});

Prefer
// Testing concurrent cart updates
test("handles concurrent additions", async () => {
  const cart = createCart();

  // Fire both requests at once
  const [r1, r2] = await Promise.all([
    cart.addItem("sku-a", 1),
    cart.addItem("sku-b", 2),
  ]);

  // Verify final state after both settle
  const items = await cart.getItems();
  expect(items).toHaveLength(2);
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-a" })
  );
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-b" })
  );
});
// Testing concurrent cart updates
test("handles concurrent additions", async () => {
  const cart = createCart();

  // Fire both requests at once
  const [r1, r2] = await Promise.all([
    cart.addItem("sku-a", 1),
    cart.addItem("sku-b", 2),
  ]);

  // Verify final state after both settle
  const items = await cart.getItems();
  expect(items).toHaveLength(2);
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-a" })
  );
  expect(items).toContainEqual(
    expect.objectContaining({ sku: "sku-b" })
  );
});
Why avoid

Running operations one after another never overlaps their execution. The code under test always sees a settled state before the next operation begins. Bugs that only appear when two writes overlap (lost updates, stale reads) will pass this test and break in production.

Why prefer

Promise.all fires both operations concurrently, which is how real users interact with a cart. This exposes race conditions such as lost updates or duplicate entries that only surface under concurrent access. Sequential tests hide these bugs entirely.

MDN: Promise.all()
Avoid
// Testing a job processor
test("emits progress events", async () => {
  const processor = new JobProcessor();
  const events: string[] = [];

  processor.on("progress", (msg) => {
    events.push(msg);
  });
  processor.start("job-1");

  // Assume processing takes under 2 seconds
  await new Promise((r) => setTimeout(r, 2000));

  expect(events).toEqual([
    "Validating",
    "Processing",
    "Finalizing",
  ]);
});
// Testing a job processor
test("emits progress events", async () => {
  const processor = new JobProcessor();
  const events: string[] = [];

  processor.on("progress", (msg) => {
    events.push(msg);
  });
  processor.start("job-1");

  // Assume processing takes under 2 seconds
  await new Promise((r) => setTimeout(r, 2000));

  expect(events).toEqual([
    "Validating",
    "Processing",
    "Finalizing",
  ]);
});

Prefer
// Testing a job processor
test("emits progress events", async () => {
  const processor = new JobProcessor();
  const events: string[] = [];

  processor.on("progress", (msg) => {
    events.push(msg);
  });
  processor.start("job-1");

  // Wait until the done event fires
  await new Promise<void>((resolve) => {
    processor.on("done", () => resolve());
  });

  expect(events).toEqual([
    "Validating",
    "Processing",
    "Finalizing",
  ]);
});
// Testing a job processor
test("emits progress events", async () => {
  const processor = new JobProcessor();
  const events: string[] = [];

  processor.on("progress", (msg) => {
    events.push(msg);
  });
  processor.start("job-1");

  // Wait until the done event fires
  await new Promise<void>((resolve) => {
    processor.on("done", () => resolve());
  });

  expect(events).toEqual([
    "Validating",
    "Processing",
    "Finalizing",
  ]);
});
Why avoid

A fixed 2-second sleep is an arbitrary guess. If the processor finishes in 50ms, the test wastes time. If CI is under load and processing takes 2.1 seconds, the test fails randomly. Tying assertions to the completion event removes both problems.

Why prefer

Waiting for the 'done' event ties the test to the actual completion signal of the system. The test finishes as soon as processing completes, whether that takes 10ms or 900ms. It never waits longer than necessary and never times out prematurely.

Node.js: EventEmitter
Avoid
// Testing order creation with a server
const server = await createMockServer();

afterAll(async () => {
  await server.close();
});

test("creates an order", async () => {
  server.post("/orders", { id: "ord-1" });

  const order = await createOrder({
    baseUrl: server.url,
    items: [{ sku: "a", qty: 1 }],
  });

  expect(order.id).toBe("ord-1");
  expect(server.requests).toHaveLength(1);
});
// Testing order creation with a server
const server = await createMockServer();

afterAll(async () => {
  await server.close();
});

test("creates an order", async () => {
  server.post("/orders", { id: "ord-1" });

  const order = await createOrder({
    baseUrl: server.url,
    items: [{ sku: "a", qty: 1 }],
  });

  expect(order.id).toBe("ord-1");
  expect(server.requests).toHaveLength(1);
});

Prefer
// Testing order creation with a server
let server: MockServer;

beforeEach(async () => {
  server = await createMockServer();
});

afterEach(async () => {
  await server.close();
});

test("creates an order", async () => {
  server.post("/orders", { id: "ord-1" });

  const order = await createOrder({
    baseUrl: server.url,
    items: [{ sku: "a", qty: 1 }],
  });

  expect(order.id).toBe("ord-1");
  expect(server.requests).toHaveLength(1);
});
// Testing order creation with a server
let server: MockServer;

beforeEach(async () => {
  server = await createMockServer();
});

afterEach(async () => {
  await server.close();
});

test("creates an order", async () => {
  server.post("/orders", { id: "ord-1" });

  const order = await createOrder({
    baseUrl: server.url,
    items: [{ sku: "a", qty: 1 }],
  });

  expect(order.id).toBe("ord-1");
  expect(server.requests).toHaveLength(1);
});
Why avoid

Sharing a single server across tests means route handlers and recorded requests leak between them. The second test sees leftover requests from the first, so the toHaveLength check depends on execution order. Adding or reordering tests will cause unexpected failures.

Why prefer

Creating and tearing down the mock server in beforeEach/afterEach gives every test a clean instance. Registered routes, recorded requests, and any internal state are fully isolated. Tests can run in any order without affecting each other.

Vitest: beforeEach / afterEach