Our relative timestamps round instead of floor, so 91 seconds ago reads as 2m ago
Notifio is a desktop app that checks rental search pages on a loop and emails you when a new listing shows up. Almost every number in its UI is a time: when a search was last checked, how long the check took, when a blocked host gets retried, when a listing turned up.
All of that goes through one 25-line file. I read it properly for the first time last week and found that it rounds, everywhere, and that rounding means two different things depending on which direction the clock is pointing.
The file
/** Small shared formatters for the status strip and the finds list. */ /** "12s ago", "4m ago", "2h ago". Empty string for a null timestamp. */ export function relativeTime(at: number | null): string { if (!at) return ""; const seconds = Math.max(0, Math.round((Date.now() - at) / 1000)); if (seconds < 10) return "just now"; if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours}h ago`; return `${Math.round(hours / 24)}d ago`; } /** "in 45s", "in 3m". Used for the retry countdown on a blocked search. */ export function countdown(until: number): string { const ms = until - Date.now(); if (ms <= 0) return "any moment"; if (ms < 60_000) return `in ${Math.max(1, Math.round(ms / 1000))}s`; return `in ${Math.round(ms / 60_000)}m`; } /** "1.4s". Shown on a row so a slow site is visible rather than guessed at. */ export function seconds(ms: number | null): string { if (ms === null) return ""; return `${(ms / 1000).toFixed(1)}s`; } Four Math.round calls in relativeTime, one in countdown. The conventional choice for elapsed time is Math.floor, and I want to argue that the conventional choice is wrong in one of these functions and right in the other.
Rounding an elapsed time can only overstate
relativeTime(Date.now() - 91_000) gives "2m ago". Ninety-one seconds is one minute and thirty-one seconds. Math.floor would say 1m ago, which is true and under-informative. Math.round says 2m ago, which is false and over-informative.
Work out the sign of the error and it stops being a toss-up. A rounded elapsed time is wrong in one direction only: it can claim something is older than it is by up to half a unit, and it can never claim something is newer than it is. Floor does the opposite. It can claim a check that happened 119 seconds ago was "1m ago", which is the direction that matters here, because every one of these timestamps is answering the same underlying question:
Is this thing still working?
A monitor that says last check 1m ago when it was really two minutes is a monitor reassuring you with a number it made up. A monitor that says 2m ago when it was really 91 seconds has rounded against itself. If the app is going to be wrong by up to thirty seconds, I want it wrong in the direction that makes me look at it.
That is not an accident of the implementation, but I cannot claim it was planned either. The comment above the function says "12s ago", "4m ago", "2h ago" and nothing about rounding. I am adopting it after the fact, which is a thing you are allowed to do as long as you write the reason down somewhere, which is partly what this post is.
Rounding a countdown breaks a promise
countdown is a different proposition, because it is not reporting, it is promising. It renders on the retry chip of a search whose host has told us to wait:
{waiting && ( <button onClick={onRetry} title="Stop waiting and check this search again now" className={`${CHIP} group min-w-[104px] ...`} > <span className="group-hover:hidden">Retry {countdown(waiting)}</span> <span className="hidden group-hover:inline">Retry now</span> </button> )} With 89 seconds left, Math.round(89_000 / 60_000) is 1, so the chip says Retry in 1m. A minute later nothing has happened, because there are still 29 seconds to go. The chip has by then updated itself to in 29s, so it self-corrects rather than lying permanently, but there is a moment where the app said one minute and meant one and a half.
For a countdown, Math.ceil is the honest function. The error should fall on the same side as it does for elapsed time: never promise sooner than you can deliver. One character.
I have not changed it yet, and the reason is the thing hovering over that chip. Every countdown in this app is skippable. Hover the chip and the text swaps to Retry now, click it and the wait is over. We decided a while back that if the app shows you a countdown it has to let you out of it, and that decision takes most of the weight off the exact number. A countdown you can cancel is a hint. A countdown you cannot cancel is a contract, and a contract should round up.
Note also the Math.max(1, ...) on the seconds branch. Without it, 400ms remaining renders as in 0s, which is the single worst string a countdown can produce: it is both wrong and finished-looking. The floor of 1 means the chip counts in 2s, in 1s, any moment, and any moment is the right register for a number we do not actually control.
The clock that drives all of it
None of these functions are reactive. They read Date.now() when they run, and React does not re-render because time passed. So there is a hook whose entire job is to make a component re-render on a schedule:
export function useNow(intervalMs = 1000, active = true): number { const [now, setNow] = useState(() => Date.now()); useEffect(() => { if (!active) return; const timer = setInterval(() => setNow(Date.now()), intervalMs); return () => clearInterval(timer); }, [intervalMs, active]); return now; } Three components call it, with three different intervals and three different conditions:
// SitesList: one second, but only while something is counting down. useNow(1000, hasCountdown || app.status === "polling"); // MonitorCard: five seconds, only while the monitor is in an active state. useNow(5000, isActive); // RecentFinds: thirty seconds, only if there is anything in the list. useNow(30_000, app.finds.length > 0); The intervals are chosen against the formatter, not against the component. SitesList is the only one that renders a countdown, whose output changes every second below a minute, so it ticks every second. RecentFinds renders relativeTime on listings that are minutes to days old, where the string changes at most once a minute, so thirty seconds is already generous and a one-second tick would be 29 wasted renders out of 30.
The active flags matter more than the intervals. This app's whole job is to sit in the background for hours, and a React tree re-rendering once a second while nobody is looking at it is a laptop fan. hasCountdown is computed per render from whether any search has a nextRetryAt:
// Only tick while something is actually counting down. const hasCountdown = sites.some((s) => { const nextRetryAt = app.runtimeFor(s)?.nextRetryAt; return nextRetryAt !== null && nextRetryAt !== undefined; }); So the second-by-second timer exists only while a host is in backoff or a poll is in flight, and the rest of the time the tree is still. The dependency array is [intervalMs, active], so flipping active tears the interval down rather than leaving it running against a stale condition.
There is still a seam in SitesList: above one minute, countdown returns the same string for sixty consecutive ticks. The fix is to compute the next interesting moment and set a timeout for exactly that, and it is not worth it for one chip on an app with at most fifteen searches running.
What I would take from this
Pick the rounding mode from the sign of the error you can live with, not from what reads nicest. "Round to nearest" is the default because it minimises average error, and average error is almost never the thing that matters in a status line. What matters is which way the UI lies when it lies, and whether that direction makes the user check or relax.
Elapsed time: round up, make them look. Countdown: round up, make them wait. Both of those are the same rule, which is a good sign that it is the rule.
The app this came out of is at notifio.app, and there is a page per site it watches under alerts if you want to see what it is timing.