How a Countdown Timer Became a State-Synchronization Problem

What Office Escape taught me about timestamps, browser throttling, offline state, and multiple UI surfaces

By

Office Escape started as a joke: show how much time is left before the workday ends, and if the day is already over, keep counting into negative time.

The first version was basically a countdown.

Then I added a popup, PWA behavior, browser-tab time, configurable work schedules, overtime, overnight shifts, and a Chrome extension.

At that point the timer stopped being the hard part.

The hard part became making every surface agree on what "now" means.

The naive version

The most obvious countdown implementation is something like this:

let remaining = endTime - Date.now()
 
setInterval(() => {
  remaining -= 1000
  render(remaining)
}, 1000)

It looks fine while the page is visible.

But browsers do not promise that background timers will run exactly every second.

Tabs get throttled. Mobile browsers suspend work. Extension popups are created and destroyed. Service workers wake up for events instead of behaving like permanent processes.

So if the application treats "I ran my interval 37 times" as the truth, the displayed time can drift away from reality.

That is exactly the kind of issue I started seeing.

The popup could show a different time. The browser-tab title could stop updating after I switched tabs. Offline behavior introduced another path. Overnight shifts made simple comparisons ambiguous.

The fix was to stop storing the countdown

The most important design decision was this:

The countdown value is derived state. The timestamps are the source of truth.

Instead of persisting "2 hours, 13 minutes, 41 seconds left", the system should persist the schedule needed to calculate that value.

For example:

const schedule = {
  startAt: '2026-09-06T09:00:00+05:30',
  endAt: '2026-09-06T17:00:00+05:30',
}

Every surface can calculate:

const remainingMs = new Date(schedule.endAt).getTime() - Date.now()

If a tab sleeps for five minutes, nothing has to "catch up" through five minutes of missed interval callbacks.

The next render simply asks the clock again.

Intervals became refresh signals, not timekeepers

I still use periodic updates to repaint the UI.

But there is an important mental difference:

Wrong:
interval advances the clock
 
Better:
real clock advances time
interval asks for a fresh view

That distinction makes background recovery much simpler.

When a tab becomes visible again, I can immediately recalculate from the current timestamp instead of trusting how many timer callbacks happened while it was hidden.

The same applies to a browser extension popup. Opening the popup does not need a continuously running hidden countdown process. It can load the current schedule and calculate the current value immediately.

Multiple surfaces need a small state contract

Once I had a web page, installed PWA, popup, and extension, I needed to decide what data they actually share.

I found it useful to separate the state into two groups.

Persistent configuration:

workday start/end
breaks
weekend settings
custom events
preferences

Ephemeral display state:

remaining seconds
formatted H:M:S
progress percentage
"working" / "overtime" label

Only the first group deserves synchronization.

The second group can always be regenerated.

This reduces both network traffic and consistency bugs.

Overnight shifts forced me to model states explicitly

A normal daytime schedule makes this logic tempting:

if (now < end) {
  return 'working'
}
 
return 'overtime'

That becomes less obvious when a shift starts before midnight and ends the next morning.

It also gets interesting around transitions such as:

working -> completed -> overtime

or when the page wakes up after the expected end time.

I stopped thinking of the timer as only a number and started treating the work session as a state machine.

Conceptually:

BEFORE_SHIFT

WORKING

END_TRANSITION

OVERTIME

The displayed number is only one output of that state.

This also made UI transitions easier to test because I could test state boundaries instead of waiting for a real workday to end.

Offline support changed what the server was allowed to do

A countdown application should not stop knowing the time just because the internet disappears.

That became an architectural constraint for the PWA.

The browser needs enough cached configuration to continue calculating the current work state locally.

The server is useful for account-backed settings and cross-device synchronization, but it should not be required to answer this every second:

How much time is left right now?

That question is already answerable from local data plus Date.now().

This became a useful general rule for me:

If the client can deterministically derive a high-frequency value from low-frequency state, sync the low-frequency state and derive the rest locally.

Browser-tab titles are a weird test case

I wanted the current time left in the tab title so I could see it while working in another tab.

That exposed browser throttling quickly.

A background tab is exactly where you should expect interval precision to degrade.

The solution is not to fight the browser by scheduling more work. The solution is to make each update stateless enough that any callback can recover the correct value.

Whenever the browser gives the app CPU time, the title should be calculated from the actual current timestamp.

The title might not repaint every exact second in every browser state, but it should never accumulate timing error.

Those are different guarantees.

The extension made event-driven thinking more important

Browser extensions encourage a different lifecycle from a normal page.

A popup is temporary. A service worker is event-driven. The main tab may or may not be open.

Trying to keep a permanent one-second loop alive across all of those surfaces would be both unreliable and wasteful.

Instead, the architecture works better when events move durable state and each surface derives its own presentation:

settings changed

persist / sync settings

notify interested surfaces

each surface recalculates from timestamps

That is much simpler than trying to synchronize every displayed second.

What I learned

I expected the interesting part of Office Escape to be the funny UX around escaping work.

The engineering lesson ended up being about state.

The important ideas were:

  • store timestamps, not countdown values
  • treat intervals as repaint triggers, not clocks
  • derive high-frequency UI locally
  • synchronize configuration, not every render
  • design explicitly for background throttling
  • make offline behavior part of the data model
  • model overnight and overtime transitions as states
  • let extension surfaces be event-driven

A countdown is simple when it lives in one component.

Once the same concept has to survive tab switching, offline mode, browser suspension, extension popups, and cloud sync, it becomes a small distributed-state problem.

That was a much more useful lesson than I expected from a timer app.

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

© 2026 Anuka Mithara. All rights reserved.

GitHubLinkedInanukamithara.com
Close Menu