Building a timer that survives browser crashes (Next.js + Supabase)
Every time-tracking tutorial starts the same way: setInterval(() => setSeconds(s => s + 1), 1000); And every one of them is wrong the moment the user closes the laptop lid. I recently built Dayflow, a daily-pla
Every time-tracking tutorial starts the same way:
setInterval(() => setSeconds(s => s + 1), 1000);
And every one of them is wrong the moment the user closes the laptop lid.
I recently built Dayflow, a daily-planning app with per-task timers, and spent most of the design effort on making the timer unable to lose time. Here's the approach.
1. Store timestamps, not counters
A timer session is a row:
create table time_sessions (
id uuid primary key,
user_id uuid not null,
task_id uuid not null,
started_at timestamptz not null default now(),
ended_at timestamptz, -- null = running
duration_seconds integer, -- set by trigger when ended_at is set
last_heartbeat_at timestamptz not null default now()
);
Elapsed time on the client is Date.now() − started_at (corrected by the server-clock offset the page was rendered with). A 1-second setInterval only triggers a re-render. Nothing accumulates, so a throttled background tab or a 3-hour sleep changes nothing.
2. Enforce "one running timer" in the database
create unique index one_active_timer_per_user
on time_sessions (user_id) where ended_at is null;
Now two tabs, a double-click, or a retried request physically cannot create two running timers.
3. Make start/stop atomic and use the database clock
create function start_timer(p_task_id uuid, p_stop_active boolean default false)
returns time_sessions language plpgsql security invoker as $$
declare v_active time_sessions; v_new time_sessions;
begin
select * into v_active from time_sessions
where user_id = auth.uid() and ended_at is null for update;
if found then
if not p_stop_active then raise exception 'active_timer_exists'; end if;
update time_sessions set ended_at = now() where id = v_active.id;
end if;
insert into time_sessions (user_id, task_id) values (auth.uid(), p_task_id)
returning * into v_new;
return v_new;
end $$;
The client never sends a timestamp when starting. The browser clock is untrusted; now() is the truth. security invoker means Row Level Security still applies inside the function.
4. Handle "the browser was closed while running"
This is the interesting UX question. If someone starts a timer at 14:00, closes the laptop at 14:05, and reopens it at 17:00 — were they working for three hours?
You can't know. So ask.
While a timer runs, the tab posts a heartbeat every 60 seconds (and via navigator.sendBeacon on pagehide). It only updates last_heartbeat_at. On the next page load, if the last heartbeat is more than 5 minutes old, the app shows:
Your timer is still running. Client Work has been running for 3h. The app was last open at 14:05.
[Stop at 14:05] [Stop now] [Keep running]
"Stop at 14:05" calls stop_timer(session_id, last_heartbeat_at), and the SQL clamps the value to [started_at, now()] so a bad client can't produce negative or future durations.
A plain refresh never triggers this because the heartbeat is fresh.
5. Completing a task stops its timer — in the same transaction
create function set_task_status(p_task_id uuid, p_status task_status) ...
if p_status in ('completed','cancelled') then
update time_sessions set ended_at = now()
where task_id = p_task_id and ended_at is null;
end if;
update tasks set status = p_status where id = p_task_id;
No "completed task still has a running timer" state is possible.
Result
Planned vs actual per day and per week, computed purely from these rows — the same numbers on every device, after every refresh.
Try it: https://dayflow-demo.vercel.app — click "Log in as demo user", start a timer, close the tab, come back.
I've packaged the whole app (planner, timer, weekly review, auth, RLS) as a starter kit if you'd rather start from working code: https://dayflowkit.gumroad.com/l/dayflow (disclosure: it's my product).
Happy to answer questions about the design in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.

