DEV Community

Daniel Pertu
Daniel Pertu

Posted on

If we show the user a countdown, we have to let them skip it

Notifio polls rental search pages every thirty seconds and tells you the moment something new appears. Almost everything in it is a timer. I have written about how to poll someone else's site without getting banned and about the backoff ladder that decides how long to leave a site alone after it refuses us.

What neither post covered is the interface question those timers create. Every one of them is a wait the user did not choose and cannot see the reasoning for. This post is about which of them got an override button, which did not, and the rule that turned out to separate the two.

The four waits

const POLL_INTERVAL_MS = 30_000;
const POLL_JITTER_MS = 5_000;

/**
 * Floor between cycles, even when one overran the interval. Back-to-back with
 * no gap at all would look mechanical to the sites we scrape and leaves no room
 * to hit Stop.
 */
const MIN_GAP_MS = 5_000;
Enter fullscreen mode Exit fullscreen mode

Plus the backoff ladder, which goes from one minute to an hour depending on how a site refused us and how many times in a row, and a short randomised pause before an auto-reply is submitted.

Two of those four are visible in the window. The cycle ("Next poll in 27.4s") and the backoff ("pararius.nl, blocked, retry in 3m"). Those are the two with buttons, and that is not a coincidence.

Check now, and the 250 milliseconds

export function checkNow(): boolean {
  if (!_running || !_tick || _polling) return false;
  if (_timer) clearTimeout(_timer);
  const run = _tick;
  _timer = setTimeout(run, 250);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Six lines, and three decisions.

It does not call the tick. It cancels the pending timer and schedules the same tick function 250ms out. Calling tick() directly would work most of the time and give me a second path into the cycle, with its own relationship to the re-entrancy guard and its own ordering against a timer that may be about to fire. There is one scheduler for polls and this goes through it.

It returns a boolean rather than nothing, and false means "not started, because a cycle is already running". That boolean travels all the way to the UI:

async function handleCheckNow() {
  const started = await app.checkNow();
  showToast(started ? "Checking every search now" : "Already checking");
}
Enter fullscreen mode Exit fullscreen mode

A button that always says "checking now" whether or not anything happened is a button that teaches the user to distrust the app. "Already checking" is both true and useful, and it cost one return value.

And the request that carries it refuses rather than starting the monitor for you:

app.post('/api/monitor/check-now', (req: Request, res: Response) => {
  if (!monitor.isRunning()) {
    return res.status(409).json({ error: 'Start monitoring first.' });
  }
  const started = monitor.checkNow();
  res.json({ ok: true, started, alreadyChecking: !started });
});
Enter fullscreen mode Exit fullscreen mode

Retry, which overrides a policy rather than a schedule

The per search retry is the more interesting one, because the thing it skips is not a clock we set for convenience. It is a decision about not annoying somebody else's server.

/**
 * Stop waiting on a blocked site and try it again now.
 *
 * The user can see the countdown, and they are often looking at this because
 * they have just gone and passed the check in their own browser, so making
 * them wait out a timer we chose for them would be perverse.
 */
app.post('/api/monitor/retry', (req: Request, res: Response) => {
  const { url } = req.body ?? {};
  if (typeof url !== 'string' || !url) {
    return res.status(400).json({ error: 'url required' });
  }
  monitor.clearBackoff(url);
  const started = monitor.checkNow();
  res.json({ ok: true, started });
});
Enter fullscreen mode Exit fullscreen mode

That comment is the whole argument for the feature. Our backoff is an inference: the site refused us, so it probably still will. The user standing in front of the machine may have information we do not, namely that they have just opened the site in their own browser and cleared whatever wall was in the way. When the user knows something the policy cannot, the policy has to be overridable.

Clearing it is host wide, and it touches the UI state as well as the registry:

export function clearBackoff(siteUrl: string): void {
  const host = hostOf(siteUrl);
  const had = _backoff.get(host) !== undefined;
  resetBackoff(host);
  for (const state of siteState.snapshot()) {
    if (hostOf(state.url) === host) siteState.setNextRetry(state.url, null);
  }
  if (had) log(`[monitor] Wait cleared for ${host}, will retry on next poll`);
  // A successful login lifts any post-logout auth-alert suppression for the host.
  delete _authSuppressedUntil[host];
}
Enter fullscreen mode Exit fullscreen mode

Host wide because a refusal comes from the site, not from one of your searches on it, so five searches on one rental site stand down together and come back together. The loop over the runtime snapshot is there so the countdown disappears from all five rows the instant the button is pressed, instead of surviving until the next poll writes over it.

The same override, fired by something that is not a button

clearBackoff is called from one more place, and it is the one I like best:

// A fresh login usually also clears any anti-bot wall, resume this site.
monitor.clearBackoff(url);
Enter fullscreen mode Exit fullscreen mode

That is in the login confirm handler. The user did not ask us to retry. They went and logged in, and logging in is strong evidence that whatever made the site refuse us has been dealt with by a human passing a check in a real browser. Waiting out the remaining twenty minutes of our ladder after that would be the app ignoring the news.

The same handler for a manual log out does the opposite, and suppresses the "login required" alert for a while, so the app does not immediately email you about a state you created on purpose. Both are the same idea: a deliberate user action is information, and policy state that predates it is stale.

What the countdown needs to be worth skipping

None of this means anything if the wait is invisible, so the runtime state carries nextRetryAt and the row renders it:

/** "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`;
}
Enter fullscreen mode Exit fullscreen mode

A countdown that does not count is worse than no countdown, because a frozen "in 45s" reads as a hung app, and that is a whole post of its own about a hook whose return value nobody uses. The relevant part here is that the ticking is conditional on something actually counting down:

const hasCountdown = sites.some((s) => {
  const nextRetryAt = app.runtimeFor(s)?.nextRetryAt;
  return nextRetryAt !== null && nextRetryAt !== undefined;
});
useNow(1000, hasCountdown || app.status === "polling");
Enter fullscreen mode Exit fullscreen mode

The two waits with no button, and why

The poll interval has no user facing control. You cannot set it to five seconds. The randomised pre-reply pause has no control either.

The rule I ended up with is this: a wait gets an override when the user can know something we cannot, and does not when the wait exists to protect somebody other than the user.

Skipping a backoff after you have personally cleared a challenge is the first case. Polling a rental portal every five seconds is the second: the only party that benefits is the impatient user, the party that pays is a site we depend on, and the user who gets the whole account blocked did not win anything. Same for the reply pause, which exists so a message does not look machine sent to the landlord reading it.

Those two are not silent about it, though. The cycle logs Next poll in 27.4s every time, and logs a different line when a cycle overran the interval:

if (delay === MIN_GAP_MS && elapsed > target) {
  log(
    `Cycle took ${(elapsed / 1000).toFixed(1)}s, over the ${(target / 1000).toFixed(0)}s interval. Next poll in ${(delay / 1000).toFixed(1)}s`
  );
}
Enter fullscreen mode Exit fullscreen mode

Not overridable is fine. Not explained is not.

See it running

The app is a free download for Mac and Windows, the help page covers the monitor controls, and the per site pages such as Pararius and Funda describe what each search is actually watching.

Top comments (0)