DEV Community

HoratioFox1281
HoratioFox1281

Posted on

Realtime Stale User Cleanup Explained (5 Data Contracts for an Online Classroom)

Short answer: choose a realtime surface that makes stale-user cleanup, reconnects, and backfill explicit, then enforce that contract on both sides of the online classroom connection.

Picture a live lesson with 80 browsers in a room. Before the contract, the server sees a socket disappear and guesses whether the learner left, lost Wi-Fi, or is about to reconnect. The roster flickers. A poll vote can look duplicated. After the contract, every participant has a stable identifier, an expiry rule, and a recovery cursor. A disconnect is a state transition, not a mystery.

What should a stale-user data contract guarantee?

Start with five fields and keep their meaning boring:

  1. participant_id is stable for the learner in this room. It is not a random connection id.
  2. session_id identifies one connection attempt. A reconnect gets a new session id.
  3. last_seen_at is the server's timestamp for the latest accepted heartbeat or event.
  4. expires_at is computed by the server. Clients never delete another participant because their local clock is wrong.
  5. version (or an event cursor) lets a client reconcile a snapshot with events received later.

The server owns expiry and authorization. The client owns rendering and asking for recovery. Write that division down before picking an endpoint. It prevents a common failure: a browser marks a user offline, then a delayed presence event marks the same user online again.

One sentence matters here.

An expired user is eligible for cleanup; it is not proof that the person abandoned the class.

How do realtime stale user cleanup and reconnect backfill work together?

Use a small state machine: active -> suspect -> expired -> removed. A heartbeat or valid event moves a participant to active. A missed deadline moves it to suspect; only the server's expiry check moves it to expired. Cleanup emits a tombstone carrying the same participant_id and a monotonically increasing version.

On reconnect, the client sends its last applied version. The server returns a room snapshot plus events after that version, or a fresh snapshot when the cursor is too old. That is the backfill contract. It handles duplicate delivery because the client applies an event only when its version is newer than the stored value. It handles partial failures because the client can retry the read without inventing a second participant.

For an online poll, the event sequence might be: poll.opened (41), vote.recorded (42), participant.expired (43). A reconnect that last saw version 41 asks for events after 41 and receives 42 and 43. The UI can show the vote and remove the stale row in order. No guesswork.

Test this path with realistic latency, duplicate delivery, and authorization cases. I would inject a 2-second delay, deliver vote.recorded twice, and attempt a backfill with a token for a different room. The expected result is deterministic: one vote, one roster transition, and a denied cross-room read. I'm not sure your client clock is trustworthy; the contract should not depend on it.

A minimal TypeScript cleanup worker

The worker below treats cleanup as an idempotent command. It sends an explicit method, uses a bearer token from the environment, honors Retry-After for rate limits, and surfaces non-success responses. The endpoint is the realtime user disconnect route, so the server can apply its own expiry and authorization rules.

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;

if (!apiKey || !baseUrl) throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");

async function disconnectStaleUser(
  room: string,
  participantId: string,
  expiryVersion: number,
): Promise<unknown> {
  const idempotencyKey = `stale:${room}:${participantId}:${expiryVersion}`;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/realtime/user/disconnect`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify({ room, participant_id: participantId }),
      },
    );

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("Retry-After") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`cleanup failed (${response.status}): ${detail}`);
    }

    return response.json();
  }

  throw new Error("cleanup rate limit persisted after retries");
}

await disconnectStaleUser("lesson-204", "p-17", 43);
Enter fullscreen mode Exit fullscreen mode

The room and participant_id names are the application contract in this example; validate them against the endpoint schema exposed by your chosen provider before shipping. The important behavior is the stable idempotency key: a worker retry cannot turn one expiry decision into two disconnect commands.

Infrai is one option when a plain REST API fits your stack. A request needs no SDK or language-specific client, and the same bearer-key pattern can sit beside the rest of your backend calls. That is useful for a small classroom service where the cleanup worker already speaks HTTP. It is not a reason to skip contract tests.

Which realtime option fits this classroom workflow?

There is no universal winner. Compare the recovery contract, not a feature checklist.

Option Where it fits Trade-off to verify
Ably Hosted realtime messaging when you want managed connection and history primitives Confirm its retention and cursor semantics match your backfill window
Pusher Channels A straightforward hosted channel model for presence and events Confirm how you represent server-owned expiry and duplicate event handling
Supabase Realtime Teams already centered on a Postgres-backed application Confirm authorization and snapshot-plus-events recovery for your room model
Infrai realtime API HTTP-first services that prefer one key and no SDK installation You must own the classroom data contract and client reconciliation logic

The catch is important: an HTTP surface is not suitable when your team expects a provider-specific client to hide reconnection policy, presence semantics, and local cache reconciliation. In that case, stick with a managed client ecosystem such as Ably or Pusher Channels and make the same five fields part of your application payload. Choose Supabase Realtime when database authorization is the deciding constraint and its recovery behavior meets your tests.

Before you ship: prove the failure states

Run a table-driven test over these transitions: heartbeat arrives before expiry, heartbeat arrives after expiry, reconnect with an old cursor, duplicate tombstone, and token revoked during backfill. Assert the resulting participant_id, version, and roster state. Also assert that a participant from another room cannot read or mutate this room.

Observe the workflow with three signals: a counter for expiries, a histogram for backfill lag, and an alert on repeated authorization failures. Log the session_id, participant_id, room, and request id; avoid logging the bearer token. A crisp before/after dashboard makes it obvious whether a spike is real churn or a delayed event stream.

Keep recovery behavior explicit in the runbook. If a snapshot is returned, replace local roster state and then apply newer events. If the cursor is rejected, request a fresh snapshot. If authorization fails, stop retrying and ask the user to rejoin. Those rules are small enough to review in a pull request, which is exactly where they belong.

References

Top comments (0)