DEV Community

Cover image for Supabase realtime keeps mobile presence honest without polling your database dead
Dave Kurian
Dave Kurian

Posted on Originally published at otf-kit.dev

Supabase realtime keeps mobile presence honest without polling your database dead

Polling is the default sin of mobile realtime. A timer fires every five seconds, hits your API, returns nothing new, and drains battery while your Postgres connection pool quietly fills with identical queries. Supabase Realtime exists so you can stop doing that: one websocket connection per client, server-pushed changes, and presence tracking that tells you who is actually online instead of who polled last.

This post covers the three Realtime primitives that matter for production mobile apps — broadcast, presence, and Postgres changes — plus the connection-lifecycle patterns that keep them stable on iOS and Android where the OS kills sockets without asking permission.

Why polling breaks mobile apps first

On web, a five-second poll is rude. On mobile, it is expensive in three currencies at once: battery (radio wake-ups), data (metered connections), and server load (one connection per poll instead of one persistent socket). A chat screen with 10,000 daily users polling every five seconds generates over 170 million requests a day. The same screen on a websocket generates roughly 10,000 connections that sit idle until something actually happens.

The math gets worse with backgrounding. iOS suspends your JavaScript timers when the app backgrounds, so your "every five seconds" poll becomes "whenever the OS feels like it," and your UI shows stale state on foreground without telling the user. Android's Doze mode does the same with different timing. Websockets have the same backgrounding problem, but the reconnect path is explicit and observable — you know the socket died, so you can resync. A silently paused poll timer never tells you it stopped.

If you already hardened row-level security for mobile clients, as described in our Supabase RLS mobile safety guide, Realtime is the natural next layer: the same policies that gate your REST queries also gate your Postgres Changes subscriptions, so you do not build a second authorization system.

The three primitives and when to use each

Supabase Realtime offers broadcast, presence, and Postgres changes. They solve different problems and you will usually use two of them together.

Broadcast is low-latency ephemeral messaging between clients: typing indicators, cursor positions, game moves, live reactions. Messages are not persisted. If a client is offline when a broadcast fires, it never sees it. Use broadcast for anything where the current state matters more than the history.

Presence tracks who is online and synchronizes per-user state across clients. Each client joins a presence channel with a key (usually the user id) and a payload (name, avatar, current screen, status). The server keeps the roster and pushes joins and leaves to every subscriber. This is how you build "3 teammates viewing this document" or driver-online indicators without a heartbeat table in Postgres.

Postgres changes streams row-level database events — inserts, updates, deletes — to subscribed clients, filtered by table, schema, and optionally by row-level security policy. This is the workhorse: new chat message inserted, every subscriber on that conversation gets the row pushed. No polling, no refresh button, no stale list.

A typical production screen combines presence ("who is here") with Postgres changes ("what changed"). Broadcast handles the ephemera in between.

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(process.env.EXPO_PUBLIC_SUPABASE_URL!, process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!)

// Postgres changes: new messages in this conversation, pushed not polled
const channel = supabase
  .channel('conversation-42')
  .on(
    'postgres_changes',
    { event: 'INSERT', schema: 'public', table: 'messages', filter: 'conversation_id=eq.42' },
    (payload) => prependMessage(payload.new)
  )
  .subscribe((status) => {
    if (status === 'SUBSCRIBED') markFeedLive()
    if (status === 'TIMED_OUT' || status === 'CHANNEL_ERROR') scheduleReconnect()
  })
Enter fullscreen mode Exit fullscreen mode

Presence done right on mobile

The naive presence implementation joins on mount and leaves on unmount. That works in a demo and lies in production, because mobile apps do not unmount cleanly — they background, get suspended, lose the radio in a tunnel, and return without ever calling your cleanup function.

The pattern that holds: track presence with an explicit heartbeat keyed to app state, and treat the roster as eventually consistent. Join the presence channel on subscribe, update your presence payload on every foreground transition, and configure the channel to track a last_seen timestamp in the payload. On the UI side, render anyone whose last_seen is older than 60 seconds as "away" rather than removing them — this absorbs the tunnel-and-elevator gaps that otherwise make your online list flicker.

import { AppState } from 'react-native'

// Re-announce presence on every foreground; the OS kills sockets silently
const presenceChannel = supabase.channel('doc-room-7', {
  config: { presence: { key: currentUser.id } },
})

presenceChannel
  .on('presence', { event: 'sync' }, () => renderRoster(presenceChannel.presenceState()))
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await presenceChannel.track({ name: currentUser.name, last_seen: Date.now() })
    }
  })

AppState.addEventListener('change', async (state) => {
  if (state === 'active') {
    await presenceChannel.track({ name: currentUser.name, last_seen: Date.now() })
    resyncMissedChanges()
  }
})
Enter fullscreen mode Exit fullscreen mode

Note the resyncMissedChanges call on foreground. Realtime tells you what happens while you are connected; it cannot tell you what happened while you were gone. Every channel that uses Postgres changes needs a foreground fetch that queries "everything since my last known timestamp" and merges it into local state. Pair this with an offline mutation queue like the one in our offline-first Expo guide and your sync story covers both directions: queued writes flush up, missed rows fetch down.

RLS is your realtime authorization layer

A common mistake is treating Realtime subscriptions as trusted because they use the authenticated client. They are not special: Postgres Changes respects your row-level security policies, which means a subscriber only receives rows their policy allows them to see. That is capable, but only if your policies are actually restrictive.

Audit every table you subscribe to with the mobile-client threat model: the anon key is embedded in your binary, so any policy that returns true for authenticated without further restriction broadcasts those rows to every logged-in user who guesses the table name. Scope policies by ownership, membership, or conversation participation. Test subscriptions with two different users and confirm each sees only their rows before you ship.

One more production detail: RLS evaluation on Postgres Changes happens per event, and complex policies with subqueries add latency to every pushed row. Keep realtime-gated policies simple — direct column comparisons, indexed membership lookups — and push the heavy authorization logic into your write path (edge functions, triggers) rather than the read policy. Your p99 push latency will thank you.

Connection lifecycle the OS will not respect

Mobile operating systems kill idle sockets aggressively. iOS suspends background sockets within seconds; Android Doze batches network access and some OEM skins kill background connections outright. Your Realtime layer must assume the socket dies constantly and recover without user intervention.

The recovery protocol has four parts. First, observe subscription status explicitly — the subscribe callback gives you SUBSCRIBED, TIMED_OUT, and CHANNEL_ERROR; log all three, not just success. Second, back off reconnects exponentially with jitter so 10,000 clients coming out of a tunnel do not reconnect in the same millisecond. Third, resync missed data on every reconnect using the last-known-timestamp fetch described above. Fourth, surface connection state in the UI: a small "live" vs "reconnecting" indicator prevents the worst realtime bug, which is the user trusting a stale screen.

let retryAttempt = 0

function scheduleReconnect() {
  const delay = Math.min(1000 * 2 ** retryAttempt, 30000) + Math.random() * 1000
  retryAttempt += 1
  setConnectionState('reconnecting')
  setTimeout(() => channel.subscribe((status) => {
    if (status === 'SUBSCRIBED') {
      retryAttempt = 0
      setConnectionState('live')
      resyncMissedChanges()
    } else {
      scheduleReconnect()
    }
  }), delay)
}
Enter fullscreen mode Exit fullscreen mode

Cap channels per screen at one or two. Each channel is a multiplexed subscription, so you do not need a channel per conversation — one channel can carry multiple postgres_changes bindings with different filters. Ten channels on one screen means ten reconnect state machines; one channel with ten bindings means one. This distinction matters enormously when the reconnect storm hits.

When not to use realtime

Realtime is not free. Each connected client holds a socket on Supabase's infrastructure, and the Postgres replication slot behind Postgres Changes adds write-amplification on your database. Three cases where polling or push notifications win: data that changes rarely (app config, user profile — fetch on foreground instead), data the user is not looking at (background conversation updates belong in push notifications, not sockets), and high-frequency sensor-style data (location breadcrumbs every second are cheaper as batched REST writes than per-row replication events).

The rule of thumb: subscribe only to what is on screen, unsubscribe on blur, and let push notifications plus foreground resync cover everything else. Your connection count — and your bill — will stay proportional to actual attention instead of installed base.

Sources

Top comments (0)