QA How-To
Playwright clock API: Examples and Best Practices
Explore Playwright clock API examples for countdowns, debounce, polling, autosave, expiry, midnight, exact boundaries, and stable timer testing in CI.
15 min read | 2,272 words
TL;DR
Install Clock before timer creation, choose runFor() or fastForward() based on whether intermediate callbacks matter, and assert an exact user-visible boundary. Separate browser time from network and server time.
Key Takeaways
- Test timer boundaries immediately before and exactly at the required threshold.
- Use runFor() for countdown, polling, and other behavior requiring every interval occurrence.
- Verify debounce and autosave reset after later input and use only the final value.
- Assert that polling and countdown timers stop after reaching their terminal state.
- Configure the context time zone when an instant maps to local calendar behavior.
- Keep browser scheduling, network completion, and server-owned time as explicit separate controls.
Reliable playwright clock API examples start with a user-visible time risk, not with a fake-timer method. A countdown needs controlled ticks, a debounced search needs an exact quiet period, a polling screen needs repeated callbacks, and a token banner may need an absolute wall-clock jump. Each behavior calls for a different test shape.
The recipes below are runnable with Playwright Test and use browser-owned markup so the timing mechanics are visible. In a real suite, replace setContent() with the application route and keep the same three-part pattern: establish time, trigger behavior, then assert the observable outcome.
TL;DR
| Scenario | Starting move | Advance method | Essential assertion |
|---|---|---|---|
| One-time timeout | clock.install() before page code |
runFor(delay) |
State changes at the boundary |
| Repeating countdown | Install at a known instant | runFor() |
Intermediate value and terminal value |
| Debounce | Install before event listeners | runFor(quietPeriod) |
One operation after last input |
| Background-tab return | Install and create timer | fastForward() |
Page reconciles after a jump |
| Stable generated timestamp | setFixedTime() |
No fake advance required | Exact machine timestamp |
| User changes device clock | Install known time | setSystemTime() |
New actions see adjusted time |
1. Playwright clock API examples test harness
Begin with Playwright Test and TypeScript. Every example imports test and expect, installs time before application scripts run, and awaits every Clock operation.
import { test, expect } from '@playwright/test';
const BASE_TIME = new Date('2026-07-13T09:00:00Z');
test('clock smoke test', async ({ page }) => {
await page.clock.install({ time: BASE_TIME });
await page.setContent(`
<output aria-label="current time"></output>
<script>
document.querySelector('output').textContent =
new Date().toISOString();
</script>
`);
await expect(page.getByLabel('current time'))
.toHaveText('2026-07-13T09:00:00.000Z');
});
This smoke test answers two setup questions. The page sees the controlled instant, and the locator can observe the expected result. If it fails in a real project, inspect installation order and whether the app renders inside another frame or process.
Use ISO timestamps with Z for UTC or an explicit offset. If the feature is based on local calendar dates, also configure timezoneId in the test or Playwright project. The controlled instant and the display time zone are separate inputs.
Keep time constants near the behavior they define. BASE_TIME is readable for a suite, while a constant such as INACTIVITY_WARNING_MS should come from the product requirement or a shared domain value. Do not copy a production implementation constant into the expectation without independent confirmation, or the test may repeat the same mistake.
2. One-time timeout at an exact boundary
A one-shot timeout is the simplest case, but it still deserves boundary testing. Assert that the effect has not occurred one millisecond before the threshold and that it occurs at the threshold.
test('locks the editor after ten minutes of inactivity', async ({ page }) => {
const lockAfterMs = 10 * 60 * 1000;
await page.clock.install({ time: BASE_TIME });
await page.setContent(`
<textarea aria-label="Document">Draft</textarea>
<p role="status">Editing</p>
<script>
setTimeout(() => {
document.querySelector('textarea').disabled = true;
document.querySelector('[role=status]').textContent = 'Locked';
}, ${lockAfterMs});
</script>
`);
await page.clock.runFor(lockAfterMs - 1);
await expect(page.getByRole('status')).toHaveText('Editing');
await expect(page.getByLabel('Document')).toBeEnabled();
await page.clock.runFor(1);
await expect(page.getByRole('status')).toHaveText('Locked');
await expect(page.getByLabel('Document')).toBeDisabled();
});
The two assertions after each transition show both message and operability. If the message changes but the editor remains enabled, the product still has a defect. A single callback-count assertion would miss it.
Use runFor() here because the test wants normal elapsed timer execution. A large waitForTimeout() would make the suite slow and still depend on scheduler load. Clock turns ten minutes into a deterministic operation while the locator assertions retain Playwright's normal retry behavior.
This pattern fits inactivity locks, delayed tooltips, grace periods, deferred validation, and notification dismissal. Replace the millisecond boundary with the requirement, not an arbitrary value chosen to make the test fast.
3. Repeating countdown with stop-at-zero behavior
Countdowns often have two bugs: an incorrect intermediate value and an interval that continues below zero. Test an intermediate tick, the terminal tick, and a later time to prove the terminal state is stable.
test('counts down and stops at zero', async ({ page }) => {
await page.clock.install({ time: BASE_TIME });
await page.setContent(`
<p>Retry available in <output>3</output> seconds</p>
<button disabled>Retry</button>
<script>
let remaining = 3;
const output = document.querySelector('output');
const button = document.querySelector('button');
const timer = setInterval(() => {
remaining -= 1;
output.textContent = String(remaining);
if (remaining === 0) {
clearInterval(timer);
button.disabled = false;
}
}, 1000);
</script>
`);
await page.clock.runFor(1_000);
await expect(page.locator('output')).toHaveText('2');
await expect(page.getByRole('button', { name: 'Retry' })).toBeDisabled();
await page.clock.runFor(2_000);
await expect(page.locator('output')).toHaveText('0');
await expect(page.getByRole('button', { name: 'Retry' })).toBeEnabled();
await page.clock.runFor(5_000);
await expect(page.locator('output')).toHaveText('0');
});
runFor() is essential because every interval occurrence contributes to the displayed value. fastForward(3000) is not equivalent, since a jump fires due timers at most once. That difference is an API detail with direct business impact.
For a long countdown, do not assert every tick. Choose representative points and important boundaries. If the UI derives remaining time from an absolute expiry rather than decrementing a variable, add a recovery case that jumps ahead and asks the page to recalculate. That better represents a laptop sleeping or a background tab being throttled.
4. Debounced search after the final input
A debounce delays work until input has remained quiet for a defined period. The test should prove three facts: no request before the quiet period, a new keystroke resets the delay, and exactly one request is made with the final query.
test('sends one search after 300 ms of quiet time', async ({ page }) => {
await page.clock.install();
const queries: string[] = [];
await page.exposeFunction('recordQuery', (query: string) => {
queries.push(query);
});
await page.setContent(`
<label>Search <input></label>
<p role="status">Idle</p>
<script>
let debounceId;
const input = document.querySelector('input');
input.addEventListener('input', () => {
clearTimeout(debounceId);
debounceId = setTimeout(async () => {
await window.recordQuery(input.value);
document.querySelector('[role=status]').textContent = 'Searched';
}, 300);
});
</script>
`);
const search = page.getByLabel('Search');
await search.fill('play');
await page.clock.runFor(299);
expect(queries).toEqual([]);
await search.fill('playwright');
await page.clock.runFor(299);
expect(queries).toEqual([]);
await page.clock.runFor(1);
await expect(page.getByRole('status')).toHaveText('Searched');
expect(queries).toEqual(['playwright']);
});
The in-process queries array keeps this teaching example self-contained. In an application test, route the search endpoint or use waitForRequest() and inspect the request URL or payload. Register the request wait before advancing the final millisecond if the callback can issue the request immediately.
Do not use pressSequentially() merely to create delay when Clock already controls the debounce. Input-event coverage and debounce timing are different concerns. Use realistic input actions, then move controlled time precisely.
5. Autosave reset after continued editing
Autosave commonly behaves like debounce but adds asynchronous completion and a visible status. A strong test verifies that continued editing cancels the earlier schedule, only the latest content is saved, and the status reflects completion.
test('autosaves only the latest draft', async ({ page }) => {
await page.clock.install({ time: BASE_TIME });
const savedBodies: string[] = [];
await page.exposeFunction('saveDraft', (body: string) => {
savedBodies.push(body);
});
await page.setContent(`
<label>Notes <textarea></textarea></label>
<p role="status">Saved</p>
<script>
let saveId;
const notes = document.querySelector('textarea');
const status = document.querySelector('[role=status]');
notes.addEventListener('input', () => {
status.textContent = 'Unsaved';
clearTimeout(saveId);
saveId = setTimeout(async () => {
await window.saveDraft(notes.value);
status.textContent = 'Saved';
}, 2000);
});
</script>
`);
const notes = page.getByLabel('Notes');
await notes.fill('First draft');
await page.clock.runFor(1_999);
await notes.fill('Final draft');
await page.clock.runFor(1_999);
expect(savedBodies).toEqual([]);
await expect(page.getByRole('status')).toHaveText('Unsaved');
await page.clock.runFor(1);
await expect(page.getByRole('status')).toHaveText('Saved');
expect(savedBodies).toEqual(['Final draft']);
});
When the real autosave calls a backend, mock only if the test is about client scheduling. Add a separate integration test for request payload, authorization, conflict handling, and error recovery. Combining every layer in one Clock test can make a failure impossible to assign.
For a save-in-progress race, control the API response independently. Clock can schedule the request, but fulfilling a route or resolving a promise controls network completion. Keep those levers named in test steps so reviewers can distinguish elapsed page time from backend response timing.
6. Polling until a terminal server state
Polling combines interval timing with network state. Use an absolute URL in a self-contained page, route each response, and advance through every interval using runFor().
test('polls until the export is ready', async ({ page }) => {
await page.clock.install({ time: BASE_TIME });
let requestCount = 0;
await page.route('https://api.example.test/exports/42', async route => {
requestCount += 1;
const status = requestCount >= 3 ? 'ready' : 'processing';
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'access-control-allow-origin': '*' },
body: JSON.stringify({ status })
});
});
await page.setContent(`
<p role="status">Starting</p>
<script>
const output = document.querySelector('[role=status]');
const poll = async () => {
const response = await fetch('https://api.example.test/exports/42');
const data = await response.json();
output.textContent = data.status;
if (data.status === 'ready') clearInterval(intervalId);
};
poll();
const intervalId = setInterval(poll, 5000);
</script>
`);
await expect(page.getByRole('status')).toHaveText('processing');
await page.clock.runFor(10_000);
await expect(page.getByRole('status')).toHaveText('ready');
expect(requestCount).toBe(3);
await page.clock.runFor(20_000);
expect(requestCount).toBe(3);
});
The final advance proves polling stops after the terminal state. Without it, the UI could look correct while continuing to load the server.
The application script declares intervalId before the first awaited fetch completes in normal JavaScript scheduling, so the terminal callback can clear it. In production, also test non-200 responses, malformed payloads, backoff, and maximum attempts. Those behaviors may need separate fixtures rather than one oversized scenario.
If a polling test times out, inspect whether the route matches, whether Clock was installed before setInterval, and whether the assertion waits for async fetch completion. The Playwright timeout diagnosis guide covers those evidence checks.
7. Toast dismissal and hover pause
Notification banners often auto-dismiss, then pause their remaining timer while the user hovers. This is not one continuous timeout. The application must retain remaining duration, cancel the timer, and schedule a new timeout on mouse leave.
test('pauses toast dismissal while hovered', async ({ page }) => {
await page.clock.install({ time: BASE_TIME });
await page.setContent(`
<div role="status" tabindex="0">Report downloaded</div>
<script>
const toast = document.querySelector('[role=status]');
let remaining = 5000;
let startedAt = Date.now();
let timerId;
function dismiss() { toast.remove(); }
function start() {
startedAt = Date.now();
timerId = setTimeout(dismiss, remaining);
}
toast.addEventListener('mouseenter', () => {
clearTimeout(timerId);
remaining -= Date.now() - startedAt;
});
toast.addEventListener('mouseleave', start);
start();
</script>
`);
const toast = page.getByRole('status');
await page.clock.runFor(2_000);
await toast.hover();
await page.clock.runFor(10_000);
await expect(toast).toBeVisible();
await page.mouse.move(0, 0);
await page.clock.runFor(2_999);
await expect(toast).toBeVisible();
await page.clock.runFor(1);
await expect(toast).toBeHidden();
});
This recipe combines normal Playwright actionability with controlled time. The test does not dispatch synthetic mouse events directly, so it also verifies that the displayed element can actually receive hover interaction.
An accessible notification may need more than auto-dismiss behavior. Test the appropriate live-region semantics, keyboard pause behavior where required, and whether important information remains available elsewhere. Clock proves timing, not the complete user experience.
8. Absolute expiry and system-clock changes
Some client decisions compare Date.now() with an absolute timestamp. Token banners, signed links, and cached values often use this pattern. setSystemTime() is useful when the test needs a wall-clock change without replaying all intervening timers.
test('rejects a cached value after a device clock jump', async ({ page }) => {
const start = new Date('2026-07-13T09:00:00Z');
const expiresAt = Date.parse('2026-07-13T10:00:00Z');
await page.clock.install({ time: start });
await page.setContent(`
<button>Use cached report</button>
<p role="status"></p>
<script>
const expiresAt = ${expiresAt};
document.querySelector('button').addEventListener('click', () => {
document.querySelector('[role=status]').textContent =
Date.now() < expiresAt ? 'Report opened' : 'Report expired';
});
</script>
`);
await page.getByRole('button', { name: 'Use cached report' }).click();
await expect(page.getByRole('status')).toHaveText('Report opened');
await page.clock.setSystemTime(new Date('2026-07-13T10:00:01Z'));
await page.getByRole('button', { name: 'Use cached report' }).click();
await expect(page.getByRole('status')).toHaveText('Report expired');
});
This test deliberately controls only the client rule. It does not prove a server will reject an expired credential. Server verification needs a token with a suitable expiry or a supported service-side clock strategy.
Also consider moving the system time backward. Duration code based on wall time may produce a negative elapsed value. Performance-sensitive duration measurement should usually use a monotonic clock, while absolute expiry should use wall time. Test the requirement, not a blanket assumption that every clock jump is an error.
9. Midnight, month-end, and time-zone examples
Calendar bugs appear when one instant maps to different local dates. Pin the browser time zone and cross the smallest relevant boundary. The example below verifies a daily banner in Tokyo.
import { test, expect } from '@playwright/test';
test.use({ timezoneId: 'Asia/Tokyo', locale: 'en-US' });
test('changes the local date at Tokyo midnight', async ({ page }) => {
await page.clock.install({
time: new Date('2026-07-13T14:59:59Z')
});
await page.setContent(`
<output></output>
<script>
const output = document.querySelector('output');
const render = () => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tokyo',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date());
const get = type => parts.find(part => part.type === type).value;
output.textContent =
get('year') + '-' + get('month') + '-' + get('day');
};
render();
setInterval(render, 1000);
</script>
`);
await expect(page.locator('output')).toHaveText('2026-07-13');
await page.clock.runFor(1_000);
await expect(page.locator('output')).toHaveText('2026-07-14');
});
Localized formatting can differ in punctuation and part order, even with a chosen locale. For strict business logic, expose or calculate a stable date key and assert it. For presentation tests, pin the environment and assert the exact approved string only when typography and order are requirements.
Month-end suites should include months with 28, 29, 30, and 31 days when the application calculates billing or schedules. Daylight-saving cases should specify the region and test the repeated or skipped local hour. Adding 86,400,000 milliseconds is not always the same as moving to the same local time tomorrow.
The broader Playwright Clock API tutorial explains how instants, context time zones, and system-time changes fit together.
10. Playwright clock API examples and best-practice matrix
Use this decision matrix during test design:
| Product behavior | Method | Avoid |
|---|---|---|
| Callback must execute for each interval | runFor() |
Jumping with fastForward() |
| Page wakes after a long gap | fastForward() plus reconciliation assertion |
Replaying thousands of irrelevant ticks |
| Stable date in a generated document | setFixedTime() |
Installing full fake timers without need |
| Freeze at a future point for inspection | pauseAt() |
Real sleeping until that point |
| Resume controlled progression | resume() |
Assuming time remains frozen afterward |
| User adjusts device clock | setSystemTime() |
Treating the adjustment as normal timer elapsed time |
Keep each test responsible for one time model. A countdown test that also changes time zone, mocks three APIs, and adjusts the device clock has too many failure causes. Create small tests for the scheduler, calendar rule, network recovery, and service integration.
Use semantic locators and web-first assertions after advancing time. The Playwright locator and assertion cheat sheet provides the corresponding patterns. Avoid fixed real sleeps, unawaited Clock calls, and assertions against implementation variables that users cannot observe.
In CI, report the starting instant, advancement, browser project, locale, and time zone. Keep test records unique across workers. If a test depends on an API, wait for its observable completion after advancing the timer. Clock executes the scheduled callback, but an asynchronous network operation may complete on its own promise chain.
Finally, verify server-owned time separately. A client Clock test is excellent evidence for UI scheduling and date rendering. It is not evidence that database retention, billing, queues, or authentication expiry works at the service boundary.
Interview Questions and Answers
Q: How would you test a five-minute debounce without waiting five minutes?
I would install Clock before the event listener is created, trigger input, run for one millisecond less than the threshold, and prove no work occurred. Then I would advance the final millisecond and assert one operation with the latest value.
Q: Which method should a countdown test use?
Usually runFor(), because each interval occurrence changes the displayed remaining value. I would assert an intermediate tick, zero, the enabled terminal action, and that the value does not continue below zero.
Q: How would you test an application returning from sleep?
I would use fastForward() when the intended model is a large jump without replaying every interval. Then I would trigger or observe the application's reconciliation and assert a value derived from the current absolute time.
Q: Why test one millisecond before a timer boundary?
It proves the behavior does not happen early and documents whether the threshold is inclusive. Testing only far beyond the threshold can miss an off-by-one or wrong-unit error.
Q: What should a polling test assert besides call count?
It should assert the user-visible state based on the latest response and that polling stops at a terminal result. Call count is supporting evidence for cadence and duplicate requests, not the product outcome by itself.
Q: Can Clock control a route fulfillment delay?
Clock controls page timers. Route fulfillment is test-runner and network orchestration, so I control it with the route handler or another explicit promise. Keeping the two controls separate makes race tests easier to understand.
Q: How would you test local midnight?
I would configure the browser context time zone, install an explicit UTC instant one tick before local midnight, advance across the boundary, and assert the intended local date behavior. I would avoid relying on the CI machine's time zone.
Q: What makes a Clock test production-worthy?
It installs before timers, names the time model, checks an exact boundary, asserts user-visible behavior, and isolates network or server time assumptions. It also records enough time-zone and browser context to diagnose CI failures.
Common Mistakes
- Copying a fake-timer method into a test without deciding whether time should run or jump.
- Installing Clock after the app has registered its timeout or interval.
- Using
fastForward()for a countdown that requires every tick. - Verifying only a callback counter and not the resulting UI or business state.
- Advancing a debounce once without proving later input resets it.
- Forgetting that async work started by a timer may finish after timer advancement.
- Treating browser time as backend, database, queue, or identity-provider time.
- Depending on the CI host's local time zone or locale.
- Testing only after a deadline, not immediately before and at the deadline.
- Combining several time models in one difficult-to-diagnose case.
- Waiting in real time after installing controlled timers.
- Sharing server records between parallel time-based tests.
Conclusion
The strongest playwright clock API examples map one method to one product behavior. Use runFor() when every scheduled occurrence matters, fastForward() for a time jump, setFixedTime() for deterministic wall time, and setSystemTime() for a device-clock adjustment. Always install before timer creation and assert the user's result.
Choose one slow or flaky timer test from your suite and rewrite it as a boundary test. Check just before the threshold, cross it with Clock, and verify the stable terminal state. That single pattern usually provides more confidence than minutes of real waiting.
Interview Questions and Answers
How would you test a long debounce quickly?
I install Clock before the handler is registered, trigger input, and advance to one tick before the quiet period. I prove no work occurred, reset the timer with later input, then cross the boundary and assert one operation with the latest value.
What assertions make a countdown test strong?
I check the initial or an intermediate display, the exact zero boundary, and the action enabled at completion. I then advance farther and prove the interval stopped. This covers both calculation and cleanup.
How do you verify polling behavior?
I control or observe responses, use runFor so each interval executes, and assert the user-visible terminal state. I also verify request count when cadence matters and advance afterward to ensure polling stops.
Why is fastForward suitable for a sleeping page scenario?
A sleeping or suspended page may return after a large gap without executing every scheduled interval. fastForward models a jump by firing due timers at most once. The application should then reconcile from absolute time or fresh server state.
How do you test autosave reliably?
I make continued editing reset the delay, prove nothing saved before the final boundary, and verify only the latest content is sent. I control response completion separately if the save has an in-flight state.
What does a client expiry example not prove?
It does not prove that a backend, database, or identity provider agrees that the credential expired. Browser Clock only controls the page. I add service-level evidence with explicit expiry data or a supported server clock.
How would you test local midnight?
I pin timezoneId, install an explicit UTC instant just before local midnight, and cross the boundary by the smallest needed duration. I assert the relevant local date rule and avoid depending on host settings.
What should a Clock test log in CI?
It should expose the starting instant, advancement method and duration, browser project, time zone, locale, and safe test-data identity. That context helps distinguish a wrong time model from application readiness or shared-state failures.
Frequently Asked Questions
How do I test a countdown with Playwright Clock?
Install Clock before the interval starts and use runFor() so every tick executes. Assert an intermediate value, zero, the terminal action, and that later advancement does not move below zero.
How do I test debounce in Playwright?
Trigger input, advance to just before the quiet-period boundary, and prove no operation occurred. Send later input, advance the complete quiet period, and assert exactly one operation with the final value.
Should polling tests use runFor or fastForward?
Use runFor() when each polling interval should make a request. After the terminal response, advance again and prove polling stopped.
How do I test a page returning after sleep?
fastForward() is often the closer model because it jumps without replaying every missed interval. Assert that the application reconciles its state from the current absolute time.
Why should timer tests check one millisecond before the threshold?
The check proves the behavior does not happen early and documents an inclusive or exclusive boundary. A test far beyond the threshold can miss unit conversion and off-by-one defects.
Can a Clock test wait for an API response?
Clock can schedule the page callback that starts the request, but network completion has its own promise. Route or observe the API explicitly, then assert the user state after both operations complete.
How do I test daylight-saving behavior?
Pin a region-specific timezoneId and use explicit instants around the skipped or repeated local hour. Do not assume that adding 24 hours always produces the same local time tomorrow.
Related Guides
- Playwright mock date and time: Examples and Best Practices
- Playwright drag and drop: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices
- Playwright APIRequestContext: Examples and Best Practices
- Playwright aria snapshot: Examples and Best Practices