DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

Token Expiry in Live Stock Watchlists — Deterministic UX and Reconnect Tests

Situation User-visible behavior Test control
Token is valid and the stream is healthy Prices update in place; keep the last update timestamp visible A fake clock advances normal heartbeats
Token expires while the socket is open Freeze updates, show a compact re-auth prompt, and preserve the list Inject an expiry event at a known logical time
Re-auth succeeds after a disconnect Reconnect, backfill the missed sequence, then resume live updates Replay a fixed event log with a deterministic cursor
Re-auth is cancelled or denied Keep cached quotes marked stale and offer retry; never imply a fresh price Assert state transitions, not wall-clock delays

Short answer: model expiry as an explicit state transition, drive it with a virtual clock, and assert the watchlist state plus backfill cursor instead of sleeping for real time. That makes the trading screen honest when a token ages out and keeps tests repeatable.

A watchlist has a sharper edge than a chat feed. A stale quote can change a decision, so “the connection looks open” is not enough. The client needs to distinguish fresh data, paused data, and data that was recovered after a gap.

Watch the boundary.

What should a stock watchlist show when a realtime token expires?

Treat the stream as a small state machine: live, expiring, reauthenticating, backfilling, stale, and failed. The state belongs to the watchlist session, not to an individual quote. A banner can say “Updates paused. Sign in again to continue.” Keep the last known price, but attach its age and a stale label.

Do not clear symbols during expiry. Traders use the list itself to decide what to do next, and an empty panel looks like a data loss event. Disable actions that require a fresh quote, such as a market-order shortcut, until the session returns to live. Three words help: “Quotes may be stale.”

I keep the transition event-shaped so the UI and telemetry share one vocabulary:

type WatchlistState =
  | { kind: 'live'; lastSequence: number }
  | { kind: 'expiring'; expiresAt: number; lastSequence: number }
  | { kind: 'reauthenticating'; lastSequence: number }
  | { kind: 'backfilling'; fromSequence: number }
  | { kind: 'stale'; reason: 'token' | 'network'; lastSequence: number };

function onTokenExpiry(state: WatchlistState): WatchlistState {
  if (state.kind === 'live' || state.kind === 'expiring') {
    return { kind: 'expiring', expiresAt: Date.now(), lastSequence: state.lastSequence };
  }
  return state;
}
Enter fullscreen mode Exit fullscreen mode

The exact countdown is a product choice. The invariant is not: no update should be presented as current after the authorization boundary.

How can expiry, reconnect, and backfill be tested without flaky timing?

Replace elapsed time with a test clock and replace an actual broker with a deterministic event source. The test advances time to just before expiry, confirms normal rendering, then advances one tick and emits TOKEN_EXPIRED. No setTimeout needs to finish. No CI machine speed is involved. In a fuller scenario, seed the stream with quote events for AAPL, MSFT, and a thinly traded symbol, record sequence 41 as the last rendered item, and then close the transport at the expiry boundary. The client should retain those three rows, mark each one stale, and remember 42 as the first sequence it needs. When authentication returns, replay 42 through 47, deliberately duplicate 44, and deliver 46 before 45. The reducer should discard the duplicate, hold the out-of-order event until the gap is filled, and expose one monotonic view. This test catches a common UX lie: a green connection icon beside prices that silently skipped a burst of updates.

class TestClock {
  private nowMs = 1_700_000_000_000;
  now() { return this.nowMs; }
  advance(ms: number) { this.nowMs += ms; }
}

type Event =
  | { type: 'quote'; sequence: number; symbol: string; price: number }
  | { type: 'TOKEN_EXPIRED' }
  | { type: 'AUTHENTICATED' };

function reduce(state: WatchlistState, event: Event): WatchlistState {
  if (event.type === 'TOKEN_EXPIRED') return onTokenExpiry(state);
  if (event.type === 'AUTHENTICATED' && state.kind === 'expiring') {
    return { kind: 'backfilling', fromSequence: state.lastSequence + 1 };
  }
  if (event.type === 'quote' && state.kind === 'live') {
    return { kind: 'live', lastSequence: event.sequence };
  }
  return state;
}

const clock = new TestClock();
let state: WatchlistState = { kind: 'live', lastSequence: 41 };
clock.advance(14 * 60 * 1000);
state = reduce(state, { type: 'TOKEN_EXPIRED' });
if (state.kind !== 'expiring') throw new Error('expiry was not rendered');
state = reduce(state, { type: 'AUTHENTICATED' });
if (state.kind !== 'backfilling' || state.fromSequence !== 42) {
  throw new Error('backfill cursor was not preserved');
}
Enter fullscreen mode Exit fullscreen mode

The useful assertion is the cursor (42 here), not “reconnected within 500 ms.” Backfill tests should replay a known gap, including duplicate and out-of-order events, and verify that the reducer ends with one quote per symbol and a monotonic sequence. Your mileage may vary on the exact event schema, but the principle travels across transports.

At the boundary, test four paths: expiry before a user action, expiry during an in-flight action, successful re-auth, and a denied re-auth. Add a fifth for a tab returning from sleep. Browsers can suspend timers, so a test that relies on timer callbacks alone can miss the very transition you need to protect.

Which observability signals prove the UX is telling the truth?

Log a session identifier, watchlist identifier, last rendered sequence, expiry reason, and backfill range as structured fields. Metrics should separate expiry_to_prompt_ms, reauth_success_total, backfill_events_total, and stale_seconds. A single reconnect counter hides too much.

Trace the sequence as a short chain: token accepted, stream opened, expiry observed, credentials refreshed, gap requested, gap applied, live resumed. In a dashboard, put the stale-age percentile next to reconnect rate. If reconnects rise while stale age stays low, the UX may be fine; if stale age climbs, users are looking at old prices even though sockets appear healthy.

The WebRTC recommendation describes connection and state concepts that are useful when a realtime transport exposes lifecycle transitions; your application protocol still needs an explicit authorization-expiry event and sequence semantics. The reference below documents the transport contract; document your own event contract beside the client reducer.

Limits and trade-offs

A virtual clock proves deterministic client behavior, not that an identity provider will refresh credentials correctly in production. Keep one small end-to-end test for the real refresh path, and make its assertions event-based with generous bounds.

A backfill buffer costs memory and can expose sensitive quote history, so cap its size and scope it to the authenticated account. For very high-frequency symbols, a snapshot-plus-delta protocol may be simpler than replaying every tick.

The catch is product policy: pausing all trading controls is safer, but it can frustrate users during a brief refresh. If your risk team permits a narrower policy, gate only actions that require current quotes and label everything else with its age. Stick with a full pause when regulatory or risk requirements demand an unambiguous fresh-data boundary.

References

Top comments (0)