TL;DR: When realtime presence shows ghost users after a network drop, treat presence as a short lease owned by one newsroom editor session, not as a fact that a disconnect event must erase. A Node.js service should expire the lease unless the same authenticated session renews it. Disconnect events can speed up cleanup, but they can't be the source of truth.

Keep in-app mention notifications separate from cursor presence. A notification is durable application data. A green avatar is a temporary claim. Mixing them makes a dropped link look like user activity and makes debugging much harder.

Why does realtime presence show ghost users after a network drop?

A server usually learns about a graceful close quickly. A network drop is different. The client cannot send a final message when Wi-Fi vanishes, a laptop sleeps, or a process is killed. WebRTC exposes ICE and peer connection state transitions, including disconnected, failed, and closed, but those states describe the local connection view. They do not create a globally reliable leave event.

That gives the first debugging expectation: a ghost is normal during the bounded expiry window and a bug after it. If the UI promises instant removal after every link loss, the promise is wrong.

That's the contract.

Start with four timestamps in one trace: the last accepted renewal, the transport state change, the server expiry deadline, and the client render time. Use one clock domain for server decisions. Browser wall clocks are evidence, never authority. A stale avatar then becomes a specific question: did renewal stop, did expiry run, or did the view retain an expired record?

The constraint that changed the design

The tempting design is a map keyed by userId, followed by delete(userId) on socket close. It is small. It is also ambiguous as soon as a reporter opens the same story in two tabs. Closing tab A can erase tab B, while a missing close can preserve both forever. Scope the credential more narrowly. Each authenticated editor session gets a random presence token restricted to one document, one user, one session, and a short expiry. The browser may renew that lease, but it may not choose another user ID, another document ID, or an arbitrary expiry. Client trust ends there. Now replay the awkward sequence: tab A connects, tab B connects, A loses its network, B renews, and A's delayed close finally arrives. A map keyed only by user erases the healthy tab. A session-scoped lease does not, because the delayed close owns only A's key. This is the kind of failure I want a fake-clock test to settle before deployment, not a dashboard to explain afterward.

This is a revenue-per-hour decision. Debugging distributed cleanup callbacks doesn't improve the editor. A bounded lease has a testable invariant, which leaves more of a one-person week for shipping the next feature. Ship weekly; outsource undifferentiated state machinery to a shared store when scale demands it.

Use a fresh sessionId after reconnect. A delayed packet from the old transport must not extend the new session, and an old close must not delete it. The server can publish the current presence set after every accepted change; clients replace their view for that document instead of merging forever.

The core does not need transport-specific code. It needs authenticated claims, monotonic server time, and idempotent expiry. This in-memory sketch shows the ownership boundary; a production deployment would place the same compare-and-expire rule in shared storage.

type Claims = { documentId: string; userId: string; sessionId: string; }; type Lease = Claims & { expiresAtMs: number }; const leases = new Map<string, Lease>(); const leaseKey = (c: Claims) => `${c.documentId}:${c.userId}:${c.sessionId}`; export function renewPresence(claims: Claims, nowMs: number): Lease { // Claims come from a verified server token, never request JSON. const lease = { ...claims, expiresAtMs: nowMs + 30_000 }; leases.set(leaseKey(claims), lease); return lease; } export function expirePresence(nowMs: number): Lease[] { const removed: Lease[] = []; for (const [key, lease] of leases) { if (lease.expiresAtMs > nowMs) continue; if (leases.get(key)?.expiresAtMs !== lease.expiresAtMs) continue; leases.delete(key); removed.push(lease); } return removed; } export function leavePresence(claims: Claims): void { leases.delete(leaseKey(claims)); } 

Thirty seconds here is an example policy, not a universal target. A concrete starting test can use a 30-second lease renewed every 10 seconds, then inject a 31-second gap. Pick the real expiry from the product's tolerance for stale cursors and measured scheduling delay. Test the boundary with a fake clock: one millisecond before expiry, exactly at expiry, a late renewal, duplicate renewal, an old-session leave, and two tabs for one user.

Do not let presence delivery acknowledge or suppress mention notifications. Persist notification state under its own authorization rules, then push a hint to connected clients. On reconnect, the client fetches durable notification state and separately replaces its presence snapshot. No polling is needed during a healthy connection, and link loss does not turn an ephemeral cursor into a durable record.

Debugging the ghost without guessing

First, stop the renewal path without sending a close. Browser offline mode is useful, but killing the process and suspending the machine exercise different failure shapes. The avatar should remain only until its lease expires. Then reconnect with a new session and inject the old session's leave; the new avatar must survive.

Log identifiers and transitions, not raw tokens. A useful record contains a hashed session identifier, document identifier, accepted server time, previous expiry, new expiry, and reason such as renew, leave, or expire. Count active leases, expiry lag, rejected scope mismatches, and reconnects. Expiry lag matters: a correct deadline still produces ghosts when the sweeper is starved.

One nasty case deserves its own test. A renewal can race the expiry worker. The worker must delete only the version it inspected, as the comparison in the example does. Otherwise it can erase a lease that was validly extended between scan and delete.

Keep the visible rule simple: show presence only when the latest server snapshot contains an unexpired session. Never extend visibility because cursor coordinates arrived late. Cursor movement and lease renewal may share a transport, but they do not share authority.

What I would change at scale

Move leases to a shared store that supports atomic conditional updates and expiry, because process-local maps split the room when Node.js runs on multiple instances. Publish document-scoped changes, rebuild from authoritative lease state after subscriber reconnect, and measure the delay from deadline to removal. The semantic rule stays the same.

I would also separate three budgets: renewal traffic, fan-out traffic, and durable notification writes. Presence can be coalesced. Cursor coordinates can be dropped. Mention notifications cannot. This split prevents a busy story with 40 editors from turning every pointer movement into durable work.

The trade-off is deliberate staleness. Short leases remove ghosts faster but increase renewal load and sensitivity to brief stalls. Long leases are calmer but leave departed editors visible longer. Disconnect events remain worthwhile as a fast path; lease expiry remains the correctness path. For a solo SaaS, that division is easy to explain, cheap to test, and unlikely to consume the week.

Further reading