Supabase realtime presence tracking
Build presence indicators and live cursors with Supabase Realtime Presence — channel lifecycle, conflict resolution, and scaling limits

Supabase Realtime Presence Tracking: Building Live Cursors and Chat Indicators
Presence is the feature that turns a database-backed app into something that feels alive. When you open a Google Doc and see five colored cursors gliding across the page, that is presence. When Slack shows a green dot next to a teammate's avatar, that is presence. Supabase Realtime ships a Presence primitive that handles the hard parts (membership tracking, conflict resolution, automatic cleanup on disconnect) so you do not have to write a custom CRDT or run your own Redis pub/sub.
This article walks through what Presence actually does on the wire, how the channel lifecycle interacts with React component lifecycles, the conflict resolution model you need to understand before you ship, and the scaling ceilings worth knowing about before your first thousand concurrent users arrive.
What Presence Is, and What It Is Not
Supabase Realtime exposes three primitives on a single WebSocket channel: Broadcast (fan-out messages), Postgres Changes (CDC-style row updates), and Presence (shared state per client). They are independent features that travel over the same connection, so you pay for one channel subscription and get all three.
Presence is closest in spirit to Phoenix Presence from the Elixir ecosystem, which makes sense because Supabase Realtime is built on top of Phoenix Channels. Each client tracks a piece of state under a presence_key. The server keeps a CRDT-style map of {key: [metadata, ...]} and rebroadcasts the merged state to every subscriber. When a client disconnects, the server removes its entries automatically after a short grace period.
What Presence is not: a persistent store. The state lives only as long as at least one subscriber is connected. If everyone leaves the channel, the state evaporates. That is the right default for cursors, typing indicators, and "who is online" lists, but it means you should not use Presence to track anything that needs to survive a page refresh by a solo user. Persist that in Postgres and use Realtime Postgres Changes instead.
Broadcast is the right choice when you want one-shot messages with no shared state semantics (a chat message, a button click, a cursor click event). Presence is the right choice when every client needs to know the current state of every other client. The decision boils down to: do you care about the latest event, or do you care about the latest state?
Channel Lifecycle in Practice
A Realtime channel goes through four states: closed, joining, joined, and leaving. You subscribe once per logical conversation and tear down when the user navigates away. The classic React mistake is creating a new channel on every render, which produces a flood of joining events and exhausts your concurrent connection budget within minutes.
Here is the minimum viable presence hook for a React app:
import { useEffect, useState } from "react";
import { createClient, RealtimeChannel } from "@supabase/supabase-js";
const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
);
type PresenceState = {
user_id: string;
username: string;
cursor: { x: number; y: number } | null;
last_seen: number;
};
export function usePresence(roomId: string, me: PresenceState) {
const [peers, setPeers] = useState<Record<string, PresenceState[]>>({});
useEffect(() => {
const channel: RealtimeChannel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: me.user_id } },
});
channel
.on("presence", { event: "sync" }, () => {
setPeers(channel.presenceState<PresenceState>());
})
.on("presence", { event: "join" }, ({ key, newPresences }) => {
console.log("joined", key, newPresences);
})
.on("presence", { event: "leave" }, ({ key, leftPresences }) => {
console.log("left", key, leftPresences);
})
.subscribe(async (status) => {
if (status === "SUBSCRIBED") {
await channel.track(me);
}
});
return () => {
channel.untrack();
supabase.removeChannel(channel);
};
}, [roomId, me.user_id]);
return peers;
}
Three details in this hook decide whether it survives production. First, the presence.key config field tells the server how to group entries. If two tabs from the same user join with the same key, the server keeps both metadata entries under that key. If you omit the key, Supabase assigns a random one per connection and you lose the ability to dedupe across tabs. Second, track is called inside the SUBSCRIBED branch of the subscription callback, not at the top level. Calling track before the channel is joined silently drops the state. Third, the cleanup function calls both untrack and removeChannel. Skipping untrack works (the server reaps stale entries within roughly 30 seconds) but leaves a visible ghost for other users.
The dependency array intentionally includes me.user_id and not the full me object. If you put the whole object in the array, every cursor movement that updates me.cursor triggers a channel teardown and rejoin, which is catastrophic for performance. Update presence state through a separate channel.track(updated) call instead, debounced to roughly 16ms for smooth cursor animation.
Conflict Resolution: Last Write Wins Per Key
Supabase Realtime Presence uses a simple conflict resolution rule: for each (key, connection) pair, the most recent track() call wins. The CRDT semantics show up when you have multiple connections under the same key. The server keeps an array of metadata entries per key, one per connection, and never merges them.
This matters when you build features like "currently editing this paragraph". If Alice has the doc open in two tabs, both tabs publish under key: "alice", and the presence state looks like { "alice": [{tab: 1, paragraph: 3}, {tab: 2, paragraph: 7}] }. Your UI needs to decide whether to show both, show the most recent, or merge them. The server will not make that call for you.
The mental model that works: treat each key as a user identity and each entry in the array as a session. For cursors, you typically render one cursor per session because two tabs really do have two cursors. For "is online", you collapse all sessions to a single boolean. For typing indicators in chat, you union the typing state across all sessions of a key (the user is typing if any tab is typing).
Compared to operational transformation systems like ShareJS or Yjs, this model is dramatically simpler but also less powerful. You cannot use Presence to merge concurrent edits to the same field. If two users grab the same cursor position, you get both positions, not a transformed one. For collaborative text editing, pair Presence with a real CRDT library and use Presence only for cursor + selection display. Yjs has a y-presence adapter that does exactly this, and Supabase has a worked example at https://github.com/supabase/supabase/tree/master/examples/slack-clone that shows the pattern.
Live Cursors: The 60fps Question
Cursor sharing is the canonical Presence demo, and it exposes the throughput limits faster than anything else. A naive implementation calls track on every mousemove event, which fires roughly 60 times per second on a desktop browser. With ten participants in a room, that is 600 messages per second per client, 6000 across the room. You will hit Realtime's per-connection message rate limit (default 100 messages per second on the free tier) within seconds.
The fix is to throttle outbound presence updates and rely on the server to deliver them at a sensible cadence. A 50ms throttle gets you 20 updates per second, which feels smooth to a human eye but reduces traffic by 3x compared to raw mousemove. If you can tolerate 100ms (10 fps), traffic drops by another 2x and most users cannot tell the difference. Linear's cursor implementation famously uses adaptive throttling that backs off when more than 5 users are in the room.
import throttle from "lodash.throttle";
const updateCursor = throttle((x: number, y: number) => {
channel.track({ ...me, cursor: { x, y }, last_seen: Date.now() });
}, 50);
document.addEventListener("mousemove", (e) => {
updateCursor(e.clientX, e.clientY);
});
On the receive side, animate between the previous and new position with requestAnimationFrame and a 100-150ms interpolation window. This turns 20fps server data into 60fps visual motion, hiding the throttle entirely. The cost is a small lag (the displayed cursor trails the actual remote cursor by the interpolation window), which is invisible at typical interaction speeds.
Two other patterns worth knowing. Use relative coordinates (percentage of viewport width and height) rather than absolute pixels, otherwise users on different screen sizes see cursors in the wrong place. Stop publishing cursor updates when the tab loses focus (via the visibilitychange event), which prevents idle tabs from burning your message budget while sitting in the background.
Scaling Ceilings: What Breaks First
Supabase Realtime's published limits give you a clear picture of when you outgrow the free and Pro tiers. The numbers below come from the official limits page at https://supabase.com/docs/guides/realtime/quotas and are the ones you should plan against.
The free tier allows 200 concurrent connections and 2 million messages per month. A presence-heavy app burns through messages faster than you would think. If you have a chat room with 10 users sending 1 cursor update per second each for an hour, that is 36,000 messages from a single room in one hour. You will exhaust 2 million in roughly 55 hours of cumulative activity, or about a week for a small app.
The Pro tier raises concurrent connections to 500 and messages to 5 million, which buys you maybe 4x headroom. The hard ceiling that matters for most teams is messages per channel per second. A single channel can deliver about 100 messages per second to all subscribers. If your room has 50 users and each publishes a cursor at 20 updates per second, that is 1000 outbound messages per second and 50,000 inbound message-deliveries per second. You hit the channel limit immediately. The fix is sharding: split a single logical room into N channels by user-id hash, so each channel carries only users / N of the cursor traffic.
Beyond about 500 concurrent users in a single presence channel, you should be thinking about a different architecture entirely. Either use Broadcast with manual state reconciliation, batch presence updates server-side via a Postgres trigger plus Realtime fan-out, or move to self-hosted Realtime where you control the resource budget. Discord's voice channels famously cap at 99 users for exactly this reason. Plan your UX around the constraint instead of fighting it.
When Presence Is the Wrong Tool
Presence shines for ephemeral state shared across a known set of subscribers. It is the wrong tool when:
- State must survive disconnect. Use Postgres + Realtime Postgres Changes. A presence entry vanishes when the last subscriber leaves. A row stays.
- You need authoritative ordering. Presence uses last-write-wins per key. If two clients race to claim a resource (a poker game seat, a meeting room booking), you need a Postgres transaction with row-level locking, not Presence.
- Update rate exceeds 20 per second per client sustained. WebRTC data channels handle high-rate per-client signaling more efficiently because they skip the server-side fan-out. Use Presence to coordinate which peers to connect and WebRTC for the actual cursor stream.
- You need durable history. Presence has no log. If you need a record of "Alice typed at 14:32", log it to a Postgres table.
The decision tree is straightforward. If the state should outlive the session, persist it. If the state needs strict ordering, use the database. Otherwise, Presence is the cheapest tool in the box.
Putting It Together
A practical chat app combines all three primitives. Use Postgres rows for the message history (durable). Use Broadcast for typing indicator pulses (ephemeral, no state to merge). Use Presence for the "who is online" list and live cursors over the chat composer (shared state). Three primitives, one channel subscription, one WebSocket.
The implementation discipline that pays off in production: always set presence.key, always call track inside the SUBSCRIBED branch, always throttle outbound updates, always interpolate on the receive side, and always tear down channels in your cleanup. Those five rules cover roughly 80% of the bugs people hit when they ship Presence-based features.
Realtime Presence is one of the most polished primitives Supabase ships. It scales further than you would expect for the simplicity of the API, and the limits you hit first (channel message rate, monthly message budget) are well-documented and easy to plan around. Build your cursor feature, ship it behind a feature flag, watch the metrics, and shard when the dashboard tells you to.
References: