โ† Back to writing

Presence Isn't One Concept: Designing Scoped Presence for Real-Time Chat

Jul 2026 ยท 8 min read

Building real-time chat between matched flatmates on an early-stage property platform, the part that looked trivial and wasn't was presence. Message delivery, connection handling, room lifecycle โ€” Ably's chat SDK genuinely solves all of that well through its official React hooks. Presence needed its own design pass, because "is this person online" is not one question with one correct scope. It's at least two different questions โ€” is this person active in this specific conversation, and is this person logged into the app at all โ€” and an SDK's presence primitive only ever answers one of them by default.

What happens when you treat two scopes as one

Ably's presence.enter()/presence.get()tracks whether a client is currently present in a specific channel โ€” a room-scoped concept, by design. Using that directly to answer "is this person online" in a chat header works right up until it doesn't: a browser tab can hold a room's presence open long after someone has stopped meaningfully using the app, so the header keeps showing "Online" for a person who is, for any practical purpose, gone. Reproducible, not an edge case: leave a chat tab open, close the actual app session, and the other person still sees a green dot that no longer means anything.

That's a category error, not a one-off bug: room-scoped presence and app-scoped presence are different concepts that happen to share a name in casual conversation, and a system that conflates them will demo perfectly โ€” two tabs open, both show online, looks great โ€” while being wrong in precisely the case that matters, someone who has genuinely left.

The design: a presence concept that doesn't belong to any one room

The fix is a second, global presence channel โ€” entirely separate from any individual chat room's presence set โ€” joined once at login and left once at logout:

// on login
const appPresence = ably.channels.get("app-presence");
await appPresence.presence.enter({ userId, status: "online" });

// on logout
await appPresence.presence.leave({ userId, status: "offline" });

Chat components check thischannel to decide whether to show someone as online, not the room's own presence set. The room-scoped presence didn't become useless โ€” it's still the right primitive for "is this person currently active in this specific conversation" โ€” it just stopped being asked a question it was never actually answering.

The deeper lesson wasn't "add a second presence channel." It was that presence isn't one concept with one correct scope โ€” it's a family of related concepts (room-level, app-level, arguably device-level if you wanted to go further), and an SDK hands you whichever scope it was designed around, which is not guaranteed to match the scope your product actually needs. Using the convenient primitive for the first presence feature you build, without checking whether its scope matches your product's actual question, is exactly how a chat feature ends up demoing perfectly โ€” two tabs open, both show online, looks great โ€” while being wrong in precisely the case that matters: someone who has genuinely left.

The same lesson, smaller: typing indicators

Typing indicators went through a similar arc, at smaller scale. The first version was hand-rolled โ€” custom logic firing on keystroke events โ€” and it didn't reliably work: indicators that stuck around too long, or didn't clear, or fired inconsistently across clients. The fix wasn't more custom logic, it was recognizing this was a solved problem one layer down and switching to Ably's own useTyping hook, which already handles the debounce and timeout semantics โ€” auto-clearing an indicator after a few seconds of inactivity โ€” correctly:

useEffect(() => {
  if (message.trim()) {
    typingHook.startTyping();
    const timeout = setTimeout(() => typingHook.stopTyping(), 3000);
    return () => { clearTimeout(timeout); typingHook.stopTyping(); };
  }
  typingHook.stopTyping();
}, [message, typingHook]);

This is the mirror image of the presence lesson, not a contradiction of it: default to the platform's built-in primitive for exactly the parts of the problem it was actually built to solve โ€” timeout semantics, event delivery, debounce edge cases most teams get subtly wrong the first time. Only step outside the SDK's primitive when its scopedoesn't match what the product needs, the way room-scoped presence didn't match "is this person logged in." Reach for a custom layer because of a scope mismatch, not because the built-in version feels unfamiliar.

One status line, one priority list

The last piece was turning "what do we show in the chat header" from an ad hoc set of conditionals into a simple, explicit priority: typing beats online, online beats a last-seen timestamp, and a last-seen timestamp beats a bare "offline." Once that priority was explicit, the header display stopped being a tangle of edge-case handling and became one small function anyone could read and verify against the four cases it actually needed to cover.

What actually made this an "enterprise-feeling" chat feature

None of what made this chat feature feel solid instead of demo-quality was about picking a fancier real-time transport โ€” Ably's hooks handled message delivery, connection state, and room lifecycle correctly from the start. What took the extra rounds was noticing that "online" meant two different things depending on who was asking, and refusing to let the SDK's convenient default silently pick which one the product showed to users.