Timers look trivial until you actually build one that people rely on. "Just setInterval and decrement a number," right? That gets you a timer that quietly drifts, silently dies when the tab sleeps, and forgets to make a sound at zero.
I recently built a fullscreen countdown timer as a plain-JS canvas widget (it powers the timer pages on blankscreen.io), and three things turned out to be more interesting than expected:
- Keeping accurate time without
setIntervaldrift - Stopping the screen from sleeping mid-countdown (Screen Wake Lock API)
- A clean alarm beep with WebAudio — no audio files
Here's how each part works, with code you can drop into your own project.
1. Don't trust setInterval for the clock
The naive version:
let remaining = 300; // seconds
setInterval(() => {
remaining -= 1;
render(remaining);
}, 1000);
setInterval is not a metronome. The browser throttles it in background tabs, and even in the foreground each tick can arrive late. Over a 30-minute timer those errors accumulate into visible drift.
The fix is to stop counting ticks and start measuring elapsed real time. Store performance.now() and subtract the actual delta each frame:
let remaining = total; // seconds
let running = false;
let last = 0;
function start() {
running = true;
last = performance.now();
}
function tick() {
if (running) {
const now = performance.now();
remaining -= (now - last) / 1000; // real elapsed, not "1"
last = now;
if (remaining <= 0) { remaining = 0; running = false; onDone(); }
}
render(remaining);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
Now the displayed time tracks the wall clock regardless of frame rate or minor jank. I render on a <canvas> with requestAnimationFrame because the same loop also draws a progress bar and a "time's up" flash, but the timekeeping idea is framework-agnostic — it works just as well updating a DOM node.
Formatting is the boring-but-fiddly part (show hours only when needed):
function fmt(s) {
s = Math.max(0, Math.ceil(s));
const h = Math.floor(s / 3600),
m = Math.floor((s % 3600) / 60),
ss = s % 60;
const p = n => String(n).padStart(2, '0');
return total >= 3600 ? `${h}:${p(m)}:${p(ss)}` : `${m}:${p(ss)}`;
}
2. Keep the screen awake with the Wake Lock API
A timer the user is watching should not let the phone dim and lock 30 seconds in. The Screen Wake Lock API handles this, and it's refreshingly small:
let wl = null;
async function keepAwake() {
try {
if ('wakeLock' in navigator && !wl) {
wl = await navigator.wakeLock.request('screen');
wl.addEventListener('release', () => { wl = null; });
}
} catch (e) { /* denied or unsupported — fail silently */ }
}
function releaseAwake() {
if (wl) { wl.release(); wl = null; }
}
Two gotchas worth knowing:
- The lock is auto-released when the tab loses visibility. So re-request it when the user comes back:
document.addEventListener('visibilitychange', () => {
if (!document.hidden && running) keepAwake();
});
- Request it in response to the user starting the timer (a user gesture), and release it on pause/reset so you're not holding the screen on for a paused timer.
Support isn't universal, so treat it as a progressive enhancement — wrap it in a try/catch and never let a rejection break the timer.
3. An alarm beep with zero audio assets
Shipping an alarm.mp3 means a network request, autoplay-policy headaches, and a file that can fail to load exactly when you need it. WebAudio can synthesize the beep on the fly:
let ac = null;
function ensureAudio() {
if (!ac) ac = new (window.AudioContext || window.webkitAudioContext)();
if (ac.state === 'suspended') ac.resume();
}
function beep() {
const o = ac.createOscillator();
const g = ac.createGain();
const t = ac.currentTime;
o.type = 'sine';
o.frequency.setValueAtTime(880, t); // two-tone: A5...
o.frequency.setValueAtTime(660, t + 0.18); // ...then E5
// quick fade in/out to avoid clicks
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.35, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.5);
o.connect(g); g.connect(ac.destination);
o.start(t); o.stop(t + 0.52);
}
The key autoplay detail: create/resume the AudioContext inside the user gesture that starts the timer, not at page load. Browsers block audio that isn't tied to interaction, so if you wait until zero to spin up the context, the beep gets swallowed. Prime it on "Start", then at zero just call beep() on a short interval until the user dismisses it:
function onDone() {
ensureAudio();
beep();
const id = setInterval(beep, 900);
// clear `id` on reset/pause
}
Putting it together
The full widget is ~80 lines: the requestAnimationFrame loop, a THEMES object for a light/dark toggle, a progress bar (elapsed / total), and a red flash + "Time's up" state at zero. No dependencies, no build step.
If you want to see it running across a bunch of durations, I made a page per common preset (1-minute up to 3-hour) over on blankscreen.io — same canvas widget, just seeded with a different starting time. It's also where I keep a few other no-install browser utilities (color screens, white noise, screen tests) if that's your kind of thing.
Takeaways
- Measure elapsed time with
performance.now()deltas — never countsetIntervalticks. - Screen Wake Lock is tiny and worth it, but re-request on
visibilitychangeand treat it as optional. - Synthesize the alarm with WebAudio and prime the
AudioContexton the start gesture.
What would you add — a stopwatch mode, interval/Tabata support, keyboard shortcuts? Curious how others handle background-tab timing.
Top comments (0)