Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

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

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-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.

Day view with a running timer

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]

Recovery dialog

"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.

Weekly history

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.