
Drift-resistant React countdown timer hook. A setInterval-based countdown
loses a few milliseconds on every tick, and loses seconds when the tab is
backgrounded — by minute 30 of a 30-minute exam timer, the clock has wandered
off the wall. useTimer from @x1ee7/react-drift-timer fixes this by
calculating remaining time from the absolute start timestamp on every
tick. The displayed countdown matches a wall clock to the second, even after
the tab sleeps, the laptop suspends, or React skips frames.
This is the production timer powering exam sessions on humberrealestate.com — a real-estate exam prep platform where mis-timing a 90-minute proctored simulation by even a few seconds would be a bug report. The hook is open; the question bank stays in the app.
npm install @x1ee7/react-drift-timerpnpm add @x1ee7/react-drift-timer
# or: yarn add @x1ee7/react-drift-timerReact 17+ peer dependency. ESM + CommonJS + TypeScript types. No runtime dependencies.
import { useTimer } from "@x1ee7/react-drift-timer";
function QuizTimer({ seconds, onTimeUp }: { seconds: number; onTimeUp: () => void }) {
const { timeRemaining, isRunning, pause, resume } = useTimer(seconds, onTimeUp);
if (timeRemaining === null) return null;
const mm = Math.floor(timeRemaining / 60);
const ss = String(timeRemaining % 60).padStart(2, "0");
return (
<div>
<span>{mm}:{ss}</span>
<button onClick={isRunning ? pause : resume}>
{isRunning ? "Pause" : "Resume"}
</button>
</div>
);
}Pass null for initialSeconds to disable the timer (it returns
{ timeRemaining: null, isRunning: false }). When initialSeconds changes to
a new number, the timer resets and starts again — useful for "new quiz
started" or "next question" flows.
| Export | Signature | Notes |
|---|---|---|
useTimer |
(initialSeconds: number | null, onExpire: () => void) => UseTimerReturn |
The hook itself. |
UseTimerReturn |
{ timeRemaining, isRunning, pause, resume } |
Return type. timeRemaining is null while the timer is disabled. |
UseTimerReturn shape:
interface UseTimerReturn {
timeRemaining: number | null;
isRunning: boolean;
pause: () => void;
resume: () => void;
}The naive countdown looks like this:
// ❌ drifts
useEffect(() => {
const id = setInterval(() => setSeconds((s) => s - 1), 1000);
return () => clearInterval(id);
}, []);Three things go wrong:
setIntervalis not precise. The browser only guarantees "at least 1000 ms"; it can be 1003 ms, 1015 ms, sometimes more. Each tick drops the slop on the floor and starts a new interval, so error compounds.- Background tabs throttle. Chrome and Safari throttle background timers
to once-per-minute (or longer). A
setInterval(fn, 1000)running in a backgrounded tab fires maybe 60× less often than it should — and thes - 1math has no idea the tab was hidden, so the clock falls behind by the entire backgrounded duration. - React batching delays ticks. State updates can be deferred behind
layout or transitions, so the
setSecondscall doesn't always land immediately.
useTimer sidesteps all three by storing the absolute start time in a
ref and computing remaining = initialSeconds − floor((Date.now() − startTime) / 1000)
on every tick. The displayed value is derived from the wall clock, not
accumulated. Skipped ticks self-heal on the next one. Backgrounded tabs catch
up the moment they become visible.
The tick cadence is 4×/second (setTimeout(tick, 250)) — enough for a
sub-second-feeling countdown, not so fast that it floods React's scheduler.
pause() flips isRunning to false, which clears the next scheduled tick.
resume() does the inverse, but it also adjusts startTimeRef so the
elapsed-time math stays consistent:
startTimeRef.current = Date.now() − ((initialSeconds − timeRemaining) × 1000);In other words, resuming a paused timer is mathematically the same as if the pause had never happened — the countdown picks up exactly where it stopped, not at the moment of resume. This is what users expect from a stopwatch.
When the countdown reaches 0, onExpire is called once and isRunning flips
to false. The internal hasExpiredRef guards against repeat fires (which
can otherwise happen if React re-runs the effect, or if the timer is paused
and resumed past zero). If you swap the onExpire callback between renders,
the latest callback is used — the timer holds a ref to it that updates
without resetting the countdown.
- Hard real-time guarantees. This is browser JavaScript; sub-100ms precision is not a goal. The hook is accurate to the second.
- Stopwatches (counting up). This hook only counts down. A
drift-resistant stopwatch is the same idea (
Date.now() − startTime) but doesn't need expiration handling — write it inline. - Server-side timers. This is a client hook with a
"use client"directive. Server-side schedules belong in a job queue, not a React hook.
| Approach | Drifts on long timers | Survives backgrounded tab | Pause / resume | Bundle |
|---|---|---|---|---|
setInterval + decrement |
yes | no | manual | tiny |
requestAnimationFrame loop |
yes (rAF pauses in bg) | no | manual | tiny |
| Date-based on every tick (this hook) | no | yes | built-in | ~1 KB gz |
react-countdown / react-timer-hook |
varies | varies | yes | larger |
setInterval only guarantees "at least 1000 ms" between fires, so the few
extra milliseconds on every tick compound into a visible offset over a long
countdown. Background tabs make it worse — Chrome throttles timers to roughly
once per minute when the tab is hidden, so a tab-switched countdown can fall
behind by minutes. The fix is to derive the displayed value from the wall
clock (Date.now() − startTime) instead of subtracting on every tick.
Store the start timestamp in a ref. On every tick, compute
remaining = initialSeconds − Math.floor((Date.now() − startTime) / 1000).
The tick interval can be 250 ms or 1000 ms — it doesn't matter, because the
displayed value is recomputed from absolute time each time, not accumulated.
That's the entire idea behind useTimer.
Yes. While the tab is hidden, the browser throttles setTimeout so ticks
fire much less often — but the first tick after the tab regains focus
recomputes remaining from Date.now() − startTime and immediately catches
up. There is no compounding error.
Yes. The hook returns pause and resume functions. pause stops the
countdown; resume continues it from exactly the second it stopped at, with
no drift introduced by the pause itself (the start timestamp is rewound
internally to keep the math consistent).
Pass an onExpire callback. It is called exactly once when the countdown
hits 0, even if the timer is paused and resumed past zero, even if the
onExpire reference changes between renders.
No. Those libraries have broader scope (stopwatch + countdown + intervals)
and larger bundles. react-drift-timer is a single hook focused on one
problem: keeping a long countdown accurate against a wall clock. If you need
a stopwatch or multiple timers in one component, use one of those instead.
Yes — all three. The peerDependencies is react: >=17.0.0. The hook only
uses useState, useEffect, useRef, and useCallback.
Yes. The source has a "use client" directive so it works inside a Server
Component tree without manual annotation. Import and use it from any client
component.
No. The only peerDependency is react. The published bundle ships ESM,
CommonJS, and .d.ts types.
Yes — it's the same shape. Pass 25 * 60 as initialSeconds, pass your
"break starts" callback as onExpire, and use pause / resume for the
"pause Pomodoro" button. When the break starts, call the hook again with
5 * 60.
MIT © humberrealestate.com
Built and battle-tested on a real-estate exam prep platform where a mis-counted timer would be a bug report. If you find this useful and want to support more open-source extracts like it, you can sponsor on GitHub.