A tick is not a second.
Our timer paid people 44% of their time for months. The bug was one line. The fix was not the line you would guess.
A member finished a long day and their session read 5.20 hours. The activity ledger for the same window — the timestamped buckets that only exist because tracking was running across them — read 11.75 hours. Two numbers, one afternoon, produced by the same running application, and one of them was going on an invoice.
The ledger was right.
Nothing. It promises nothing.
The heartbeat was a timer that fired every second and added a second:
setInterval(() => {
elapsed += 1;
}, 1000);
Read it out loud and it sounds like a definition. It is closer to a wish. setInterval(1000) is a
request to be woken no sooner than a second from now, and macOS answers that request differently
depending on whether it thinks you matter. An app that is not frontmost gets its timers coalesced —
deferred by roughly 1.2 seconds — so ticks that were supposed to land every 1.0s actually landed
about every 2.3s.
Every tick still added exactly one second.
A time tracker is, by definition, the app you are not looking at. The one state we never tested in was the only state it ships in.
That is the whole bug, and it explains why it survived so long. On a developer's machine, with the window in front and the profiler open, the heartbeat is accurate to the millisecond. It is only wrong when somebody is working — in their editor, in a call, in a browser — which is to say it was wrong in production and right in every demo.
The activity bucket loop, running in the same process under the same deferral, had stayed accurate the entire time. It never counted ticks. It measured a span between two observed timestamps and credited that. So the fix was not to make the timer more precise — you cannot — but to stop treating the timer as a source of truth and start treating it as an interrupt:
export function resolveTickCredit(gapMs: number, sleepGapMs: number): TickCredit {
if (!Number.isFinite(gapMs) || gapMs <= 0) {
return { kind: "credit", seconds: 0 };
}
if (gapMs >= sleepGapMs) {
return { kind: "gap", awaySeconds: Math.round(gapMs / 1000) };
}
return { kind: "credit", seconds: gapMs / 1000 };
}
Three branches, and each one is a decision somebody could otherwise get wrong at 2am inside a React
effect. Pulling it out as a pure function was not tidiness. The driver in App.tsx is exactly where
this went silently wrong for months; a rule that lives in a component is a rule nobody can unit-test
in isolation, and a rule nobody tests in isolation is a rule that drifts.
Note the second branch does not credit a partial amount, or an average, or a best guess. It credits nothing.
Why a long gap is worth zero, unconditionally
If eleven minutes pass between two ticks, something happened, and from inside the process there is no way to tell which something. The machine slept. The scheduler stalled. The app was suspended. Every one of those looks identical to a JavaScript timer that woke up late, and exactly one of them — none of them — is work.
So a gap at or over the sleep threshold is never credited. Not reduced. Not prorated. Zero.
This is the hard bound that keeps the heartbeat honest, and it is what separates measuring a delta
from the pattern we ban outright: deriving credit from a lastResumedAt that might be hours old and
paying out everything since. Here the anchor is reset on every single tick, so the maximum any one
tick can ever pay is the sleep threshold itself.
The deeper error was not arithmetic. It was letting a scalar answer a question it has no information about.
An elapsed counter is a single number and a start time. It carries nothing about when its seconds
were earned, which is why it can never attribute a day — sessions routinely cross midnight, and one
of ours ran for four days. For a while the day total was computed as
min(elapsedSeconds, secondsSinceMidnight), which looks like an attribution and is actually just a
cap. Before 5am it rendered the wall clock as tracked work: open your laptop at 00:05 and the app
would proudly report five minutes of work you had not done.
Day totals now come from the bucket ledger and nowhere else. Buckets are real timestamped intervals. They know which day they are in, because they were there.
When the clock and the evidence disagree, the evidence wins. It is the only one of the two that was present.
A timer running fast is a bug in a stopwatch. A timer running slow, in software that decides what somebody gets paid, is something else. It ran slow for months, quietly, in the direction that cost the worker rather than the customer — the direction nobody files a support ticket about, because nobody's spreadsheet ever came up short.
That is the part worth remembering. Not that a tick is not a second, though it is not. That the errors which survive longest are the ones that fail in your favour.
Questions, corrections, or a war story of your own? Write to hello@worktrackhq.com — a person reads every message.

