SlateBeaverSlateBeaver
SlateBeaverSlateBeaver
Log in
← Blog/Engineering

Building a server-side timer that survives lid-close

The timer in Aero tracks how long a developer has spent on a ticket. This sounds straightforward. It is one of the most technically interesting problems we've solved at SlateBeaver, because of a constraint that seems small until you try to build around it: the timer has to keep counting even when the browser is closed.

This article is a detailed account of the architecture we settled on, the approaches we rejected, and the specific problems you run into when you move time-keeping responsibility from the client to the server.

The lie that client-side timers tell

A client-side timer is an illusion. It is a JavaScript setInterval running in a browser tab, incrementing a local counter, sending periodic updates to a backend. The number it shows is a function of how long that tab has been running, not how long the timer has been active.

Close the laptop. The setInterval stops. The counter freezes. Open the laptop six hours later. The counter resumes from where it left off. The timer has 'lost' six hours. The developer looks at their time log at the end of the week and finds that their 8-hour day recorded 5.5 hours because two lid-close events weren't counted.

Kill the tab. Same result. Network connectivity drops. The periodic sync fails silently. The backend has one number; the client has another. When the client reconnects, which one wins?

These are not edge cases. On a development machine used by a real engineer across a real workday, all of these things happen routinely. A timer that cannot handle them is not a timer - it is a suggestion.

The architecture: tick on the server, display on the client

The core insight is that the authoritative elapsed time should be computed from two server-side timestamps: when the timer was started and the current server time. The client never stores elapsed time. It stores a start timestamp. The elapsed time it displays is always computed as `now − start_timestamp`, where `now` comes from the server.

When a developer starts a timer, we record a row in the database: ticket_id, user_id, started_at (server timestamp), ended_at (null). When they stop it, we set ended_at. The elapsed time for any period is always ended_at − started_at. The client never does arithmetic on wall-clock time - it displays what the server tells it.

For the live display while the timer is running, the client computes display_time as Date.now() minus a client_start_offset that is set once when the page loads and calibrated against the server timestamp. This is a display optimization, not the source of truth. The source of truth is the database.

Handling disconnect and reconnect

The interesting case is a running timer when the client goes offline. The display keeps counting using the local clock, but the backend doesn't need to be informed - the timer is already running as a database row. When the client reconnects, it asks the server: 'Is this timer still running? What time is it?' The server responds with the current elapsed time computed from the database. The client updates its display. The user sees continuity.

The session does not die when the tab closes. The started_at record exists in the database until the developer stops the timer. They can close the laptop, come back the next morning, open a different browser, and the timer is still running - because the timer was never in the browser. The browser was just a display.

The reconciliation algorithm

Conflicts arise when a developer has two sessions - two browser tabs, a browser and a mobile device - with the same timer running. We handle this with a soft-locking model: the most recent resume event wins. A timer can only be in one state (running or paused) per user, and the state is stored server-side with an optimistic concurrency token.

When two clients try to start the same timer simultaneously, one succeeds and the other receives a conflict response. The losing client shows a brief notification: 'Timer started in another tab.' The developer sees the correct elapsed time either way.

Offline mode and clock drift

Clock drift between client and server is a real problem for display accuracy. A developer's laptop clock might be several seconds off from the server. For a timer that shows hours and minutes, this is invisible. For a timer that tries to show seconds, it becomes noticeable.

Our solution is to perform a one-time clock offset calculation when the client initializes: we send a server timestamp and record the client time at the moment the response was received. The offset is server_time − client_time. All subsequent display calculations add this offset. It is not perfectly accurate - the network latency is folded in - but it is accurate to within a second for almost all practical cases.

For offline mode, we show a slightly different visual state: a dimmed timer with a syncing indicator. The developer knows the display may be slightly off. When connectivity returns, the display snaps to the server-computed value.

What we learned shipping this in production

The thing that surprised us most in production was not a technical problem. It was a behavioral one. Developers forget to stop timers. A server-side timer that survives lid-close also survives the end of the workday, and the weekend, and the Monday morning standup. We added a maximum duration cap (24 hours) after the first week, and a 'your timer has been running for X hours - still working?' notification after the second week.

The architecture is more complex than a client-side timer. The infrastructure cost is trivial - a database column and a read on page load. The developer experience improvement is significant enough that we have never had a request to revert to a client-side approach.

Author
SlateBeaver EngineeringEditorial desk
More posts →
Related product

SlateBeaver Aegis manages credentials with per-reveal audit logs, 9-role RBAC, and .env drift detection. 14-day free trial.

Explore Aegis →