DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our longest practice run is 30 minutes in one page view, and two seconds in a hidden tab ends it

Most practice on CogniPrep is short. A test is a few minutes, sometimes with a clock, sometimes without, and the browser never has to do anything unusual.

The newest provider broke that. One test in the UK train driver battery is a vigilance test: 30 minutes of watching for something that rarely happens. At the assessment centre it runs without a break and cannot be paused, so ours does not pause either, and it says so on the public page and again on the brief before you start: leave the page for more than two seconds and the run is void.

Writing that rule down took a sentence. Making the browser honour it for half an hour took four separate fixes.

1. The hidden tab, checked twice

The obvious implementation is a visibilitychange listener that starts a two second timer when the page hides and voids the run if it is still hidden when the timer fires. That is half of it. The other half is that browsers throttle timers in background tabs, and a page that is hidden and shown quickly may never run your callback at the moment you expected.

So the check runs on both edges:

const onVisibility = () => {
  if (document.hidden) {
    hiddenAt = performance.now();
    timer = window.setTimeout(() => {
      if (document.hidden) voidRun();
    }, CONFIG.hiddenAllowanceMs);
  } else {
    window.clearTimeout(timer);
    if (hiddenAt !== null && performance.now() - hiddenAt > CONFIG.hiddenAllowanceMs) voidRun();
    else void requestWakeLock();
    hiddenAt = null;
  }
};
document.addEventListener('visibilitychange', onVisibility);
Enter fullscreen mode Exit fullscreen mode

The timer catches the candidate who switches away and stays away. The elapsed comparison on return catches the case where the timer was throttled, deferred or never fired at all, which is the one a short manual test will not reproduce. Neither alone is enough, and the second one is three lines.

You can watch the throttling yourself in any console. Run this, switch tabs for ten seconds, come back:

let ticks = 0;
const id = setInterval(() => ticks++, 100);
setTimeout(() => { clearInterval(id); console.log('ticks in 15s, expected ~150:', ticks); }, 15000);
Enter fullscreen mode Exit fullscreen mode

2. The screen going dark on its own

Half an hour of watching a mostly static screen and pressing a key occasionally is exactly the input pattern that convinces a phone it has been abandoned. The Screen Wake Lock API exists for this, and it is best effort in every sense:

const requestWakeLock = useCallback(async () => {
  try {
    const nav = navigator as Navigator & { wakeLock?: { request: (type: 'screen') => Promise<WakeLockLike> } };
    if (nav.wakeLock) wakeRef.current = await nav.wakeLock.request('screen');
  } catch {
    // Not supported, or refused (battery saver): the run goes ahead without it.
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

Three things matter more than the request itself. It must be feature detected, because support is uneven. It must be inside a try, because a browser in battery saver mode rejects the promise rather than resolving it to nothing. And the lock is dropped when the page hides, so the visibility handler above re-requests it on the way back in, in the same branch where it decides the run is still valid.

It also has to be released when the component unmounts:

// Leaving the game mid-run must not hold the screen awake.
useEffect(() => () => releaseWakeLock(), [releaseWakeLock]);
Enter fullscreen mode Exit fullscreen mode

A candidate who abandons a run and navigates to the pricing page should not have a phone that never sleeps. The brief also tells them plainly that we ask the browser to keep the screen awake and that not every browser can, so plug in.

3. React, politely asked to stay out of it

A 30 minute animation driven by React state is 30 minutes of re-renders in a tree that is not changing. So during the run there is no state at all. One requestAnimationFrame loop computes what should be on screen and writes two inline styles to a node held in a ref:

const paint = (visual) => {
  const el = squareRef.current;
  if (!el || visualRef.current === visual) return;
  visualRef.current = visual;
  el.style.visibility = visual === 'off' ? 'hidden' : 'visible';
  el.style.backgroundColor = visual === 'dark' ? SHADE_DARK : SHADE_ON;
};
Enter fullscreen mode Exit fullscreen mode

The early return matters as much as the writes: the loop runs every frame, the visual changes rarely, so most frames touch nothing.

Two details about the clock. The run does not start when the effect runs; it starts on the first frame that has actually been painted, through a double requestAnimationFrame, so our own layout and hydration are not charged to the candidate's half hour:

export function afterPaint(callback) {
  let inner = 0;
  const outer = requestAnimationFrame(() => {
    inner = requestAnimationFrame(() => callback(performance.now()));
  });
  return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); };
}
Enter fullscreen mode Exit fullscreen mode

And key presses use the event's own timeStamp rather than a reading taken inside the handler, because that property shares performance.now()'s clock and is set when the event happened rather than when React got round to telling us.

4. The session nobody touches for half an hour

The fourth problem is the one that does not show up in any browser API. For 30 minutes the page makes no requests at all. Then it makes one, which has to succeed, because a voided run is recoverable and a lost result is not.

The build brief for the round made that an explicit deliverable: verify that the session lifetime and the submit path still work after 30 minutes, and report what you found.

The honest report came back in two halves. Nothing in our own code expires inside that window: no limit in the app, no payload size anywhere near the size of what gets submitted, no client-side timeout on the path that stores a result. But the managed Postgres project's own session time box and inactivity settings are configured in a dashboard, not in the repository, so they could not be verified from the code and were handed back to the owner to confirm rather than written up as checked.

That split is the useful bit. "We tested it and it works" would have covered a setting nobody looked at. Saying which half was verified in the repo and which half lives somewhere a reader of the repo cannot see is a slower sentence and a true one.

See it

  • Open the train driver hub and search the page for WAFV is the full 30 minutes. The paragraph states the void rule before anybody pays for or starts anything, which is the product half of this post: an anti-cheat rule that is only fair if it is disclosed up front.
  • The same page lists the test as "A square flashes for 30 minutes; respond only when it darkens". The run itself shows no clock and no progress bar, because the real one has neither, which is also why there is nothing on screen to tell you how much of the half hour is left.
  • Then paste the interval snippet above into a console on any site and switch tabs. The gap between the ticks you expected and the ticks you got is the reason the visibility check happens twice.

Top comments (0)