Why FastAPI requests still lead to stale terminals in React and Next

We ran into an strange bug during local testing - a restaurant order terminal stopped receiving new orders, even though all its API requests were coming back blazing fast in under 180 milliseconds. It turned out the server wasn’t dropping requests - the browser had simply stopped sending them. When the developer switched tabs, the browser throttled our background JavaScript, causing a gap between requests and letting the venue's order lease silently expire. Here's how we uncovered the gap between browser scheduling and server deadlines, stripped out brittle timing assumptions, and redesigned the recovery flow so staff never lose control of their order state.

Fast API responses can hide execution delays

On the surface, everything looked healthy while the terminal tab was open and visible. But as soon as the developer switched to a customer storefront in another tab, the terminal expired.

When we checked the logs, successful requests were taking just 170–185ms. Even the server's rejection response arrived in a snappy 174ms. The server was responding promptly, but that wasn't the same as hearing from the browser promptly.

This happened in a local demonstration app built with Next.js, TypeScript, and SQLite. The business rule was simple: to accept incoming orders, staff had to maintain an active lease on the venue by sending periodic heartbeat requests.

Why setInterval isn't a guaranteed schedule

The terminal was configured to send a heartbeat every 8 seconds, while the server allowed a 30-second window before marking the venue's lease as expired. On paper, that gave us plenty of breathing room.

QuantityValueEvidence type
Heartbeat interval8 secondsConfigured
Lease validity window30 secondsConfigured
Successful request durationsApproximately 170–185 msReported observations
Gap between request startsApproximately 42 secondsInferred from rounded observations

So how did an 8-second timer result in a 42-second gap?

The key takeaway was that request duration only measures how long an HTTP request takes after it leaves the browser. It tells you nothing about how long JavaScript waited in the event loop before making that call. Modern browsers aggressively throttle or suspend setInterval in hidden background tabs to save battery and memory. If the browser isn't executing your callback, smaller interval values won't fix the problem.

Protecting state by Never Reopening Orders Automatically

When a heartbeat arrives late, the server’s instinct to reject it is actually correct. A venue should only receive orders if staff are actively monitoring the screen. Automatically reviving an expired lease on a late heartbeat risks reopening order intake when no one is paying attention.

We also had to fix how the UI communicated this failure.

Wake Locks aren't silver bullets: Keeping a display on isn't the same as keeping background JavaScript running. Wake locks can be denied, released, or ignored by device power-saving modes.

UX ambiguity: When a connection dropped or timed out, the UI displayed the exact same "Ready when you are" message as a deliberate pause by staff. We needed explicit states for Interrupted vs Stopped.

Persisting intention in sessionStorage

Originally, page refreshes generated a brand-new terminal ID and wiped React state, making recovery impossible. We updated the component to store terminal identity, intended state, and active filters in sessionStorage.

On reload, the client uses this saved intent to query the server, but it never assumes authority. The server checks lease ownership and validity before allowing a renewal.

Code Clean-up & Annotations

TypeScript// Explicitly renew an existing lease during state recovery
if (
  status.state === 'active' &&
  ['active', 'starting'].includes(saved.intent)
) {
  // Send a 'beat' to validate ownership, NOT an automatic 'open'
  const response = await request('terminal', abort.signal, {
    action: 'beat',
    terminalId: saved.terminalId,
  });

  if (isValidLease(response)) {
    commitState('active');
    void callbacks.current.keepAwake();
  }
}

Key Rule: Reconnection re-establishes communication, but human staff must explicitly click Resume to accept new orders after an interruption.

Testing edge cases vs real device conditions

To verify our fixes, we ran 12 backend unit tests along with browser recovery checks. By manually modifying database timestamps and simulating network drops, we confirmed that:

Refreshing an active tab re-acquired the lease seamlessly.

Expired or disconnected terminals stayed safely in an Interrupted state until manually resumed.

Deliberately stopped terminals remained stopped.

The Lesson: Mocked tests and simulated database edits prove your logic flows work, but they don't reproduce natural browser background throttling. Real venue hardware, tablet power settings, and user habits still require physical device testing!

Design for interruption rather than perfect timing

You can't force web browsers to run background timers reliably. Instead of fighting browser power management, design your system around these four principles:

  1. Treat background execution as untrusted. Assume timers will drift or pause.

  2. Separate transport from permission. A fast HTTP connection doesn't equal an active operator.

  3. Preserve user intent locally, but let the server decide authority.

  4. Require explicit human action to resume critical workflows after an outage.