Design a countdown timer
A reusable countdown for sales that end, auctions, launches, resend buttons, exams and focus timers. Showing a number that goes down looks trivial, but the hard parts are hidden: timers that drift and get throttled in background tabs, device clocks that are wrong, deadlines that move, an expiry that must fire exactly once, server rendering that is stale on arrival, and a display that a screen reader can live with.
The question
Design a countdown timer component for our design system. Product teams want it for flash sales ("ends in 2h 14m"), auction cards, launch pages, the resend button on the code screen, and a 25-minute focus timer in our productivity app.
Walk me through how it keeps accurate time, what happens when the tab is in the background or the user's clock is wrong, how it formats and announces the time, how it behaves on the server and at expiry, and what its API looks like.
Real-life examples: The "Deal ends in 02:14:09" banner on Amazon Lightning Deals, eBay auction end times, Product launch and ticket sale pages, "Resend code in 0:30" on a sign-in screen, Online exam timers, Pomodoro apps like Forest and Focus To-Do
Requirements: which countdowns are we building?
A countdown timer shows how much time is left until something happens: "Deal ends in 02:14:09" on a shopping site, "Auction ends in 3m 12s", "Resend code in 0:30", or a 25:00 focus timer that you start and pause. They all show a number going down, but they are not the same problem, and the first job in the interview is to find out which ones we must support.
Neighbouring guides already cover parts of this, and we link to them rather than repeat them: the online judge guide estimates the server clock offset from several round trips, the flight booking and seat map guides cover hold timers and what the server does when a hold lapses, and the OTP input guide covers the resend cooldown. This guide is the reusable component under all of them.
Start with questions. Each answer changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Do we count to a fixed moment, or for a length of time? | Both: deadlines (a sale ends at 2026-11-28T07:59:59Z) and durations (25 minutes, pausable) | Two models with different clocks and different controls |
| Does anything important depend on the end? | Yes for auctions and sales: the server decides. No for a focus timer | Whether we need a server time offset and a server check at expiry |
| How precise must the display be? | Whole seconds. Sub-second only for a rare "last 10 seconds" mode | One update per second, aligned to the second; no animation frames by default |
| How many timers on one page? | Up to about 50 (a deals grid or an auction list) | One shared ticker instead of 50 intervals |
| Is the page server-rendered? | Yes, product and sale pages are SSR for speed and SEO | Hydration mismatch and stale first paint must be handled |
| Which locales? | About 20, including Arabic and Hebrew (right to left) | Units, plurals and digits come from Intl, not string concatenation |
| Can the deadline move? | Yes: auctions extend by 2 minutes when a late bid arrives | The target is data that can change while the timer runs |
| Is it a session timeout? | Sometimes: "you will be signed out in 2:00" | WCAG 2.2.1 Timing Adjustable: warn and let users extend |
Functional requirements
- Count down to an absolute deadline, given as an ISO instant from the server.
- Count down a duration, with start, pause, resume and reset.
- Show days, hours, minutes and seconds in several styles:
02:14:09,2h 14m 9s,2 hours 14 minutes, and a coarse "Ends in 3 days". - Fire
onExpireexactly once when time runs out, even if the tab was asleep at that moment. - Accept a new deadline while running (auction extension) without remounting.
- Optional milestones: callbacks and screen reader announcements at, say, 5 minutes and 1 minute left.
Non-functional requirements
- Correct within one second of the server's view of time for deadlines, even when the device clock is minutes off.
- No drift: after 25 minutes the duration timer is still within a few milliseconds of real time.
- Flips on the second: the digits change at the same moment as a wall clock, not up to 999 ms late.
- Cheap: 50 timers on a page cost one timer callback per second and one short render each, well under 1 ms of main thread work per tick on a mid-range phone.
- No layout shift: digits keep a fixed width (
tabular-nums), and the server-rendered HTML has the same size as the live one. - Accessible: keyboard reachable controls, no announcement every second, warnings before a session ends.
Out of scope
- Deciding who won an auction or whether a sale price still applies: that is the server's job. The timer only shows time.
- Clock offset estimation across many samples: the online judge guide covers that NTP-style estimate. We reuse a single offset value here.
- Push notifications when a sale ends while the page is closed.
Check your understanding · Exercise 1
Which answers change the design of the timer itself, not only its styling? Select all that apply.
What makes a countdown hard
The first version everyone writes is five lines: start at 60, setInterval every 1000 ms, subtract one, render. It passes a demo. It fails in production in at least five ways, and each one drives a later chapter.
- Timers are late, never early.
setInterval(fn, 1000)means "no sooner than 1000 ms". A busy main thread or a long render makes ticks late, and a counter that subtracts one per tick never makes up the difference. - Hidden tabs barely run timers. To save battery, browsers throttle background timers; Chrome goes down to once a minute after 5 minutes hidden. A tick-counting timer hidden for 10 minutes gets about 305 ticks, so it thinks about 5 minutes passed.
- The device clock is not the truth.
Date.now()reads the user's clock, which can be seconds or minutes off, sometimes on purpose. A sale that ends at midnight server time then ends early or late on that device. - The end is an event, not a number. When time runs out, something must happen exactly once (disable the bid button, call the server), even if the tab was asleep at that moment.
- The first render happens on a server. By the time server HTML that says "2:14:09 left" hydrates, a second or two passed, so the server HTML and the client's first render disagree.
Two more pressures: announcing every change makes a ticking number unbearable for screen reader users, and 50 deals with 50 intervals waste battery and flip out of step.
Underneath sits one modelling choice: own a number that goes down, or own a target moment and compute the time left when needed? Get that right and drift, background tabs and sleeping laptops mostly fix themselves.
Think first
A user starts a 25-minute focus timer, switches to another tab for 12 minutes, then comes back. What should the display show, and what does the five-line setInterval version show?
It should show about 13:00 left, because 12 real minutes passed. The naive version subtracted one per tick it actually received. In Chrome that is about 300 ticks in the first 5 hidden minutes, then about one a minute, so roughly 307 ticks: it shows about 19:53 left.
The fix is not a faster timer. It is to stop counting ticks and compute the time left from the clock on every tick.
Two kinds of countdown: store the target, never decrement
There are two kinds of countdown, and they look identical on screen. A deadline countdown counts to a fixed instant that exists outside the page: the sale ends at 2026-11-28T07:59:59Z for everyone in the world. You can't pause it, and a reload doesn't change it.
A duration timer counts a length of time that the user owns: 25 minutes from when they press Start, paused when they press Pause. It only survives a reload if we saved it.
The naive model for both is a number of seconds left that a timer decrements. As the previous chapter showed, every late or skipped tick is lost time. The question is what to store instead.
Decision What state does the timer keep?
What we pick: Store the target instant and compute remaining = target - now on every read. The timer callback becomes a repaint signal with no state of its own, so a late or skipped tick can leave the display stale for a moment but never wrong once it runs. Durations use the same idea: running means "ends at X", paused means "Y ms left".
The model in code
type Countdown =
| { kind: "deadline"; targetMs: number } // server instant, epoch ms
| { kind: "duration"; status: "running"; endsAt: number } // performance.now() ms
| { kind: "duration"; status: "paused"; remainingMs: number }; // also before the first start
function remainingMs(c: Countdown, now: Clocks): number {
if (c.kind === "deadline") return Math.max(0, c.targetMs - now.server());
if (c.status === "running") return Math.max(0, c.endsAt - now.mono());
return c.remainingMs;
}
function pause(c: Countdown, now: Clocks): Countdown {
if (c.kind !== "duration" || c.status !== "running") return c;
return { kind: "duration", status: "paused", remainingMs: Math.max(0, c.endsAt - now.mono()) };
}
function resume(c: Countdown, now: Clocks): Countdown {
if (c.kind !== "duration" || c.status !== "paused") return c;
return { kind: "duration", status: "running", endsAt: now.mono() + c.remainingMs };
}
Clocks is a small object with two functions. server() returns the device time corrected by the server offset, for deadlines. mono() returns performance.now(), a monotonic clock: it only moves forward and is not changed when the user or the operating system adjusts the wall clock.
That split matters. If a laptop syncs its clock and jumps back 40 seconds, a duration timer built on Date.now() suddenly gains 40 seconds; one built on performance.now() does not.
Last, rounding. Countdowns round up with Math.ceil, so the display shows 0:01 until the last millisecond and reaches 0:00 exactly at expiry. With Math.floor it would show 0:00 for a full second while the button is still enabled, which looks like a bug.
Check your understanding · Exercise 2
A focus timer stores endsAt from performance.now() while running. Which statements are true? Select all that apply.
Ticking: drift, background tabs, and flipping on the second
With the target stored, the timer callback has one job: wake up when the display needs to change, and repaint. Get the timing wrong and digits flip 600 ms after the wall clock, or two cards flip at different moments.
Why counting ticks drifts
Timer callbacks run on the main thread, in between other tasks. setTimeout(fn, 1000) means "run fn once 1000 ms have passed and the thread is free", so a long task (a 120 ms React render, a 60 ms JSON parse) makes the callback wait. When an interval falls far behind, browsers do not queue up the missed calls: they are dropped. A decrementing counter loses every one of those dropped ticks for good.
Background tabs make this dramatic. In Chrome, timers on a hidden page are checked at most once per second. Since Chrome 88, intensive throttling goes further: once a page has been hidden for more than 5 minutes and silent for 30 seconds, without WebRTC, chained timers (a setInterval, or a setTimeout chain five or more deep) are checked only once per minute.
Other browsers differ in the details. MDN documents a 1 second minimum in background tabs for desktop Firefox and up to 15 minutes for Firefox on Android, and Safari also throttles and suspends background pages. A design that only works when timers fire on time does not work.
visibilitychange.Decision How does the component schedule repaints?
What we pick: Use a setTimeout aligned to the next change of the displayed value. It flips on the second, wakes up only when the output changes, and recovers from any lateness on its own. Use requestAnimationFrame only in the rare sub-second mode (for example, the last 10 seconds of an auction showing tenths), and switch back after expiry.
function scheduleNext(getRemaining: () => number, unitMs: number, render: () => void) {
let id = 0;
const tick = () => {
render();
const left = getRemaining();
if (left <= 0) return; // expiry is handled by the state machine
// With Math.ceil display, the value changes when left crosses a multiple of unitMs.
const delay = left % unitMs || unitMs;
id = window.setTimeout(tick, delay + 1);
};
tick();
return () => clearTimeout(id);
}
The extra 1 ms is a guard against rounding between the timer's clock and the one getRemaining reads: waking a hair before the boundary would show the old value and cost an extra wakeup. Coarse displays become almost free: "Ends in 3 days" changes once a day, so the next wakeup is hours away. The delay is a signed 32-bit number of ms, so anything over about 24.8 days overflows and fires at once. Cap the delay at, say, one hour and recompute.
Advance time, hide the tab, or make the main thread busy.
Coming back from a hidden tab
Even the aligned timeout is throttled in a hidden tab. That is fine, because nobody is looking; what matters is the moment the user comes back. Listen for visibilitychange: when document.visibilityState becomes "visible", cancel the pending timeout and run tick() at once.
The browser may first show the frame it painted before the tab was hidden, but the next frame is correct. If the deadline passed while hidden, expiry runs now, and the state chapter makes sure it runs once. The same trick covers pageshow for pages restored from the back/forward cache, where timers were frozen.
Check your understanding · Exercise 3
A sale banner uses the aligned setTimeout and computes from the target. The user hides the tab for 20 minutes, then returns. Which are true? Select all that apply.
The device clock is wrong: whose time counts?
A deadline countdown computes target - now. The target comes from the server and is right; the now from Date.now() comes from the user's device. Most devices sync with a time server and are within a fraction of a second. Some are not: a phone with automatic time turned off, a desktop with a broken sync, a user who moved the clock to cheat a game, and across millions of users that is a lot of people.
Picture a flash sale that ends at 18:00:00 server time. A phone that is 3 minutes fast shows 0:00 at 17:57 real time and says "Sale ended" while other users are still buying. A phone that is 3 minutes slow keeps a "Buy now" button for 3 minutes after the end, and every tap fails at checkout.
The device clock is 3 minutes fast. The page measured the offset when it loaded.
Decision How does the client know what time it is on the server?
What we pick: Return serverNow next to every deadline, derive one page-wide offset from the round trip, and compute deadlines with Date.now() + offset. A single sample is accurate to roughly half the round trip, which is far under the one-second display unit. When a contest needs better, take several samples and keep the one with the shortest round trip, as the online judge guide does.
now = Date.now() + offset.Two refinements keep the offset honest. First, re-sample on visibilitychange after a long hide and after the device wakes: the device clock may have been corrected in the meantime, which changes the offset. Second, keep the offset in one shared module, not per component, so all 50 cards agree on what time it is.
When it matters, the server decides
However good the offset, the countdown is a display, not the authority. If the timer says 0:01 and the user bids, the server checks its own clock and may answer "auction closed". If the timer says 0:00 but a late bid extended the auction, the server knows.
So the client rules are: never block a request only because the local timer says time is up (send it and show the server's answer), and never treat the local expiry as final for anything with money or fairness attached. The local expiry changes the UI; a server response or push message confirms it.
Check your understanding · Exercise 4
An auction card computes the offset once from serverNow with a 200 ms round trip. Which statements are true? Select all that apply.
States, expiry exactly once, moving deadlines, and the display
A countdown is not just a number; the UI around it depends on its state. A launch page shows "Starts in" before the sale and "Ends in" during it, a focus timer shows Start, Pause or Resume, and an ended auction shows the winning bid. A few booleans (isRunning, isPaused, hasEnded) allow impossible mixes like paused and ended at once, so a small state machine makes the legal states and moves explicit.
| State | Meaning | What the UI shows |
|---|---|---|
upcoming | Now is before startsAt (a sale that hasn't opened) | "Starts in 2d 4h", button disabled |
running | Counting toward the target | The time left, live |
paused | Duration timers only; remainder frozen | Frozen time, Resume button |
expired | Local time reached zero | "Ending..." for server-owned deadlines, or the final state for local ones |
ended | The server confirmed the end | "Sale ended", the result, no live digits |
Why both expired and ended? Because the client shows time and the server decides it. For a focus timer there is no server, so expired is final and plays the chime. For an auction, local zero means "probably over, let's check": the card shows "Ending...", disables bidding and asks the server (or waits for its push), and only the server's answer moves it to ended, or back to running if the deadline moved.
onExpire exactly once
Expiry runs from three places: a normal tick that sees zero, the visibilitychange handler when the user returns after the deadline, and the very first render if the page loads after the deadline. Without a guard, a tick and the visibility handler can both see zero in the same second and fire onExpire twice, sending two requests or playing two chimes. With a naive "only when it crosses zero" check, a page loaded after the deadline never crosses and fires zero times.
let firedFor: number | null = null;
function checkExpiry(targetMs: number, left: number, onExpire: (lateMs: number) => void) {
if (left > 0 || firedFor === targetMs) return;
firedFor = targetMs;
// How long ago it really ended: 0 on a normal tick, minutes after a long hide.
const lateMs = serverNow() - targetMs;
onExpire(lateMs);
}
Passing lateMs lets the caller react sensibly. A focus timer that ended 40 minutes ago while the laptop slept should not play a loud chime when the lid opens; it should show "Finished 40 min ago". An OTP resend cooldown just enables the button, late or not.
When the deadline moves
Many auctions use a soft close: a bid in the last 2 minutes pushes the end out by 2 minutes, so nobody can win by sniping in the final second. Because the timer stores only the target, accepting the new end is one assignment: set targetMs and reschedule the next tick. No remount, no flash of a reset timer.
- Only move forward on server data. Take the new
endsAtfrom the server message, never from a local guess, and ignore messages older than the version we have, since a reconnect can replay them out of order. - Leave
expiredif the target moved past now. A card that showed "Ending..." goes back torunning, and the expiry guard allows the new target to fire later. - Tell people. A visual cue ("Extended +2:00") and a polite screen reader message, since a jump from 0:05 to 2:05 is confusing otherwise.
Check your understanding · Exercise 5
An auction card reaches 0:00 locally. Which behaviours are right? Select all that apply.
Formatting: units, locales, fixed-width digits and time zones
Each state shows text, and the time left is a number of milliseconds. Turning it into something a person reads takes three decisions: which units to show, how to spell them in each language, and how to keep the text from wobbling as it changes. The Dates, numbers, and plurals lesson covers Intl in general; here is what matters for countdowns.
Which units, at which distance
First split the time into parts: with s = ceil(ms / 1000), days = floor(s / 86400), hours = floor(s % 86400 / 3600), and so on. Then choose what to show by distance: seconds on a sale 9 days away create urgency nobody asked for and wake the page every second for nothing. A good default ladder, where each rung's unit is the unitMs from the ticking chapter:
| Time left | Shown as | Repaint every |
|---|---|---|
| More than 2 days | "Ends in 9 days" | Day (wakeups capped at one hour) |
| 2 hours to 2 days | "1d 14h" or "14h 20m" | Minute |
| Under 2 hours | 01:59:59 or "1h 59m 59s" | Second |
| Last 10 seconds, opt-in | 0:09.4 | Animation frame |
Decision How do we turn parts into localized text?
What we pick: Use Intl.DurationFormat when it exists and fall back to Intl.NumberFormat units joined with Intl.ListFormat. Feature-detect with "DurationFormat" in Intl, create each formatter once per locale and style (construction costs far more than a format call), and keep the surrounding sentence ("Ends in {time}") in the translation files.
Stop the jitter: tabular-nums
In most fonts, digits have different widths: a 1 is narrower than an 8. A ticking 11:11 to 10:58 changes width every second, so the text and anything next to it shift sideways. Set font-variant-numeric: tabular-nums so every digit takes the same width, and if the font lacks tabular figures, reserve the width with min-width in ch units. The Color, contrast, and motion lesson covers why moving text is hard to read.
Right-to-left and digits
In Arabic and Hebrew pages the sentence runs right to left, but a digital time such as 01:59:59 must still read hours first. Wrap the time in a bdi element (or give it unicode-bidi: isolate) so the bidi algorithm doesn't reorder its parts relative to the sentence. Let Intl choose the digits: new Intl.NumberFormat("ar-EG").format(25) gives Arabic-Indic digits ٢٥, while many other Arabic locales use Latin digits. Never hard-code either.
Time zones and daylight saving
Product teams describe deadlines in wall-clock time: "ends at midnight Pacific". That is ambiguous until pinned to a zone and a date, so the server converts it once with the IANA time zone database and the API ships an instant: 2026-11-28T07:59:59Z. The client only ever subtracts instants, which has no time zone or daylight saving problems.
Time zones come back when we show the end as a time next to the countdown. Format the instant in the zone the business means, and label it: Intl.DateTimeFormat("en-US", { timeZone: "America/Los_Angeles", weekday: "short", hour: "numeric", minute: "2-digit", timeZoneName: "short" }) gives "Fri, 11:59 PM PST". Adding the user's own zone avoids "I thought midnight meant my midnight".
Daylight saving bites when counting days. US clocks fall back on Sunday 1 November 2026, so a sale from Saturday 00:00 to Monday 00:00 Pacific spans two calendar days but 49 real hours. A countdown measures elapsed time, so "2d 1h" at the start is right. If the product wants "Ends Monday", show that as a date, not as a countdown.
Check your understanding · Exercise 6
Which formatting choices are sound for a countdown used in 20 locales? Select all that apply.
Server rendering: the time is stale on arrival
Sale and launch pages are usually server-rendered for a fast first paint and for search engines (the Server-side rendering lesson covers the trade-offs). A countdown is the one thing on that page that is wrong the moment it is rendered. The server computes "2:14:09" at 12:00:00.000, the HTML reaches the phone at 12:00:00.400, and the JavaScript hydrates at 12:00:02.100. If a CDN caches the HTML for 60 seconds, the first paint can be a full minute off.
Hydration adds a second problem: React expects the first client render to match the server HTML. The client computes "2:14:07", the server sent "2:14:09", and React reports a hydration mismatch. The naive fixes each fail somewhere:
Decision What does the server put in the HTML for the countdown?
What we pick: Render the target, not the remainder. The server sends facts that stay true (the end instant and a fixed-width placeholder); the client fills in the only value that depends on the moment of viewing. In React, useSyncExternalStore with a getServerSnapshot that returns null does exactly this: server and hydration renders show the placeholder, then React re-renders with the live value.
If the deadline passed before a cached copy is served, that copy still says "Ends Fri, 11:59 PM". That is fine: the client computes zero on hydration, switches to expired within a frame and fires onExpire once with a large lateMs. Nothing in the HTML was a lie, only out of date.
The same rule covers everything that depends on the browser. The server formats the end time in the business's fixed zone, which gives identical text on both sides. A second line in the user's own zone depends on the device, so it gets the same treatment as the digits: a placeholder in the HTML, filled in after hydration. Nothing in the render path calls Date.now() or reads window; the clock is read in the store and in effects.
Think first
Why is "render the target" safe to cache for an hour on a CDN, while "render the remainder" is not safe to cache for even 10 seconds?
The target is the same fact no matter when you read it, so any cached copy is still correct. The remainder is a function of the reading time, so every second in the cache makes it more wrong, and the error grows with the cache age, not with the network.
That is a general rule for SSR: put values that are true over time in the HTML, and compute values that depend on "now" on the client.
Accessibility: a ticking number without the noise
A screen reader user needs the same information as everyone else: how long is left, and when time is up. The trap is the obvious fix: put the digits in an aria-live region, and the screen reader announces "two fourteen oh nine", "two fourteen oh eight" every second, interrupting everything else. That makes the whole page unusable. The Screen readers and live regions lesson explains them in detail.
- Give the digits
role="timer"and leave it silent. ARIA'stimerrole has an implicitaria-liveofoff, and users can still read it with the virtual cursor whenever they want. Name it from the visible label witharia-labelledby("Sale ends in"); some screen readers read that name before the digits. - Announce milestones in a separate polite live region. One visually hidden element with
aria-live="polite", rendered empty from the first load, since live regions added later are often missed. At milestones (props, since an exam and a sale need different ones) and at expiry, set its text: "5 minutes left in the sale". - Announce changes the user didn't cause. An auction extension ("Auction extended by 2 minutes") and the server's final result go to the same polite region.
- Real buttons for controls. Start, Pause, Resume and Reset are
buttonelements with visible focus. A combined Pause/Resume button changes its label instead of usingaria-pressed.
Session timers and WCAG 2.2.1
When a countdown limits what the user can do ("You will be signed out in 2:00", "Your basket is held for 10:00"), WCAG success criterion 2.2.1 Timing Adjustable applies. The user must be able to turn the limit off, adjust it to at least 10 times the default, or be warned before it ends and given at least 20 seconds to extend it with a simple action, at least 10 times.
Exceptions cover real-time events like auctions, limits that are essential (extending them would invalidate the activity, as with some timed exams), and limits longer than 20 hours.
For our component, that means a warnAt prop (for example 120 seconds before the end) that triggers a dialog with an "I need more time" button. Opening that dialog moves focus into it, and the button calls the server to extend the session or hold, then sets the new target. The modal dialog guide covers the focus handling.
Motion
Flip-card digits and pulsing "last minute" effects are popular on sale pages. Under prefers-reduced-motion: reduce, swap the flip for an instant change and drop the pulse. WCAG 2.2.2 Pause, Stop, Hide also asks for a way to pause, stop or hide auto-updating content shown next to other content, unless the updating is essential. Its Understanding page names an auction timer as content that pausing would make misleading, so for a live deadline offer a way to hide the ticking seconds rather than pause them.
Check your understanding · Exercise 7
Which choices make a countdown work well with a screen reader? Select all that apply.
Architecture: one shared clock, many cheap timers, and the API
A deals page shows 50 cards, each with a countdown. If each card owns a setTimeout loop, the page wakes up to 50 times a second, cards mounted 200 ms apart flip at different moments, and every card needs its own offset and visibility listener. The fix is to separate what is shared (what time it is, when the next second starts) from what is per card (the target, the state, the text).
Decision Where do the timers live when a page has many countdowns?
What we pick: Use one shared ticker. Each component subscribes through useSyncExternalStore, which also provides the server snapshot for SSR. A tick costs one timer callback and a handful of small renders: 50 cards each re-render a short text node, typically well under a millisecond in total. Cards scrolled out of view unsubscribe through an IntersectionObserver and re-read the clock when they come back, so a 500-card infinite list costs about the same as the 10 cards on screen.
ServerClock, Ticker) exist once per page; the hook and view exist once per timer; business rules stay in feature code. That split is why 50 timers cost one wakeup per second.| Piece | Responsibility | Why the boundary is here |
|---|---|---|
ServerClock | Holds the offset; server() and mono(); re-samples on wake | Every deadline on the page must agree on "now" |
Ticker | One aligned timeout; subscriptions by target and unit; visibilitychange and pageshow | Wakeups are a page-wide cost, so one owner |
useCountdown hook | Subscribes, runs the state machine, fires onExpire once, milestones | Logic shared by every visual style |
Countdown component | Formatting, role="timer", placeholder for SSR, live region | Swappable views over the same hook |
| Feature code | Fetches the deadline, listens for extensions, confirms with the server | Business rules stay out of the design system |
endsAt to target.One rule keeps useSyncExternalStore correct: getSnapshot returns the value the ticker computed on its last tick, never a fresh Date.now() reading. React can call it several times for one render and expects the same value until the store changes. A fresh clock read near a second boundary would differ between calls and force extra renders.
The component API
The API follows the model: a deadline is a prop, a duration is a prop plus controls. The Designing component APIs lesson covers controlled versus uncontrolled props in general. Here, uncontrolled mode is the common case (pass a target and forget), and controlled pause is there for the focus timer whose state lives in the app.
type CountdownProps = (
| { target: string | number; startsAt?: string | number } // ISO or epoch ms, server time
| { durationMs: number; paused?: boolean; defaultPaused?: boolean }
) & {
format?: "digital" | "short" | "long" | "auto"; // "auto" walks the units ladder
onExpire?: (info: { lateMs: number }) => void;
milestones?: number[]; // seconds left, e.g. [300, 60]
onMilestone?: (secondsLeft: number) => void;
warnAt?: number; // seconds left; opens the extend dialog
label: string; // "Sale ends in": visible label and the timer's accessible name
children?: (state: CountdownState) => React.ReactNode;
};
type CountdownState = {
status: "upcoming" | "running" | "paused" | "expired";
remainingMs: number;
parts: { days: number; hours: number; minutes: number; seconds: number };
text: string; // formatted for the current locale
pause(): void; resume(): void; reset(): void;
};
The ended state is deliberately not in the component: only feature code knows how the server confirms an end. The API sends a deadline as { endsAt, serverNow, version }, extensions arrive on the page's existing socket or server-sent events stream as { type: "auction.extended", id, endsAt, version }, and the card passes the newest endsAt straight into target.
Check your understanding · Exercise 8
A listing shows 200 auction cards with a shared ticker. Which statements are true? Select all that apply.
Running it in the interview
This is an easy problem with a hidden depth, so the risk is spending 30 minutes on formatting. A good 30-minute plan: 4 minutes on scope (deadline or duration, server-owned or not, SSR, how many per page), then draw the two kinds on a timeline and say "store the target, compute the remainder". Spend 8 minutes on ticking and the device clock, 6 on states, expiry and formatting, 6 on SSR and accessibility, and keep the last 6 for the shared ticker and the API.
Say the key lines out loud: "ticks decide when to repaint, never what to show"; "monotonic clock for durations, server-corrected clock for deadlines"; "the client shows time, the server decides time"; "render the target on the server, the remainder on the client".
- "Show tenths of a second in the last 10 seconds." Switch that one timer to
requestAnimationFramebelow 10 seconds, write the text directly to the DOM node to skip React renders, and switch back after expiry. - "How would you test it?" Inject the clocks. With fake timers and a fake
now, test drift, hidden tabs, a moved target, a load after the deadline, and thatonExpirefires exactly once.
Check your understanding · Exercise 9
The interviewer asks for an exam timer: 90 minutes, can't be paused, and the exam must close on time even if the student reloads. Which design choices fit? Select all that apply.
Summary
- Store the target instant and compute
remaining = target - nowon every read. Timer callbacks only decide when to repaint, so late, dropped or throttled ticks never make the value wrong. - Deadlines and durations are different models: deadlines use the server-corrected clock (
Date.now() + offset), durations use the monotonicperformance.now()and turn into a frozen remainder when paused. - Schedule one
setTimeoutto the next change of the displayed value, so digits flip on the second and coarse displays wake up rarely. Re-tick onvisibilitychangebecause hidden tabs are throttled, to once a minute in Chrome after 5 minutes. - The device clock can be minutes off. Send
serverNowwith every deadline, derive one page-wide offset from the round trip, and let the server decide anything with money or fairness attached. - Model states explicitly (upcoming, running, paused, expired), fire
onExpireexactly once per target with how late it is, and accept a moved deadline without remounting. - Server-render the end instant and a fixed-width placeholder, not the remainder; fill in the digits on the client. That is cache-safe, mismatch-free and shift-free.
- Keep the ticking digits silent (
role="timer"), announce milestones and expiry in a separate polite region, and follow WCAG 2.2.1 for session limits. - Many timers share one ticker and one offset; offscreen timers unsubscribe. Format with
Intl.DurationFormatand a fallback,tabular-nums, and translated surrounding text.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Heavy throttling of chained JS timers beginning in Chrome 88 The exact throttling stages for hidden pages.
- MDN: setTimeout() Why timers run late, inactive tab minimums per browser, and the 24.8-day maximum delay.
- MDN: Performance.now() The monotonic clock used for durations.
- MDN: Page Visibility API
visibilitychangeanddocument.visibilityState. - MDN: Intl.DurationFormat Styles, options and examples.
- MDN: font-variant-numeric
tabular-numsfor equal-width digits. - WAI-ARIA 1.2: timer role The timer role and its implicit
aria-liveof off. - Understanding WCAG 2.2: Timing Adjustable Warn, extend, and the real-time and 20-hour exceptions.
- Understanding WCAG 2.2: Pause, Stop, Hide Auto-updating content, and why an auction timer should be hidden rather than paused.
- React: useSyncExternalStore Subscribing to a shared ticker, with a server snapshot.
Concepts behind it
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.