cf.completefrontend
My progress
SYSTEM DESIGN · #20

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.

QuestionAnswer we assumeWhat 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 timerWhether 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" modeOne 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 SEOHydration 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 arrivesThe 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 onExpire exactly 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.
A deals page wireframe with four uses of the same countdown component. Sale banner: Deal ends in 02:14:09, with the end time Ends Fri, 11:59 PM PST rendered by the server. Auction card: Ends in 0:42, an Extended plus 2:00 badge and a Bid button. Code screen: Resend code in 0:30 with a disabled Resend button. Focus timer: 25:00 with Pause and Reset buttons.shop.example/dealsSale bannerDeal ends in 02:14:09Ends Fri, 11:59 PM PSTdeadline, server timeAuction cardEnds in 0:42+2:00Bid $120extendsCode screenResend code in 0:30ResendcooldownFocus timer25:00PauseReset
One component, four jobs. The banner and the auction count to a server deadline; the resend button and the focus timer count a duration. The end time under the banner is plain server-rendered text that stays true in any cache; only the digits are filled in on the client.

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?

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.

Two timelines. Deadline: an arrow from page load past a reload to a fixed target set by the server; reloading changes nothing. Duration: running, paused, running again, then the end; the end moves later by the length of the pause. Start sets endsAt to now plus 25 minutes, pause keeps the remaining milliseconds, resume computes a new endsAt.Deadline: one instant for everyone, no pausepage loadreload: same endtargetDuration: a length the user owns, pausablerunningpausedrunningendsAtstart: endsAt = mono() + 25 minpause: keep remainingMs; resume: new endsAt
A deadline is one fixed instant that every visitor shares, so the page only needs to know what time it is. A duration belongs to the user: its end only exists while it runs, and every pause pushes the end later.

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?

A counter of seconds left

Start at secondsLeft = 1500.

Every interval tick, secondsLeft -= 1 and render.

Pros

  • Five lines of code.
  • Pausing is free: stop the interval.

Cons

  • Every late tick adds error; it never catches up.
  • Throttled or sleeping tabs lose whole minutes.
  • Two timers for the same sale drift apart.

Start time plus elapsed

Store startedAt and the total length.

On each tick, left = total - (now - startedAt).

Pros

  • No drift: every tick reads the clock.
  • Works for durations.

Cons

  • Pausing needs extra bookkeeping for paused spans.
  • A deadline has no meaningful start; you would invent one.

Target instant, computed on read

Deadline: store targetMs (server instant).

Duration while running: store endsAt = now + remainingMs. While paused: store remainingMs only.

On each tick, left = max(0, target - now).

Pros

  • Ticks only decide when to repaint, not what to show.
  • Missing ticks costs nothing: the next tick is correct.
  • One shape for both kinds; pause is just switching fields.

Cons

  • Needs the right clock per kind (next chapter).
  • Must clamp at zero and handle a target that moves.

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

ts
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 };
}
A duration timer is a deadline that only exists while running. Pause turns the deadline back into a remainder.

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.

Ten minutes of a chained 1000 millisecond timer in Chrome. Visible: it runs about every second the whole time. Hidden: for the first 5 minutes it is checked once a second; after 5 minutes hidden it runs only once a minute, at minutes 5, 6, 7, 8, 9 and 10. Exceptions: the page played audio in the last 30 seconds, WebRTC is in use, or the chain is shorter than 5.Visibleabout every 1000 msHiddenonce a secondonce a minute05 min hidden10 minExempt: recent audio, WebRTC, or a chain under 5
After 5 minutes hidden, a 1000 ms chain gets about 1 run per minute instead of 60. A decrementing counter loses 59 seconds every minute; a timer that computes from the target is simply repainted late. Either way, run a tick on visibilitychange.

Decision How does the component schedule repaints?

setInterval every 1000 ms

Start an interval when mounted.

Each tick, compute the remainder and render.

Pros

  • Simplest code.
  • Correct values, since we compute from the target.

Cons

  • Phase is random: started at 12:00:00.630, it flips 630 ms after each real second.
  • Two cards mounted 300 ms apart flip at different moments.

Fast interval, 100 or 250 ms

Tick 4 to 10 times per second.

Render only when the shown second changes.

Pros

  • Flips at most 100 to 250 ms late.
  • Still simple.

Cons

  • 4 to 10 times the wakeups for the same output.
  • Still never exactly on the second; wastes battery.

setTimeout aligned to the next boundary

After each render, compute ms until the shown value next changes: remaining % 1000.

Schedule one setTimeout for that delay; repeat.

Pros

  • Flips within a few ms of the real second.
  • One wakeup per visible change: for "3 days left" that is one per hour or day.
  • Self-correcting: a late wakeup just schedules a shorter next delay.

Cons

  • Slightly more code.
  • Still throttled in hidden tabs, so pair it with visibilitychange.

requestAnimationFrame loop

Run a callback every frame (60 to 120 times a second).

Render each frame.

Pros

  • Smooth sub-second displays like 0:04.37 or a shrinking ring.
  • Pauses itself in hidden tabs.

Cons

  • 60 to 120 wakeups per second to change a digit once.
  • Wasteful for anything shown in whole seconds.

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.

ts
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 unit is the granularity of the display: 1000 ms for seconds, 60000 for a minutes-only display.

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.

Drift lab at 0.0 seconds after load. Real time left 25:01. A: counter, setInterval -1 shows 25:01, off by 0 s, 0 ticks. B: setInterval, from target shows 25:01, off by 0 s, 0 ticks. C: aligned setTimeout shows 25:01, off by 0 s, 0 ticks.Real time left 25:01shownerrorflip lagA: counter, setInterval -125:010 sn/aB: setInterval, from target25:010 s0 msC: aligned setTimeout25:010 s0 ms
Strategy A decrements once per tick it receives, so late ticks push it behind and a hidden tab loses whole minutes. B and C compute from the target, so any tick they get is right. B flips about 630 ms after each real second because it started off-phase, and late ticks shift it further; C flips within a few ms and re-ticks the moment the tab is visible again.

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.

Server time 17:56:00, device clock 17:59:00. The sale ends at 18:00:00 server time. Real time left 4:00. Device clock only shows 1:00, live. With the server offset it shows 4:00, live, off by 0 ms.Server 17:56:00 Device 17:59:00 Sale ends 18:00:00Real time left4:00liveDevice clock only1:00live, off -180 sDevice + server offset4:00live, off 0 ms
The countdown from the device clock alone is wrong by the whole clock error, so a fast phone ends the sale minutes early. The offset version is off only by the network's asymmetry: Slow upload assumes the request leg takes 70% of the round trip, so the midpoint guess is off by 20% of it. If the user changes the clock, it is wrong until the page re-samples.

Decision How does the client know what time it is on the server?

Trust Date.now()

Compute target - Date.now() directly.

Pros

  • No extra code or requests.

Cons

  • Wrong by however far the device clock is off.
  • Fine for a focus timer, not for anything with a shared deadline.

Server sends time left, not a target

The response says remainingMs: 7200000.

On receipt, endsAt = performance.now() + remainingMs.

Pros

  • Immune to the device clock.
  • No offset math.

Cons

  • Error equals the response's travel time, often 50 to 300 ms on mobile.
  • Wrong if the response was cached by a CDN or service worker.
  • Every timer needs its own fresh response.

Offset from the Date header

Read the Date response header of any API call.

offset = Date.parse(header) - Date.now().

Pros

  • Free: every HTTP response has it.

Cons

  • Only whole-second precision.
  • A cached response carries an old Date.
  • Cross-origin, JavaScript can't read it unless the server lists it in Access-Control-Expose-Headers.

Offset from a time field with the round trip

The API that returns the deadline also returns serverNow in ms.

Time the request with performance.now(): rtt = received - sent.

On receipt, offset = serverNow + rtt / 2 - Date.now().

Pros

  • Millisecond field, tens of ms accuracy.
  • One offset serves every timer on the page.
  • Comes with data we fetch anyway.

Cons

  • Assumes the round trip is symmetric.
  • Must not be cached; needs Cache-Control: no-store on that field's response.

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.

Sequence diagram of estimating the server clock offset. The device clock is 3 seconds fast. The browser sends GET /deals at device time 05.000. The server replies with endsAt and serverNow equal to 02.150. The browser receives it at device time 05.300, so the round trip is 300 milliseconds. Estimated server time at receipt is 02.150 plus 150, which is 02.300, so the offset is minus 3.000 seconds. Every deadline then uses Date.now plus the offset.BrowserAPI serverGET /deals, sent 05.000endsAt, serverNow: 02.150got it at 05.300: rtt 300 msserver: 02.150 + 150 = 02.300offset = 02.300 - 05.300 = -3 severy deadline: Date.now() + offset
The server stamps its time somewhere during the round trip; assuming the middle gives an error of at most half the round trip. One offset, stored once, serves every timer: 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 machine. upcoming goes to running when startsAt is reached. running goes to expired when the time left reaches 0. expired goes back to running if the deadline moves later, and to ended when the server confirms. running goes to paused on pause, and paused back to running on resume. paused exists only for durations; ended only for server-owned deadlines.upcomingbefore startrunningtarget - nowexpiredlocal zeropausedfrozenendedserver said sostartleft = 0moved laterpause()resume()confirmedpaused: durationsended: server only
Five states instead of three booleans. The split between expired (our clock says zero) and ended (the server says so) is what lets an auction extension or a wrong clock recover gracefully.
StateMeaningWhat the UI shows
upcomingNow is before startsAt (a sale that hasn't opened)"Starts in 2d 4h", button disabled
runningCounting toward the targetThe time left, live
pausedDuration timers only; remainder frozenFrozen time, Resume button
expiredLocal time reached zero"Ending..." for server-owned deadlines, or the final state for local ones
endedThe 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.

ts
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);
}
The guard is keyed by the target, so a moved deadline can expire again, but the same one never twice.

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.

Sequence diagram of expiry. The countdown's tick sees 0 left and calls onExpire with lateMs 0, once. The auction card shows Ending and disables bidding, then sends GET status. The first request fails with a network error; the card keeps Ending and retries after 1 second. The retry returns ended, winner you. The card shows Sale ended and announces it.CountdownAuction cardAPIonExpire({ lateMs: 0 })Ending..., bid offGET statusnetwork errorkeep Ending, retry 1 sGET statusended, winner: youSale ended, announced
Local zero only moves the card to a safe, neutral state. The result comes from the server, and a failed request keeps the safe state and retries with backoff instead of guessing.

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.

Sequence diagram of an auction extension. Another bidder bids at 0:05 left, before the 18:00:00 end. The auction API moves endsAt 2 minutes later as version 8 and publishes auction.extended. The push stream delivers endsAt 18:02:00 version 8 to your card. Version 8 is newer than 7, so the card sets the new target, shows 2:05 without remounting and announces the extension. Failure path: after a reconnect a stale version 7 message arrives and is ignored because it is older.Your cardPushAuction APIBidderbid at 0:05extended v8endsAt 18:02:00, v8v8 is newer: set it2:05, no remountannounce +2 minstale v7 (reconnect)ignored (older)
The card never guesses a new deadline; it takes it from the server and keeps the newest version. Because the timer stores only a target, an extension is one assignment, not a restart.
  • Only move forward on server data. Take the new endsAt from 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 expired if the target moved past now. A card that showed "Ending..." goes back to running, 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 leftShown asRepaint 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 hours01:59:59 or "1h 59m 59s"Second
Last 10 seconds, opt-in0:09.4Animation frame

Decision How do we turn parts into localized text?

Manual strings

${h}h ${m}m, with padStart(2, "0") for the digital style.

Pros

  • Tiny, predictable.

Cons

  • English only: "h" and "m" are wrong in most languages.
  • Plurals ("1 minutes") and word order are wrong.

Intl.NumberFormat units plus Intl.ListFormat

Format each part with style: "unit", unit: "hour", unitDisplay: "long" | "narrow".

Join the parts with Intl.ListFormat (type: "unit").

Pros

  • Correct plurals and unit names: 2 Stunden in German.
  • Supported everywhere for years.

Cons

  • More code; the digital 01:59:59 style still needs manual padding.
  • You decide yourself which zero parts to drop.

Intl.DurationFormat

new Intl.DurationFormat(locale, { style: "long" }).format({ hours: 1, minutes: 46, seconds: 40 }).

style: "digital" gives the colon style.

Pros

  • Built for exactly this: units, plurals, lists and digital style per locale.
  • One call per format, no string assembly.

Cons

  • Baseline only since March 2025 (Chrome 129, Safari 16.4, Firefox 136): older browsers need the fallback.
  • No "ends in" phrasing; that is still a translated message.

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?

The time left, as computed on the server

Render 2:14:09 on the server.

Silence the mismatch with suppressHydrationWarning on the text.

Pros

  • Digits visible in the first paint.

Cons

  • Wrong by the transfer and hydration time, and by the cache age if cached.
  • The page can't be cached without making it worse.
  • Digits jump on the first client tick.

Nothing; render only on the client

Render null until mounted.

Pros

  • No mismatch, no stale value.

Cons

  • The space collapses, then the timer pops in and pushes content down: a layout shift that hurts CLS.
  • No end time for crawlers or users before JavaScript loads.

The target, plus a fixed-size placeholder

Server renders the end as a time element with a datetime attribute: "Ends Fri, 11:59 PM PST".

The digits slot renders a placeholder with the same width (--:--:-- with tabular-nums).

The client fills in the digits right after hydration.

Pros

  • True in any cache: the end instant never goes stale.
  • Same HTML on server and first client render, so no mismatch.
  • No layout shift: the slot has its final size.

Cons

  • Digits appear a moment after hydration, not in the first paint.
  • Needs a deliberate placeholder design.

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.

Two server rendering strategies over time: server render at 0.0 seconds, HTML arrives at 0.4, hydration at 2.1, and the next frame. Rendering the remainder shows 2:14:09, which is stale on arrival, then a hydration mismatch, then a jump to 2:14:07. Rendering the target plus a placeholder shows --:--:-- in the same width until hydration, with no mismatch, then 2:14:07. The second row's HTML also carries Ends Fri, 11:59 PM PST, which stays true even if the page is cached.server0.0 sarrives0.4 shydrate2.1 snext frame2.1 sremainderin HTMLtarget andplaceholder2:14:09--:--:--2:14:09--:--:--mismatch--:--:--2:14:072:14:07Row 2 HTML also says: Ends Fri, 11:59 PM PSTCached 60 s? Row 1 starts a minute off; row 2 is still true.
The remainder is a function of the moment it is read, so every step between the server and the screen makes it more wrong. The placeholder row has nothing to go stale: same HTML on the server and in the first client render, then 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?

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's timer role has an implicit aria-live of off, and users can still read it with the virtual cursor whenever they want. Name it from the visible label with aria-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 button elements with visible focus. A combined Pause/Resume button changes its label instead of using aria-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?

One timer per component

Each card runs its own aligned setTimeout.

Pros

  • Self-contained, easy to reason about.

Cons

  • Up to 50 wakeups per second, each with its own React render.
  • Cards flip out of step.
  • 50 visibility listeners and 50 copies of the offset logic.

One shared ticker

A module-level Ticker holds the offset and one aligned setTimeout for the nearest change among subscribers.

Components subscribe with their target and unit; on each tick the ticker notifies only those whose displayed value changed.

Pros

  • One wakeup per second for the whole page.
  • Every card flips in the same frame.
  • One place for offset, visibility and throttling logic.

Cons

  • A small shared module to build and test.
  • Subscribers must unsubscribe on unmount, or they leak.

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.

Architecture. On the server side, the Auction API sends endsAt and serverNow, and a Push stream sends extensions, both to AuctionCard, which is feature code. In the browser, AuctionCard passes serverNow to ServerClock and the target to the Countdown component. Countdown uses the useCountdown hook, which runs the state machine and fires onExpire once. useCountdown subscribes to the shared Ticker, which reads now from ServerClock. useCountdown sends milestone text to a polite live region.ServerAuction APIendsAt, nowPush streamextendedBrowserAuctionCardfeature codeCountdownformat, SSR, a11yServerClockoffset, mono()useCountdownstates, onExpire onceTickerone timeoutLive regionmilestones onlytargetserverNowhooksubscribenow()text
The shared pieces (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.
PieceResponsibilityWhy the boundary is here
ServerClockHolds the offset; server() and mono(); re-samples on wakeEvery deadline on the page must agree on "now"
TickerOne aligned timeout; subscriptions by target and unit; visibilitychange and pageshowWakeups are a page-wide cost, so one owner
useCountdown hookSubscribes, runs the state machine, fires onExpire once, milestonesLogic shared by every visual style
Countdown componentFormatting, role="timer", placeholder for SSR, live regionSwappable views over the same hook
Feature codeFetches the deadline, listens for extensions, confirms with the serverBusiness rules stay out of the design system
Data flow. Auction API sends endsAt, serverNow and version as JSON to AuctionCard; AuctionCard sends bids and status checks back. AuctionCard passes the target to useCountdown, which passes text and status to the Countdown view. The Push stream sends extended v8 to AuctionCard. ServerClock gives now to the Ticker, and the Ticker sends a tick up to useCountdown. On expiry, the card shows Ending and a status request confirms ended.endsAt, serverNowtargettext, statusAuction APIAuctionCarduseCountdownCountdownbid, status checkPush streamTickerServerClockextended v8ticknow()onExpire: card shows Ending..., then GET status confirms ended
Facts flow right (a deadline, then a target, then text); time flows up from the shared clock as ticks; changes the user or other bidders make flow back through the server, never straight into the timer. The card only ever passes a server-given 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.

tsx
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;
};
A deadline or a duration, never both. The render prop lets teams build flip cards or rings on the same logic.

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 requestAnimationFrame below 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 that onExpire fires 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 - now on 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 monotonic performance.now() and turn into a frozen remainder when paused.
  • Schedule one setTimeout to the next change of the displayed value, so digits flip on the second and coarse displays wake up rarely. Re-tick on visibilitychange because hidden tabs are throttled, to once a minute in Chrome after 5 minutes.
  • The device clock can be minutes off. Send serverNow with 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 onExpire exactly 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.DurationFormat and 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next