Ayobami ZenthosGet in touch
All insights

A rest timer that rings with the screen off

How the Zenthos Gym workout screen hands a running rest timer to the server, so the member's phone still buzzes when it ends, and never rings twice.

Live check-in at the front deskMember home screenManager overview

A rest timer looks like the easiest feature in a workout app. Count down 90 seconds, then beep.

Then you watch a real member use it. They finish a set, start the timer, lock their phone and put it in their pocket. On the web, a locked screen means the page is paused. The countdown stops ticking, the beep never comes, and they stand there scrolling Instagram for five minutes.

Zenthos Gym runs in the browser and installs to the home screen, so it had to solve this without a native app. This is how.

Hand the timer over when the app disappears

The trick is to stop relying on the page at the exact moment it stops being reliable. The app listens for the page becoming hidden. If a workout is running and the rest timer has more than a second and a half left, it writes one row to the database with the time the rest ends:

if (rest && rest.endsAt > Date.now() + 1500) {
  await supabase.from('rest_alarms').upsert({
    user_id: userId,
    fire_at: new Date(rest.endsAt).toISOString(),
    title: 'Rest over',
    body: rest.label.slice(0, 120),
  })
}

The member’s ID is the table’s primary key, so a member can only ever have one alarm waiting. Starting a new rest replaces the old one instead of stacking them. Row Level Security means each member can only write their own.

At the same moment, the app leaves a quiet “workout in progress” card in the notification shade, so one tap brings them straight back to the live workout.

A database that checks the clock

Something has to notice when that time arrives. A scheduled job in Postgres runs every five seconds, and it is deliberately lazy. It does nothing at all unless an alarm is actually due:

if not exists (select 1 from rest_alarms where fire_at <= now() + interval '1 second') then
  return;
end if;

Only then does it call the push sender. On a quiet night that is thousands of ticks that cost almost nothing.

Claim each alarm exactly once

Two ticks can overlap, or the sender can be woken twice. Sending a member two “Rest over” buzzes for one rest would be worse than sending none, so the sender claims alarms with a single statement that deletes and returns them in one step:

delete from rest_alarms
 where user_id in (
   select user_id from rest_alarms
    where fire_at <= now() + interval '1 second'
    for update skip locked
 )
returning *

for update skip locked means a second sender running at the same instant simply skips the rows the first one has already taken. Whatever is returned belongs to that sender alone, and it is gone from the table before anyone else can see it.

Late is worse than never

The push itself is sent to every device the member has subscribed, marked as high urgency, with a short expiry:

// A rest alert that arrives late is worse than none: the member has moved on.
const REST_TTL_SECONDS = 60

If a phone is offline and the push service cannot deliver within a minute, it drops the message. A “Rest over” buzz that arrives ten minutes later, in the car park, is just noise.

Coming back cancels everything

When the member returns to the app, the page takes over again. It ends the rest if it has already run out, deletes any waiting alarm and clears the “in progress” card:

if (rest && rest.endsAt <= Date.now()) endRest()
void supabase.from('rest_alarms').delete().eq('user_id', userId)

So there is only ever one thing responsible for the timer: the screen while it is visible, and the server while it is not. Nothing rings twice.

What I took from it

  • Design for the pocket, not the screen. On the web, a locked phone is the normal case.
  • Hand work to the server at the moment the client becomes unreliable, and take it back the moment it returns.
  • Make the scheduled job cheap when there is nothing to do. It will run far more often than it fires.
  • Claim, then send. Deleting and returning in one statement turns “maybe twice” into “exactly once”.
  • Give time-sensitive alerts an expiry. Some messages are only true for a minute.
See Zenthos Gym