Realtime / FrontendConcept

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.

Get a walkthrough
01 / 03

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.

02 / 03

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.

03 / 03

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.
Code

The interesting bits.

typescript·use-presence.ts
'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
}
tsx·PresenceStack.tsx
'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>
  )
}
Tech stack

Tools, picked deliberately.

Next.js 16React 19Supabase RealtimePostgres RLSTypeScriptTailwind
Run it yourself

From clone to working.

01

Create a Supabase project

Realtime is on by default. No separate channel server, no Redis, no extra cost.

02

Lock down the channel

Add an RLS policy on the page table so only authorised users can read. Realtime channel auth piggybacks on this.

03

Drop in the hook

Copy use-presence.ts into your codebase. Pass the page id and your user shape.

04

Render the stack

Use PresenceStack or roll your own UI. The hook returns a list of peers, sorted is up to you.

05

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.

Email me