Skip to main content
RapidDev - Software Development Agency
App Featurespersonalization-ux18 min read

How to Add a Timer and Stopwatch to Your App (Copy-Paste Prompts Included)

A timer/stopwatch needs a state machine (idle/running/paused/finished), a drift-free time engine using Date.now() delta instead of raw setInterval ticks, and an audio alert on countdown end via the Web Audio API. With Lovable or V0 you can ship both modes with lap tracking and keyboard shortcuts in 1–2 hours for $0/month — no backend required unless you want to persist session history.

4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members

Feature spec

Beginner

Category

personalization-ux

Build with AI

1–2 hours with Lovable or V0

Custom build

1–2 days custom dev

Running cost

$0/mo (client-side); $0–$25/mo if session logging is added

Works on

Web

Everything it takes to ship a Timer and Stopwatch — parts, prompts, and real costs.

TL;DR

A timer/stopwatch needs a state machine (idle/running/paused/finished), a drift-free time engine using Date.now() delta instead of raw setInterval ticks, and an audio alert on countdown end via the Web Audio API. With Lovable or V0 you can ship both modes with lap tracking and keyboard shortcuts in 1–2 hours for $0/month — no backend required unless you want to persist session history.

What a Timer and Stopwatch Feature Actually Is

A timer/stopwatch is deceptively simple at first glance but has two failure modes that break the majority of first builds. The first is drift: using setInterval to count ticks accumulates error under CPU load or when the browser tab is throttled. A correct implementation records the start time once and computes elapsed time as Date.now() minus that anchor on every render tick — drift becomes mathematically impossible. The second failure mode is the audio alert: browsers require a user gesture to unlock the AudioContext, so scheduling a beep for when the countdown hits zero only works if the context was activated earlier. Beyond these two mechanics, the product decisions are simple: which modes to expose, whether to persist session data, and how keyboard-friendly to make the UI.

What users consider table stakes in 2026

  • Countdown and stopwatch modes toggle without losing the current session state
  • Lap and split times recorded in stopwatch mode with a scrollable history list below the display
  • Audio alert when countdown reaches zero — plays even if the user switched focus to another element on the page
  • Pause and resume without drift — the display shows real elapsed time, not counted ticks
  • Keyboard shortcuts: Space to start/stop, R to reset — no mouse click required
  • Visual progress ring or bar showing the proportion of time remaining for countdowns

Anatomy of the Feature

Seven components, but three of them are often generated incorrectly by AI tools. The timer state machine, countdown display, and audio alert need explicit specification to avoid the most common failure modes.

Layers:UIDataService

Timer State Machine

UI

React useReducer managing four discrete states: idle (no session started), running (timer ticking), paused (elapsed time frozen), finished (countdown hit zero or stopwatch stopped). State transitions map to buttons: Start, Pause, Resume, Reset, Lap. Using useReducer instead of multiple useState calls prevents impossible states like 'running and paused simultaneously'.

Note: Do not use raw setInterval as the source of truth for elapsed time. It drifts. The setInterval is only a render trigger — actual time is always computed as Date.now() minus the recorded startTime.

Countdown Display

UI

MM:SS.ms or MM:SS display using CSS font-variant-numeric: tabular-nums to prevent layout shift when digits change width. Large accessible font (minimum 3rem). Color transitions red when remaining time drops below 10 seconds, providing visual urgency without additional UI chrome.

Note: tabular-nums is critical for readability. Without it, the display jitters horizontally as narrow digits (1) alternate with wide digits (8) on each tick.

Progress Ring

UI

SVG circle element with stroke-dashoffset animated via CSS transition. Calculated on each render as (remaining / total) * circumference where circumference = 2 * Math.PI * radius. No canvas required. The SVG approach allows CSS animations and transforms without JavaScript animation loops.

Note: Only relevant for countdown mode. For stopwatch mode, omit the ring or show an elapsed percentage against a session target if the app has one.

Audio Alert

Service

Web Audio API AudioContext.createOscillator() generates a zero-dependency beep when the countdown reaches zero. The AudioContext must be created and resumed on the first user click (the Start button) to satisfy the browser autoplay policy. The reference is then stored and the scheduled beep fires from the already-unlocked context when the timer ends. As an alternative, an HTMLAudioElement pointing to a short .mp3 file in the public/ directory works but requires a file to ship.

Note: AudioContext created before a user gesture will stay suspended and produce no sound. Always create it inside the Start button's click handler on the first press.

Lap Tracker

Data

In-memory array of objects: { lapNumber: number, splitTime: number, totalTime: number }. Rendered in a scrollable list below the timer display. No database needed for session-only laps — the array is part of the component state and is reset on R. Each lap entry shows both the split (time since last lap) and the cumulative total.

Note: Wrap the lap list in React.memo so it only re-renders when laps.length changes, not on every 16ms tick. The display updating at 60fps while the lap list re-renders at the same rate causes visible scroll jank on long sessions.

Persistence Layer

Data

localStorage via the useLocalStorage hook from usehooks-ts saves the last used timer duration and mode between browser sessions. An optional Supabase table (timer_sessions) stores completed session metadata for logged-in users who want cross-device history or productivity analytics.

Note: localStorage reads must be wrapped in useEffect to prevent Next.js SSR hydration mismatches. The initial state for SSR should always be null or a safe default, with the localStorage value applied client-side only.

Preset Selector

UI

Row of chip buttons for common durations: 5 min, 10 min, 25 min (Pomodoro), 1 hour. Clicking a chip sets the countdown value and switches to countdown mode. Styled as shadcn/ui Badge components in an interactive variant, or as toggle-style pill buttons.

Build it — pick your path

Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.

Best UI, Next.js ecosystemFit for this feature:

V0 excels at polished timer UI — generates the accessible SVG progress ring, tabular-nums display, and shadcn/ui styling natively. Best choice when the timer is a component inside a larger Next.js productivity app.

Step by step

  1. 1Paste the prompt below in V0 — it generates a client component with the full timer logic
  2. 2Mark the component with 'use client' — V0 sometimes omits this on stateful components
  3. 3Test the localStorage restoration by refreshing the page mid-countdown — the last duration should appear in idle state
  4. 4Verify the audio alert: AudioContext creation on the Start button click must happen before the countdown ends or the beep will be silently blocked
  5. 5Publish to Vercel and test on a real browser — the V0 sandbox preview may not reflect audio behavior accurately
Paste into v0
Create a Next.js timer and stopwatch component. Mark it 'use client'. Use React useReducer with states: idle, running, paused, finished. Time engine: on Start, record startTime = Date.now(); on each setInterval(fn, 100) tick, compute elapsed = Date.now() - startTime; remaining = duration - elapsed. Clear the interval in the useEffect cleanup function. Two modes switchable via a tab toggle: countdown (default 25 min) and stopwatch. Countdown display: large MM:SS text with font-variant-numeric: tabular-nums; transitions text-red-500 when remaining < 10 seconds. SVG progress ring: a circle element with stroke-dashoffset = circumference * (1 - remaining/duration), animated with CSS transition. Audio alert on countdown end: create AudioContext on the first Start button click interaction (store ref in a useRef); when timer finishes, call oscillatorRef.current.start() with frequency 440hz for 0.5s. Stopwatch mode: Lap button appends {lapNumber, splitTime, totalTime} to a laps array; render in a React.memo list component below the display. Keyboard shortcuts via useEffect on window: Space = toggle start/pause, R = reset; call e.preventDefault() first. Preset chips: 5 min, 10 min, 25 min, 1 hr — set the countdown value without needing a text input. localStorage: wrap read in useEffect, store last duration as 'timer-last-duration'; null is the SSR-safe initial state. Page Visibility API: addEventListener('visibilitychange') to re-sync display from startTime when tab regains focus.

Where this path bites

  • V0's sandbox preview cannot test the audio alert accurately — deploy to Vercel and test in a real browser to confirm AudioContext unlocking works
  • localStorage reads outside useEffect cause Next.js hydration warnings — ensure all reads are inside useEffect or use V0's generated useLocalStorage pattern

Third-party services you'll need

The timer feature is entirely client-side and requires no paid services. Optional session persistence uses Supabase free tier:

ServiceWhat it doesFree tierPaid from
Web Audio APIIn-browser audio alert generation — zero-dependency beep on countdown endBrowser-native, always freeFree
usehooks-tsuseLocalStorage and useEventListener hooks for localStorage persistence and Page Visibility APIOpen-source, zero costFree
Supabase (optional)timer_sessions table for cross-device session history and productivity analyticsFree tier covers this at any scale for session metadataPro $25/mo if combined DB usage exceeds free tier

Swipe the table sideways to see pricing.

What it costs to run

Drag through the tiers to see how your monthly bill scales with users — no surprises later.

Estimated monthly running cost

$0/mo

Entirely client-side computation. localStorage for persistence. Web Audio API for alerts. No backend costs.

Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.

What breaks when AI tools build this

The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.

Timer drifts visibly after a few minutes

Symptom: setInterval is not guaranteed to fire on its exact schedule. Under CPU load or when macOS/Windows deprioritizes background processes, the interval may fire 5–20ms late on each tick, accumulating to 2–5 seconds of drift per hour. A timer showing 5:02 when a real clock reads 5:00 is unusable for Pomodoro or cooking.

Fix: Record startTime = Date.now() when the timer starts. On each setInterval tick, compute remaining = duration - (Date.now() - startTime) and render that value — never decrement a counter. This anchors the display to wall-clock time regardless of how late each interval fires.

Timer display freezes when the tab is hidden

Symptom: Chrome, Firefox, and Safari throttle setInterval to a minimum of 1000ms (1 second) for background tabs to save battery. A 100ms interval becomes a 1-second interval as soon as the user switches tabs, making the display jump in 1-second increments and eventually appear frozen.

Fix: Add a visibilitychange event listener: when the tab becomes visible again, call the tick function immediately to recalculate remaining from the stored startTime. The Date.now() delta approach makes the displayed time accurate the instant the tab is focused, regardless of how long it was throttled.

Audio alert plays in the V0 preview but not after Vercel deployment

Symptom: Browsers block AudioContext creation and audio playback until a user gesture occurs. When the timer expires and the code calls audioContext.resume().then(() => playBeep()), the Promise resolves as 'suspended' if no click has happened. The preview environment's iframe security model differs from a real browser tab, masking this bug during development.

Fix: Create the AudioContext and call audioContext.resume() inside the Start button's click handler on the very first press, and store the context in a useRef. When the countdown ends, the context is already running — call oscillator.start() directly without another resume(). Test this on the deployed Vercel URL by pressing Start and immediately switching away, then letting the countdown expire.

localStorage state causes Next.js hydration mismatch on the V0 app

Symptom: The server renders the component with an empty initial state (no localStorage available during SSR). The client hydrates with whatever is stored in localStorage. React detects that the rendered HTML doesn't match what the client would render, logs a hydration error, and may render incorrectly.

Fix: Initialize all localStorage-dependent state as null or a defined default value that matches the server render. Inside a useEffect (which only runs client-side), read localStorage and update the state. The pattern with usehooks-ts useLocalStorage handles this correctly if you pass null as the initial value and check for null before rendering preset data.

Lap list causes scroll jank during long stopwatch sessions

Symptom: If the lap list component re-renders on every setInterval tick (every 100ms), a session with 50+ laps will re-render a 50-item list 10 times per second. This is unnecessary because lap data only changes when the user clicks Lap, not on every tick.

Fix: Wrap the lap list in React.memo with a custom comparison that returns true when the laps array reference is unchanged. Move the laps array outside the timer tick state so that ticks do not re-create the array reference. Only the timer display component should re-render on every tick — the lap list updates only when a new lap is added.

Best practices

1

Always use Date.now() as the time source for elapsed calculation, never accumulate setInterval ticks — drift under CPU or tab throttling is guaranteed otherwise

2

Create AudioContext on the first Start button click and store it in a useRef; do not create it at module load time or the browser will block it

3

Return a cleanup function from every useEffect that sets up a setInterval or addEventListener — unmounting without cleanup causes memory leaks and the 'state update on unmounted component' warning

4

Use tabular-nums (font-variant-numeric: tabular-nums) on the timer display to prevent digits from shifting the layout width on each tick

5

Wrap the lap list in React.memo so it only re-renders when laps.length changes, not on every 100ms tick

6

Add the visibilitychange listener to re-sync display time when the user returns to the tab — this is a 5-line addition that eliminates the most common user complaint about background throttling

7

Persist only the last used duration to localStorage, not the running state — restoring a 'running' timer after a page refresh is disorienting; idle with the pre-set duration is the right UX

When You Need Custom Development

A client-side timer covers 95% of use cases at zero cost. A few scenarios require server infrastructure that the browser-only build cannot provide:

  • Timer must sync in real time across multiple open browser tabs or devices simultaneously — requires WebSockets or Supabase Realtime, not localStorage
  • Integration with external time tracking platforms (Toggl, Clockify, Harvest) to automatically log completed sessions via OAuth and REST API calls
  • Custom audio experiences — recorded voice countdowns, music fade-outs, or dynamic soundscapes per session type require audio file hosting and potentially a streaming service
  • Enterprise timesheet compliance where sessions must be server-timestamped and signed to prevent manipulation — requires a trusted server clock and an append-only audit log

RapidDev builds these features for production

Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.

Get a fixed-price quote

Frequently asked questions

Will the timer keep running if I switch tabs?

The timer continues running — it uses Date.now() anchored to the start time, so elapsed time accumulates even when the tab is hidden. However, the display updates will be throttled to 1-second intervals by the browser to save battery. When you return to the tab, add a visibilitychange listener that immediately recalculates from the stored startTime and the display will show the correct time instantly.

Can I add multiple timers running simultaneously?

Yes, by making the timer a self-contained component with its own useReducer state and rendering multiple instances on the page. Each instance has its own startTime reference. The main design decision is whether they share a single AudioContext (create one at the app level and pass it as a prop) or each create their own (simpler but uses more browser resources).

How do I save timer history across sessions?

For local persistence, write the completed session to localStorage on each reset or finish. For cross-device history, insert a row into a Supabase timer_sessions table (user_id, mode, duration_seconds, laps jsonb, started_at, ended_at) when the session ends. Add the optional Supabase table from the data model section and include it in your prompt.

Can the timer send a notification when it finishes — even if the tab is not focused?

The Web Notifications API can show a system notification when the countdown ends. Call Notification.requestPermission() on user interaction (the Start button), then call new Notification('Timer done!') when the countdown hits zero. This works even when the tab is in the background. For push notifications that work even when the tab is closed, you need a Service Worker — that is a separate feature to build on top of the timer.

What is the difference between a countdown timer and a stopwatch in terms of build complexity?

They share the same time engine (Date.now() delta) and state machine. The differences are: countdown starts at a user-specified duration and counts down to zero, triggering the audio alert on finish; stopwatch starts at zero and counts up indefinitely, exposing a Lap button. The SVG progress ring only applies to countdown. Both modes run on the same component — the mode toggle just changes which direction the display counts and whether the Lap button is visible.

Can I embed this timer in an existing web app page?

Yes — the timer is a self-contained React component with no external dependencies beyond the Web Audio API (built into every browser). Drop the component into any page that uses React. The only requirement is that the parent page must be served over HTTPS for the AudioContext to work in production, and the component must be marked 'use client' if it is inside a Next.js App Router project.

How do I add custom alarm sounds?

Place an audio file (alarm.mp3 or alarm.ogg) in the public/ directory of your project. Replace the AudioContext oscillator approach with an HTMLAudioElement: const alarm = new Audio('/alarm.mp3'). Call alarm.play() when the countdown ends. The trade-off: audio files give you richer sounds but add a file download for every user; the Web Audio API oscillator is zero-byte and always works but only generates simple tones.

Does this work on mobile browsers?

Yes. The Web Audio API and Page Visibility API are supported on iOS Safari 14.5+ and all Android browsers. The main difference on mobile is that Safari on iOS may suspend the AudioContext when the page is backgrounded — create the context on the Start tap and test on a real iPhone, not just the Chrome DevTools mobile simulator which behaves differently.

RapidDev

Need this feature production-ready?

RapidDev builds a timer and stopwatch into real apps — auth, database, payments — at $13K–$25K.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.