Playwright Clock API: Fake Timers in TypeScript
Most QA suites treat time as something they just wait through. An OTP that expires in 30 seconds, a session that logs out after 10 idle minutes, a debounced search box that fires one request after 300 ms of silence. Test these the naive way and you either add real waitForTimeout calls that turn a 3-minute suite into a 20-minute one, or you mock timers by hand and end up with flaky, hard-to-read code. The Playwright Clock API solves this cleanly. It lets you freeze, fast-forward, and rewind the browser clock so every time-dependent feature becomes a deterministic, millisecond-fast test.
Table of Contents
- Why Real Time Breaks Your Test Suite
- What the Playwright Clock API Is
- Install the Clock Before You Navigate
- fastForward vs runFor: The Difference Everyone Misses
- Freeze and Tick: pauseAt and resume
- Test 1: OTP Countdown and Resend
- Test 2: Session Idle Timeout
- Test 3: Debounced Search
- Six Playwright Clock API Pitfalls That Will Bite You
- Why SDET Interviews in India Now Ask About Time
- Key Takeaways
- FAQ
Contents
Why Real Time Breaks Your Test Suite
I see the same three patterns in almost every legacy Playwright codebase I review. First, the waitForTimeout trap. A developer needs an OTP resend button to become clickable after 30 seconds, so they write await page.waitForTimeout(30_000) and move on. That single line costs 30 real seconds on every run, and when you have forty tests doing it, your CI pipeline crawls.
Second, the manual fake-timer trap. Developers stub Date.now or monkey-patch setTimeout inside the browser context. This works until it does not, because the page under test has its own timers, its own requestAnimationFrame loops, and its own performance.now calls that a hand-rolled stub never covers. You end up asserting against half-faked time and getting intermittent failures.
Third, the flaky-by-design trap. A debounced search box expects the user to pause for 300 ms before the request fires. If your test types and asserts instantly, it passes only when the machine happens to be fast enough. That is not a test, that is a lottery ticket.
The real cost is not the slow runs, it is the tests teams delete because they “cannot be made reliable.” Time-dependent logic is exactly the kind of code that ships real bugs to users, from an OTP that expires too early to a session that never logs out. Skipping it is how you miss the bug that logs a banking customer out mid-transaction.
Here is the arithmetic that should settle it. If thirty login tests each wait 30 real seconds for an OTP countdown, that is 15 minutes of wall-clock time per run. Run the suite five times a day across three developers and you are burning over three hours daily on waits that test nothing. The Clock API turns those same thirty tests into a few hundred milliseconds of virtual time, with the exact same assertions, and no dependence on machine speed.
What the Playwright Clock API Is
The Playwright Clock API is a set of methods on page.clock that give you full control over the browser’s sense of time. It was introduced in Playwright 1.45 and is now standard in the 1.62 line. The official Clock guide documents every method, and the project itself sits at roughly 94,700 stars on GitHub with over 208 million monthly downloads of @playwright/test on npm. Instead of waiting for real seconds, you tell the clock to jump forward, freeze, or tick through time manually, and the page behaves exactly as if that much time had really passed.
The mental model is simple. You are not stubbing one function. You are installing a fake clock that replaces every time source the page can read, so the app and the test agree on what “now” means. That agreement is what makes these tests deterministic.
What the Clock Actually Controls
When you install the clock, Playwright takes over the timer and date primitives the page uses:
Dateandnew Date(), so the app reads the fake timesetTimeoutandsetInterval, so scheduled callbacks fire on your commandrequestAnimationFrameandrequestIdleCallback, so animation and idle loops stay in syncperformance.now(), so performance measurements are consistent
This is the key difference from a hand-rolled sinon.useFakeTimers approach. The Playwright clock runs inside the browser context, so it controls the page’s real environment, not a shim layered on top of your test code.
Concretely, that means a page that uses requestAnimationFrame for a spinner, a setInterval for a live price ticker, and Date.now() for an “updated 5 seconds ago” label all read from the same fake clock, in perfect sync. There is no drift between what the app thinks the time is and what your assertion expects, which is exactly where hand-rolled stubs fall apart.
install, setFixedTime, and setSystemTime
There are three ways to set the starting time, and they solve different problems:
page.clock.install({ time })installs the fake clock and lets time flow forward naturally from a starting point. Timers fire as the clock advances. This is what you use most of the time.page.clock.setFixedTime(time)makesDate.now()andnew Date()always return the same fixed value while real timers keep running in the background. Use this when you only need a stable date, not timer control.page.clock.setSystemTime(time)sets the system time without triggering any timers. Use it when you want the page to read a different “now” but do not want scheduled callbacks to fire.
I reach for install in about 90% of cases. The other two matter for specific scenarios like testing a countdown that should not actually tick, which I will cover below.
Here is setFixedTime in action on a date-dependent feature. Say the app shows a subscription renewal date that must stay pinned while you verify the rest of the page:
// Pin the date, keep real timers running
await page.clock.setFixedTime(new Date('2026-08-20T10:00:00'));
await page.goto('https://example.com/account');
await expect(page.getByText('Renews on Aug 20, 2026')).toBeVisible();
With setFixedTime, every new Date() call returns that instant, so a renewal banner, a “valid until” stamp, or a date-sensitive price is deterministic even though background timers still run normally.
The single most common mistake is installing the clock after page.goto(). By then the page has already loaded, read Date.now(), and scheduled its first timers against the real clock. Your fake clock controls nothing from that point because the page is no longer using it.
The rule is absolute: install the clock first, then navigate.
import { test, expect } from '@playwright/test';
test('page reads the fake start time', async ({ page }) => {
// 1. Install the clock BEFORE navigation
await page.clock.install({ time: new Date('2026-08-20T08:00:00') });
// 2. Now navigate; the page loads against the fake time
await page.goto('https://example.com/dashboard');
// 3. Assert the page shows the fake time
await expect(page.getByTestId('current-time'))
.toHaveText('8/20/2026, 8:00:00 AM');
});
If you have several tests that all need a frozen start time, put the install in a fixture so it runs before every test in the file, then navigate inside each test. That keeps the ordering guaranteed without you having to remember it every time.
import { test as base, expect } from '@playwright/test';
// Override the page fixture so the clock is installed before navigation
export const test = base.extend({
page: async ({ page }, use) => {
await page.clock.install({ time: new Date('2026-08-20T08:00:00') });
await use(page);
},
});
test('page loads against a frozen start time', async ({ page }) => {
await page.goto('https://example.com/dashboard');
await expect(page.getByTestId('current-time'))
.toHaveText('8/20/2026, 8:00:00 AM');
});
This one fixture removes the ordering footgun for an entire file. Any test that needs a different start time can still call page.clock.install() or setFixedTime again to override the default before it navigates.
fastForward vs runFor: The Difference Everyone Misses
Both methods advance the clock, but they answer different questions, and mixing them up is the source of half the confusion I see.
page.clock.fastForward(ticks)jumps forward in time. Each timer that becomes due during the jump fires at most once. Think of it as closing a laptop lid and reopening it ten minutes later, the page catches up to “now” but does not replay every intermediate tick.page.clock.runFor(ticks)ticks through time manually, firing every intermediate timer in order. Think of it as sitting and watching the clock tick, every scheduled callback fires exactly as it would have in real time.
Both accept a number of milliseconds or a human-readable string like '05:00' or '01:30:00'.
// Jump forward 30 seconds; due timers fire once
await page.clock.fastForward('00:30');
// Tick through 30 seconds; every intermediate timer fires
await page.clock.runFor(30_000);
Here is the practical rule. When the page has a setInterval running a countdown every second, fastForward('00:30') fires the interval callback once and lands you at the 30-second mark. runFor(30_000) fires it thirty times, once per virtual second. If your assertion is “after 30 seconds the button is enabled,” use fastForward. If your assertion depends on the countdown label decrementing thirty times, use runFor.
Freeze and Tick: pauseAt and resume
pauseAt and resume give you manual control over the clock. pauseAt(time) jumps the clock to a specific moment and freezes it there. No timers fire while time is paused. resume() lets time flow naturally again.
This matters when you need to inspect the page at an exact instant without anything changing underneath you. A countdown label that updates every second is the classic case. Pause at 10:00:00, assert the label, then tick forward two virtual seconds and assert again.
test('countdown label ticks exactly two seconds', async ({ page }) => {
await page.clock.install({ time: new Date('2026-08-20T08:00:00') });
await page.goto('https://example.com/flash-sale');
// Freeze at a precise moment
await page.clock.pauseAt(new Date('2026-08-20T10:00:00'));
await expect(page.getByTestId('sale-timer')).toHaveText('00:00:00');
// Tick through two seconds, firing every timer in order
await page.clock.runFor(2000);
await expect(page.getByTestId('sale-timer')).toHaveText('00:00:02');
// Let time flow again
await page.clock.resume();
});
Notice the combination: pauseAt freezes, runFor ticks while frozen, and resume returns to natural flow. You can also keep using runFor and fastForward while paused without ever resuming, which keeps the entire test deterministic from the first line to the last.
Test 1: OTP Countdown and Resend
Every login flow in a modern app, especially in India where OTP is the default for banking, UPI, and government portals, has a countdown before you can resend the code. This is the perfect first clock test because it is small, real, and instantly shows the payoff.
import { test, expect } from '@playwright/test';
test('resend OTP enables after 30 seconds', async ({ page }) => {
await page.clock.install({ time: new Date('2026-08-20T10:00:00') });
await page.goto('https://example.com/login');
// Start the countdown
await page.getByRole('button', { name: 'Send OTP' }).click();
const resend = page.getByRole('button', { name: 'Resend OTP' });
// During the countdown, resend is disabled
await expect(resend).toBeDisabled();
await expect(page.getByText('Resend in 00:30')).toBeVisible();
// Jump forward 30 seconds, no real waiting
await page.clock.fastForward(30_000);
// Countdown finished, resend is clickable
await expect(resend).toBeEnabled();
});
This test runs in under a second. The same test with waitForTimeout(30_000) adds 30 seconds to every run. Across a regression suite that logs in fifty times, that is 25 minutes of pure waiting turned into nothing. If you want the deeper treatment of OTP flows, I have a dedicated Testing 2FA and OTP Flows in Playwright guide that covers the full two-factor setup.
Test 2: Session Idle Timeout
Session idle timeout is the test teams skip because “ten minutes is too long to wait.” With the clock, ten minutes is instant.
test('logs out after 10 minutes of inactivity', async ({ page }) => {
await page.clock.install({ time: new Date('2026-08-20T10:00:00') });
await page.goto('https://example.com/dashboard');
// Do nothing, then jump forward 10 idle minutes
await page.clock.fastForward('10:00');
await expect(page.getByText('You have been logged out due to inactivity.'))
.toBeVisible();
await expect(page).toHaveURL(/\/login$/);
});
Two things to note. First, use fastForward here, not runFor, because you do not care about intermediate ticks, you only care about the state after ten idle minutes. Second, this pattern pairs naturally with the session handling I cover in the Session and JWT Token Testing in Playwright guide. Test the expiry with the clock, then verify the token cleanup separately.
Test 3: Debounced Search
Debounced search is where teams burn hours on flaky tests. The component waits 300 ms of silence after the last keystroke before firing a request. Type, then assert immediately, and your test passes or fails based on machine speed.
test('debounced search fires one request after typing stops', async ({ page }) => {
await page.clock.install();
await page.goto('https://example.com/search');
await page.getByPlaceholder('Search products').fill('playwright');
// No request yet, the debounce window is still open
await expect(page.getByTestId('loading')).not.toBeVisible();
// Tick through the 300ms debounce window
await page.clock.runFor(300);
// One request fired after the pause
await expect(page.getByTestId('result-count')).toHaveText('42 results');
});
Because runFor ticks through time, the debounce timer fires exactly once, in order, just as it would in a real browser. There is no waitForTimeout, no polling loop, and no dependence on how fast the CI machine happens to be.
Six Playwright Clock API Pitfalls That Will Bite You
I have made most of these mistakes so you do not have to.
- Installing the clock after navigation. The page already bound to real time. Install first, always.
- Forgetting that the clock replaces all timers, not just yours. The app’s own
setInterval, animation frames, and idle callbacks are also under your control. If a spinner relies onrequestAnimationFrame, you may need torunFora frame or two for it to render. - Using
fastForwardwhen you need every intermediate tick. If the UI updates on each interval tick and your assertion checks an intermediate state,fastForwardwill skip those states. UserunFor. - Mixing
pauseAtwith a forgottenresume. Once youpauseAt, time is frozen and timers stop firing until you callfastForward,runFor, orresume. A test that pauses and then waits on a timer hangs forever. - Asserting a Date string with the wrong locale or timezone. The clock controls time, not formatting. If the page formats dates using the browser locale, set the locale and timezone on the context so the string matches what you expect.
- Leaving the clock installed across unrelated tests. Each test gets a fresh context, so this is rarely an issue, but if you reuse a context manually, reset or reinstall the clock to avoid leaking frozen time into the next test.
Why SDET Interviews in India Now Ask About Time
Time-dependent testing went from a niche topic to an interview staple for one reason: every high-traffic Indian app runs on OTP, sessions, and real-time features. UPI payments, IRCTC booking, banking portals, e-commerce flash sales, they all have countdowns and expiry windows that directly affect revenue. A candidate who can test an OTP expiry deterministically stands out immediately.
I tell my students to frame it as a reliability story, not a tool demo. “I cut my login regression from 20 minutes to 4 by replacing waitForTimeout with the Playwright Clock API” is a concrete, interview-ready answer that also signals you understand test economics. Interviewers at product companies often follow up with a hands-on scenario: “an OTP expires after two minutes, write a test for the resend button.” If you can reach for install and fastForward on the spot, you are already ahead of most candidates.
In my experience, SDET roles at product companies and well-funded startups now expect this, and the compensation for the skill sits in the same ₹15 to 35 LPA band as other advanced Playwright specializations, higher when you pair it with CI/CD and framework design. If you are building that base, the Browser Contexts and Retries and Flaky Tests tutorials are the natural next reads.
Key Takeaways
- The Playwright Clock API controls
Date,setTimeout,setInterval,requestAnimationFrame, andperformance.nowso time-dependent tests become deterministic. - Always
page.clock.install()beforepage.goto(), never after. - Use
fastForwardto jump ahead (timers fire once) andrunForto tick through time (every timer fires in order). - OTP countdowns, session idle timeouts, and debounced search are the three highest-value tests to convert first.
- Replacing
waitForTimeoutwith the clock is a reliability win and a strong interview talking point.
FAQ
Does the Playwright Clock API work with real network requests?
Yes. The clock controls time inside the browser, not network latency. API calls still happen in real time, so pair the clock with page.route mocking when a time-dependent response depends on a server round trip.
Can I set a specific date without controlling timers?
Yes, use page.clock.setFixedTime(time). It makes Date.now() and new Date() return a fixed value while real timers keep running normally.
What is the difference between pauseAt and setSystemTime?
pauseAt freezes time and stops timers from firing until you advance or resume. setSystemTime changes the system time the page reads without triggering any timers. Use pauseAt for manual control, setSystemTime to just shift the clock.
Does fastForward fire setInterval callbacks multiple times?
No. fastForward fires each due timer at most once, even if the interval would have fired many times during the jump. Use runFor if you need every interval tick.
When was the Clock API added?
It shipped in Playwright 1.45 and is fully supported in the current 1.62 releases, which now power over 208 million monthly downloads on npm.
How do I test a timer that waits on a server response?
Combine the clock with network interception. Use page.route to stub the API response, then use runFor or fastForward to advance the clock so the retry or timeout logic fires. The clock controls browser time, and the route controls network data, so together they cover the full flow without real latency or real waiting.
