Real-time presence in 120 lines.
A live who-is-here indicator using Supabase channels. Avatar fan-out, idle detection, clean disconnects, no extra infrastructure.
Presence is one of those features that looks trivial and turns into a state-machine swamp the moment you ship it. Tabs go to sleep. Wi-fi dies. People close the laptop without signing out. This lab is the smallest honest implementation I have arrived at after building it three times: one Supabase Realtime channel, a tiny React hook, and a debouncer for the awkward edge cases.
What it does
Each visitor on a page joins a Supabase Realtime channel keyed to that page. They broadcast a small payload: user id, name, avatar, and a heartbeat timestamp. Everyone else on the channel renders a stack of avatars in the corner with a count for the overflow.
Idle detection tracks visibility and last-input time. After two minutes of nothing, the user is marked as idle locally and broadcast as such. After ten minutes, they leave the channel cleanly. If the tab is closed, Supabase fires the leave event within a few seconds and everyone else updates.
It is the kind of thing that takes ten minutes to look right and an afternoon to feel right.
The problem it solves
Collaboration tools live or die on this signal. If you cannot tell whether your colleague is on the same Notion page as you, you end up pinging them in Slack to ask. Multiply that by a team of fifteen and you have a permanent low-grade tax on attention.
Most teams reach for socket.io or Liveblocks, which are great but introduce a separate service. If you already have Supabase, this is free, scales to thousands of concurrent users on the included tier, and stays inside your existing auth model.
Architecture
One channel per page. One hook on the client. The server does nothing. Authorisation is enforced by Supabase Row Level Security on the channel name plus your existing JWT.
- →Channel name: presence:{page_id}. Anyone with read access on the page can join.
- →Track payload: id, name, avatar_url, status, last_active.
- →Heartbeat: every 30 seconds while visible.
- →Idle threshold: 2 minutes. Leave threshold: 10 minutes.
- →Cleanup on visibilitychange, beforeunload and pagehide.
The interesting bits.
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
type Presence = {
id: string
name: string
avatar_url: string
status: 'active' | 'idle'
last_active: number
}
export function usePresence(pageId: string, me: Omit<Presence, 'status' | 'last_active'>) {
const [peers, setPeers] = useState<Presence[]>([])
useEffect(() => {
const supabase = createClient()
const channel = supabase.channel(`presence:${pageId}`, {
config: { presence: { key: me.id } },
})
let lastActive = Date.now()
const onActivity = () => { lastActive = Date.now() }
const tick = () => {
const idle = Date.now() - lastActive > 120_000
channel.track({
...me,
status: idle ? 'idle' : 'active',
last_active: lastActive,
})
}
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<Presence>()
setPeers(Object.values(state).flat().filter((p) => p.id !== me.id))
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') tick()
})
const heartbeat = setInterval(tick, 30_000)
window.addEventListener('mousemove', onActivity)
window.addEventListener('keydown', onActivity)
document.addEventListener('visibilitychange', tick)
window.addEventListener('beforeunload', () => channel.untrack())
return () => {
clearInterval(heartbeat)
window.removeEventListener('mousemove', onActivity)
window.removeEventListener('keydown', onActivity)
document.removeEventListener('visibilitychange', tick)
channel.unsubscribe()
}
}, [pageId, me.id])
return peers
}'use client'
import { usePresence } from './use-presence'
export function PresenceStack({ pageId, me }: Props) {
const peers = usePresence(pageId, me)
const visible = peers.slice(0, 4)
const overflow = peers.length - visible.length
return (
<div className="flex -space-x-2">
{visible.map((p) => (
<img
key={p.id}
src={p.avatar_url}
alt={p.name}
title={`${p.name} ${p.status === 'idle' ? '(idle)' : ''}`}
className={`w-8 h-8 rounded-full ring-2 ring-background ${
p.status === 'idle' ? 'opacity-40 grayscale' : ''
}`}
/>
))}
{overflow > 0 && (
<div className="w-8 h-8 rounded-full bg-secondary text-xs flex items-center justify-center ring-2 ring-background">
+{overflow}
</div>
)}
</div>
)
}Tools, picked deliberately.
From clone to working.
Create a Supabase project
Realtime is on by default. No separate channel server, no Redis, no extra cost.
Lock down the channel
Add an RLS policy on the page table so only authorised users can read. Realtime channel auth piggybacks on this.
Drop in the hook
Copy use-presence.ts into your codebase. Pass the page id and your user shape.
Render the stack
Use PresenceStack or roll your own UI. The hook returns a list of peers, sorted is up to you.
Test the awkward cases
Close a tab without logging out. Put the laptop to sleep. Switch off Wi-fi. Watch the avatars go grey, then disappear.
Want early access?
This one is in the workshop. The pattern is documented above; the open source release is planned. Email me for a walkthrough or early access.