Introduction

Real-time features—live chat, collaborative editing, live updates, presence indicators—are no longer “nice-to-have” features reserved for enterprise apps. Users expect instant updates across devices. Building these features from scratch used to mean managing WebSocket servers, handling connection state, and dealing with scaling nightmares.

Supabase’s real-time engine changes this. Built on PostgreSQL’s Write-Ahead Log (WAL) and Phoenix Channels, it provides production-ready real-time subscriptions with surprisingly simple APIs. As of September 2024, Supabase Realtime powers everything from collaborative whiteboards to live sports scoreboards to multiplayer games.

This guide walks through building real-time features for mobile apps using Supabase, from initial setup to production deployment—with specific attention to Australian deployment and performance optimization.

What is Supabase Realtime?

Supabase Realtime provides three types of real-time functionality:

1. Database Changes (Postgres CDC)

Listen to INSERT, UPDATE, DELETE operations on any table:

supabase
  .channel('public:posts')
  .on('postgres_changes',
    { event: '*', schema: 'public', table: 'posts' },
    (payload) => {
      console.log('Change received!', payload);
    }
  )
  .subscribe();

Use cases:

  • Live news feeds
  • Collaborative document editing
  • Real-time dashboards
  • Inventory updates
  • Order status tracking

2. Presence

Track who’s online and their state:

const channel = supabase.channel('room-1');

channel
  .on('presence', { event: 'sync' }, () => {
    const state = channel.presenceState();
    console.log('Online users:', Object.keys(state));
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await channel.track({ user_id: userId, online_at: new Date().toISOString() });
    }
  });

Use cases:

  • “Who’s viewing this” indicators
  • Collaborative editing cursors
  • Online/offline status
  • Active user counts
  • Game lobbies

3. Broadcast

Send ephemeral messages (not stored in database):

channel.send({
  type: 'broadcast',
  event: 'cursor-move',
  payload: { x: 100, y: 200, user: 'jane' }
});

channel.on('broadcast', { event: 'cursor-move' }, (payload) => {
  console.log('Cursor moved:', payload);
});

Use cases:

  • Mouse cursors in collaborative tools
  • Typing indicators
  • Ephemeral notifications
  • Real-time game state
  • Video call signaling

Project S

etup

Installation

# Create React Native project (or use existing)
npx react-native init RealtimeApp --template react-native-template-typescript

# Install Supabase client
npm install @supabase/supabase-js

# For React Native, also install async storage
npm install @react-native-async-storage/async-storage

Initialize Supabase Client

// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
import AsyncStorage from '@react-native-async-storage/async-storage';

const supabaseUrl = 'https://your-project.supabase.co';
const supabaseAnonKey = 'your-anon-key';

export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
  realtime: {
    params: {
      eventsPerSecond: 10, // Rate limit to prevent spam
    },
  },
});

Example 1: Live Chat (Database Changes)

Let’s build a

real-time chat for a customer support app.

Database Schema

-- Create messages table
CREATE TABLE messages (
  id BIGSERIAL PRIMARY KEY,
  room_id UUID NOT NULL,
  user_id UUID REFERENCES auth.users(id) NOT NULL,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create index for fast queries
CREATE INDEX messages_room_id_idx ON messages(room_id, created_at DESC);

-- Enable Row Level Security
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- Policy: Users can read messages in their rooms
CREATE POLICY "Users can read messages in their rooms"
ON messages FOR SELECT
USING (
  room_id IN (
    SELECT room_id FROM room_participants WHERE user_id = auth.uid()
  )
);

-- Policy: Users can insert messages in their rooms
CREATE POLICY "Users can insert messages in their rooms"
ON messages FOR INSERT
WITH CHECK (
  room_id IN (
    SELECT room_id FROM room_participants WHERE user_id = auth.uid()
  )
);

-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE messages;

React Native Chat Component

// components/Chat.tsx
import React, { useState, useEffect } from 'react';
import { View, TextInput, FlatList, Text, Button } from 'react-native';
import { supabase } from '../lib/supabase';
import { RealtimeChannel } from '@supabase/supabase-js';

interface Message {
  id: number;
  content: string;
  user_id: string;
  created_at: string;
}

export default function Chat({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [newMessage, setNewMessage] = useState('');
  const [channel, setChannel] = useState<RealtimeChannel | null>(null);

  useEffect(() => {
    // Fetch existing messages
    const fetchMessages = async () => {
      const { data, error } = await supabase
        .from('messages')
        .select('*')
        .eq('room_id', roomId)
        .order('created_at', { ascending: true })
        .limit(50);

      if (data) setMessages(data);
    };

    fetchMessages();

    // Subscribe to new messages
    const channel = supabase
      .channel(`room:${roomId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'messages',
          filter: `room_id=eq.${roomId}`,
        },
        (payload) => {
          const newMessage = payload.new as Message;
          setMessages((current) => [...current, newMessage]);
        }
      )
      .subscribe();

    setChannel(channel);

    // Cleanup
    return () => {
      channel.unsubscribe();
    };
  }, [roomId]);

  const sendMessage = async () => {
    if (!newMessage.trim()) return;

    const { error } = await supabase.from('messages').insert({
      room_id: roomId,
      content: newMessage.trim(),
      user_id: (await supabase.auth.getUser()).data.user?.id,
    });

    if (!error) {
      setNewMessage('');
    }
  };

  return (
    <View style={{ flex: 1 }}>
      <FlatList
        data={messages}
        keyExtractor={(item) => item.id.toString()}
        renderItem={({ item }) => (
          <View style={{ padding: 10 }}>
            <Text>{item.content}</Text>
            <Text style={{ fontSize: 10, color: 'gray' }}>
              {new Date(item.created_at).toLocaleTimeString()}
            </Text>
          </View>
        )}
      />
      <View style={{ flexDirection: 'row', padding: 10 }}>
        <TextInput
          value={newMessage}
          onChangeText={setNewMessage}
          placeholder="Type a message..."
          style={{ flex: 1, borderWidth: 1, padding: 10 }}
        />
        <Button title="Send" onPress={sendMessage} />
      </View>
    </View>
  );
}

Performance Optimization

Problem: Loading all messages on mount can be slow.

Solution: Pagination + real-time

const [messages, setMessages] = useState<Message[]>([]);
const [oldestMessageId, setOldestMessageId] = useState<number | null>(null);
const PAGE_SIZE = 20;

// Initial load
const fetchMessages = async () => {
  const { data } = await supabase
    .from('messages')
    .select('*')
    .eq('room_id', roomId)
    .order('created_at', { ascending: false })
    .limit(PAGE_SIZE);

  if (data) {
    setMessages(data.reverse());
    setOldestMessageId(data[0]?.id);
  }
};

// Load more (scroll to top)
const loadMoreMessages = async () => {
  if (!oldestMessageId) return;

  const { data } = await supabase
    .from('messages')
    .select('*')
    .eq('room_id', roomId)
    .lt('id', oldestMessageId)
    .order('created_at', { ascending: false })
    .limit(PAGE_SIZE);

  if (data) {
    setMessages((current) => [...data.reverse(), ...current]);
    setOldestMessageId(data[0]?.id);
  }
};

Example 2: Presence (Online Users)

Track who’s online in a collaborative workspace.

Presence Component

// components/OnlineUsers.tsx
import React, { useState, useEffect } from 'react';
import { View, Text } from 'react-native';
import { supabase } from '../lib/supabase';

interface UserPresence {
  user_id: string;
  username: string;
  online_at: string;
}

export default function OnlineUsers({ workspaceId }: { workspaceId: string }) {
  const [onlineUsers, setOnlineUsers] = useState<Record<string, UserPresence>>({});

  useEffect(() => {
    const channel = supabase.channel(`workspace:${workspaceId}`);

    channel
      .on('presence', { event: 'sync' }, () => {
        const state = channel.presenceState<UserPresence>();
        setOnlineUsers(state);
      })
      .on('presence', { event: 'join' }, ({ key, newPresences }) => {
        console.log('User joined:', newPresences);
      })
      .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
        console.log('User left:', leftPresences);
      })
      .subscribe(async (status) => {
        if (status === 'SUBSCRIBED') {
          const user = await supabase.auth.getUser();

          if (user.data.user) {
            await channel.track({
              user_id: user.data.user.id,
              username: user.data.user.email || 'Anonymous',
              online_at: new Date().toISOString(),
            });
          }
        }
      });

    return () => {
      channel.unsubscribe();
    };
  }, [workspaceId]);

  const userCount = Object.keys(onlineUsers).length;

  return (
    <View style={{ padding: 10, backgroundColor: '#f0f0f0' }}>
      <Text style={{ fontWeight: 'bold' }}>
        {userCount} {userCount === 1 ? 'user' : 'users'} online
      </Text>
      {Object.entries(onlineUsers).map(([key, presence]) => (
        <View key={key} style={{ flexDirection: 'row', alignItems: 'center', marginTop: 5 }}>
          <View
            style={{
              width: 8,
              height: 8,
              borderRadius: 4,
              backgroundColor: 'green',
              marginRight: 5,
            }}
          />
          <Text>{presence.username}</Text>
        </View>
      ))}
    </View>
  );
}

Advanced: Show User Activity

Track what users are doing (viewing, editing, etc.):

// Track detailed presence state
await channel.track({
  user_id: userId,
  username: user.email,
  online_at: new Date().toISOString(),
  current_page: 'dashboard',
  is_active: true,
  last_activity: new Date().toISOString(),
});

// Update activity periodically
setInterval(async () => {
  await channel.track({
    ...currentPresence,
    last_activity: new Date().toISOString(),
  });
}, 30000); // Every 30 seconds

Example 3: Broadcast (Typing Indicators)

Show when users are typing in a chat.

Typing Indicator Component

// components/TypingIndicator.tsx
import React, { useState, useEffect } from 'react';
import { View, Text } from 'react-native';
import { supabase } from '../lib/supabase';

interface TypingUser {
  user_id: string;
  username: string;
}

export default function TypingIndicator({ roomId }: { roomId: string }) {
  const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser>>({});

  useEffect(() => {
    const channel = supabase.channel(`room:${roomId}:typing`);

    channel
      .on('broadcast', { event: 'typing-start' }, ({ payload }) => {
        setTypingUsers((current) => ({
          ...current,
          [payload.user_id]: payload,
        }));

        // Remove after 3 seconds of no activity
        setTimeout(() => {
          setTypingUsers((current) => {
            const updated = { ...current };
            delete updated[payload.user_id];
            return updated;
          });
        }, 3000);
      })
      .on('broadcast', { event: 'typing-stop' }, ({ payload }) => {
        setTypingUsers((current) => {
          const updated = { ...current };
          delete updated[payload.user_id];
          return updated;
        });
      })
      .subscribe();

    return () => {
      channel.unsubscribe();
    };
  }, [roomId]);

  const typingUsernames = Object.values(typingUsers).map((u) => u.username);

  if (typingUsernames.length === 0) return null;

  const text =
    typingUsernames.length === 1
      ? `${typingUsernames[0]} is typing...`
      : `${typingUsernames.join(', ')} are typing...`;

  return (
    <View style={{ padding: 5 }}>
      <Text style={{ fontStyle: 'italic', color: 'gray' }}>{text}</Text>
    </View>
  );
}

// In chat input component
const handleTextChange = async (text: string) => {
  setNewMessage(text);

  const user = await supabase.auth.getUser();

  if (text.length > 0 && !isTyping) {
    setIsTyping(true);
    channel.send({
      type: 'broadcast',
      event: 'typing-start',
      payload: {
        user_id: user.data.user?.id,
        username: user.data.user?.email,
      },
    });
  } else if (text.length === 0 && isTyping) {
    setIsTyping(false);
    channel.send({
      type: 'broadcast',
      event: 'typing-stop',
      payload: {
        user_id: user.data.user?.id,
      },
    });
  }
};

Production Considerations

1. Connection Management

Problem: Mobile apps lose connections frequently (network switches, backgrounding).

Solution: Automatic reconnection with exponential backoff.

const channel = supabase
  .channel('my-channel', {
    config: {
      broadcast: { self: true },
      presence: { key: userId },
    },
  })
  .subscribe((status, err) => {
    if (status === 'SUBSCRIBED') {
      console.log('✅ Connected');
    }
    if (status === 'CHANNEL_ERROR') {
      console.error('❌ Connection error:', err);
      // Supabase automatically retries with backoff
    }
    if (status === 'TIMED_OUT') {
      console.warn('⏱️ Connection timed out');
    }
  });

// Listen to connection state
supabase.realtime.onConnectionStateChange((state) => {
  console.log('Connection state:', state);
  // 'connecting' | 'open' | 'closing' | 'closed'
});

2. Rate Limiting

Problem: Too many updates can overwhelm clients and database.

Solution: Throttle/debounce updates.

import { throttle, debounce } from 'lodash';

// Throttle cursor movements (max once per 100ms)
const sendCursorPosition = throttle((x: number, y: number) => {
  channel.send({
    type: 'broadcast',
    event: 'cursor-move',
    payload: { x, y, user_id: userId },
  });
}, 100);

// Debounce typing indicator (wait 500ms after last keystroke)
const stopTyping = debounce(() => {
  channel.send({
    type: 'broadcast',
    event: 'typing-stop',
    payload: { user_id: userId },
  });
}, 500);

3. Message Filtering

Problem: Receiving updates for irrelevant records.

Solution: Server-side filters.

// Only get messages from specific user
channel.on(
  'postgres_changes',
  {
    event: '*',
    schema: 'public',
    table: 'messages',
    filter: `user_id=eq.${targetUserId}`,
  },
  handleChange
);

// Only get messages after certain timestamp
channel.on(
  'postgres_changes',
  {
    event: 'INSERT',
    schema: 'public',
    table: 'messages',
    filter: `created_at=gt.${new Date().toISOString()}`,
  },
  handleChange
);

4. Security with Row Level Security

Critical: Always use RLS to prevent unauthorized access to real-time data.

-- Only receive updates for messages you're authorized to see
CREATE POLICY "Users can only see their own messages"
ON messages FOR SELECT
USING (
  auth.uid() = user_id
  OR
  -- Or in rooms they're part of
  room_id IN (
    SELECT room_id FROM room_participants WHERE user_id = auth.uid()
  )
);

Even with real-time subscriptions, RLS policies are enforced. Users only receive real-time updates for rows they can SELECT.

5. Memory Management

Problem: Long-running subscriptions accumulate messages in memory.

Solution: Limit stored messages and clean up old data.

const MAX_MESSAGES = 100;

setMessages((current) => {
  const updated = [...current, newMessage];
  // Keep only last 100 messages in memory
  return updated.slice(-MAX_MESSAGES);
});

Australian Deployment & Performance

Choose the Right Region

Supabase has a Sydney (ap-southeast-2) region:

Project Settings > Database > Region: Australia Southeast (Sydney)

Latency comparison (tested from Melbourne, Sep 2024):

RegionPing (avg)Real-time latency
Sydney12ms25-40ms
Singapore98ms120-180ms
US West185ms220-290ms

For Australian apps: Always choose Sydney region for sub-50ms real-time updates.

Connection Pooling

For production apps with many concurrent users:

-- Increase connection pool size (Pro plan)
ALTER SYSTEM SET max_connections = 200;

-- Connection pooler settings
ALTER SYSTEM SET pool_mode = 'transaction';
ALTER SYSTEM SET default_pool_size = 20;

Monitor Performance

Supabase Dashboard > Database > Performance:

  • Active connections
  • Slow queries
  • Disk usage
  • Cache hit rate

Set up alerts:

Settings > Alerts
- High CPU usage (> 80%)
- High connection count (> 150)
- Slow query duration (> 1s)

Scaling Strategies

Horizontal Scaling with Channels

Problem: One channel can handle ~1,000 concurrent connections.

Solution: Shard users across multiple channels.

// Shard by user ID (distribute load)
const channelId = `room:${roomId}:shard:${userId % 10}`;
const channel = supabase.channel(channelId);

Caching Strategy

Problem: Every user joining fetches full message history.

Solution: Cache recent messages client-side.

import AsyncStorage from '@react-native-async-storage/async-storage';

// Cache messages
const cacheMessages = async (roomId: string, messages: Message[]) => {
  await AsyncStorage.setItem(
    `messages:${roomId}`,
    JSON.stringify(messages)
  );
};

// Load from cache first, then fetch updates
const loadMessages = async (roomId: string) => {
  // 1. Load from cache (instant)
  const cached = await AsyncStorage.getItem(`messages:${roomId}`);
  if (cached) {
    setMessages(JSON.parse(cached));
  }

  // 2. Fetch latest from server
  const { data } = await supabase
    .from('messages')
    .select('*')
    .eq('room_id', roomId)
    .order('created_at', { ascending: false })
    .limit(50);

  if (data) {
    const sorted = data.reverse();
    setMessages(sorted);
    await cacheMessages(roomId, sorted);
  }
};

Testing Real-Time Features

Mock Supabase Realtime

// __mocks__/supabase.ts
export const mockChannel = {
  on: jest.fn().mockReturnThis(),
  subscribe: jest.fn().mockResolvedValue({ error: null }),
  unsubscribe: jest.fn().mockResolvedValue({ error: null }),
  send: jest.fn(),
  track: jest.fn().mockResolvedValue({ error: null }),
  presenceState: jest.fn().mockReturnValue({}),
};

export const supabase = {
  channel: jest.fn().mockReturnValue(mockChannel),
  auth: {
    getUser: jest.fn().mockResolvedValue({
      data: { user: { id: '123', email: '[email protected]' } },
    }),
  },
  from: jest.fn().mockReturnValue({
    select: jest.fn().mockReturnThis(),
    eq: jest.fn().mockReturnThis(),
    order: jest.fn().mockReturnThis(),
    limit: jest.fn().mockResolvedValue({ data: [] }),
    insert: jest.fn().mockResolvedValue({ error: null }),
  }),
};

Test Real-Time Updates

// Chat.test.tsx
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import Chat from './Chat';
import { mockChannel } from '../__mocks__/supabase';

test('should display new message when received', async () => {
  const { getByText } = render(<Chat roomId="test-room" />);

  // Simulate receiving a message via real-time
  const mockPayload = {
    new: {
      id: 1,
      content: 'Hello from real-time!',
      created_at: new Date().toISOString(),
    },
  };

  // Trigger the postgres_changes callback
  const onCallback = mockChannel.on.mock.calls[0][2];
  onCallback(mockPayload);

  await waitFor(() => {
    expect(getByText('Hello from real-time!')).toBeTruthy();
  });
});

Debugging Real-Time Issues

Enable Debug Logging

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

const supabase = createClient(url, key, {
  realtime: {
    log_level: 'debug', // 'info' | 'debug' | 'warn' | 'error'
  },
});

Common Issues & Solutions

Issue: Subscriptions not receiving updates

Check:

  1. Is RLS enabled and configured correctly?
  2. Is the table added to supabase_realtime publication?
  3. Are you subscribing to the correct channel?
  4. Check browser console for WebSocket errors
-- Verify table is in realtime publication
SELECT schemaname, tablename
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';

Issue: Too many connections

Solution: Unsubscribe when component unmounts

useEffect(() => {
  const channel = supabase.channel('my-channel');
  channel.subscribe();

  return () => {
    channel.unsubscribe(); // Critical!
  };
}, []);

Cost Optimization

Supabase real-time is included in all plans, but consider:

Database bandwidth (Pro plan):

  • Included: 100GB/month
  • Additional: $0.09/GB

Optimization tips:

  1. Use filters to reduce unnecessary updates
  2. Paginate initial data loads
  3. Cache frequently accessed data
  4. Use broadcast for ephemeral data (doesn’t touch database)

Example cost calculation:

  • 1,000 daily active users
  • 50 messages/user/day = 50,000 messages
  • Average message size: 500 bytes
  • Total: 50,000 × 500 bytes = 25MB/day = 750MB/month

Well within free/Pro tier limits.

Conclusion

Supabase Realtime brings production-grade real-time features to mobile apps with minimal setup. By building on PostgreSQL’s WAL and Phoenix Channels, it provides the reliability and scalability needed for Australian apps serving thousands of users.

Key takeaways:

  • Use database changes for persistent data (chat, notifications)
  • Use presence for online/offline tracking
  • Use broadcast for ephemeral updates (cursors, typing)
  • Deploy to Sydney region for Australian users
  • Implement proper RLS for security
  • Handle connection state and rate limiting

Real-time features are now accessible to every Australian startup. Start with a simple chat or presence feature, and expand from there.


Building a real-time mobile app for the Australian market? We’ve implemented real-time features for 20+ apps using Supabase. Contact us for architecture review and optimization strategies.