Back to Blog

Building Real-Time Features with WebSockets and Supabase

Building Real-Time Features with WebSockets and Supabase

Real-time features used to mean setting up WebSocket servers, managing connection state, handling reconnection logic, and scaling pub/sub infrastructure. We have done all of that from scratch on past projects, and it is not where a startup should be spending its engineering hours.

Supabase Realtime sits on top of your PostgreSQL database and gives you three real-time primitives through a single WebSocket connection: database changes, broadcast messages, and presence tracking. We have used all three in production, most extensively on MindHyv, where the social feed, booking notifications, and online status indicators all run through Supabase Realtime.

This post walks through each primitive with working code examples, covers the gotchas we have hit, and explains when to use which.

The Three Realtime Primitives

Supabase Realtime is not a single feature. It is three distinct mechanisms that share a WebSocket connection:

  1. Postgres Changes — listen to INSERT, UPDATE, DELETE events on your database tables. The database is the source of truth, and clients get notified when rows change.
  2. Broadcast — send arbitrary messages to all clients subscribed to a channel. Think chat messages, cursor positions, collaborative editing events.
  3. Presence — track which users are currently online and synchronize shared state. Think “3 users viewing this document” or “Alex is typing.”

Each primitive solves a different problem. Using the wrong one leads to either over-engineering or unreliable behavior.

Setting Up the Client

The Supabase JavaScript client handles the WebSocket connection. You subscribe to channels, and each channel can use any combination of the three primitives.

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

const supabase = createClient(
  process.env.PUBLIC_SUPABASE_URL!,
  process.env.PUBLIC_SUPABASE_ANON_KEY!
);

That is it for setup. The client manages the WebSocket lifecycle, reconnection, and authentication automatically. When a user is signed in via Supabase Auth, the real-time connection inherits their session, which means your Row Level Security policies apply to real-time subscriptions too.

Postgres Changes: Live Database Subscriptions

This is the most commonly used primitive. You subscribe to changes on a table, and every INSERT, UPDATE, or DELETE triggers a callback on every connected client.

Here is a live notification system, the kind we built for MindHyv to notify business owners when they receive a new booking or social interaction:

// Subscribe to new notifications for the current user
const channel = supabase
  .channel("user-notifications")
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "notifications",
      filter: `user_id=eq.${currentUser.id}`,
    },
    (payload) => {
      const notification = payload.new;
      // Add to local state
      notifications = [notification, ...notifications];
      // Show a toast
      showToast(notification.title, notification.body);
    }
  )
  .subscribe();

The filter parameter is critical. Without it, every client receives every notification for every user, and your client-side code has to discard irrelevant ones. With the filter, Supabase only sends events that match, which reduces bandwidth and keeps your client logic simple.

You can also listen for UPDATE and DELETE events:

const feedbackChannel = supabase
  .channel("board-feedback")
  .on(
    "postgres_changes",
    {
      event: "*", // INSERT, UPDATE, and DELETE
      schema: "public",
      table: "feedback",
      filter: `board_id=eq.${boardId}`,
    },
    (payload) => {
      switch (payload.eventType) {
        case "INSERT":
          feedbackItems = [...feedbackItems, payload.new];
          break;
        case "UPDATE":
          feedbackItems = feedbackItems.map((item) =>
            item.id === payload.new.id ? payload.new : item
          );
          break;
        case "DELETE":
          feedbackItems = feedbackItems.filter(
            (item) => item.id !== payload.old.id
          );
          break;
      }
    }
  )
  .subscribe();

This pattern powered the live feedback board on Trackelio. When someone votes on a feedback item or submits a new one, every user viewing that board sees the update instantly.

Important gotcha: Postgres Changes requires you to enable the supabase_realtime publication on the tables you want to track. In the Supabase dashboard, go to Database > Publications and add your tables. Or via SQL:

ALTER PUBLICATION supabase_realtime ADD TABLE notifications;
ALTER PUBLICATION supabase_realtime ADD TABLE feedback;

If you forget this step, your subscription will connect successfully but never fire any events. We have lost a solid hour to this at least twice.

Live chat application interface showing real-time messaging between users

Row-Level Security and Realtime

Postgres Changes respects your RLS policies, but with a nuance. RLS filters are applied server-side, so clients only receive events for rows they are authorized to see. However, this only works when the client is authenticated.

For public tables (like a public feedback board), you need an RLS policy that allows SELECT for anonymous users:

CREATE POLICY "Public boards are readable by everyone"
  ON feedback FOR SELECT
  USING (
    board_id IN (
      SELECT id FROM boards WHERE is_public = true
    )
  );

For authenticated-only tables, the user’s JWT is automatically used to evaluate RLS policies on real-time events. This is one of the reasons we standardize on PostgreSQL with Supabase — the authorization layer works across REST API calls, real-time subscriptions, and direct database access without duplicating logic. We cover our database choice in more detail in our database selection guide.

Broadcast: Ephemeral Messages

Broadcast is for messages that do not need to be persisted. The message goes to all subscribers on a channel and then disappears. If a client was not connected when the message was sent, they never see it.

Use cases: chat messages (if you are also persisting them separately), cursor positions in collaborative editors, “user is typing” indicators, game state updates.

// Send a typing indicator
const chatChannel = supabase.channel("chat-room-123");

// Subscribe to receive broadcasts
chatChannel
  .on("broadcast", { event: "typing" }, (payload) => {
    const { userId, userName } = payload.payload;
    showTypingIndicator(userName);
  })
  .subscribe();

// Send a broadcast
chatChannel.send({
  type: "broadcast",
  event: "typing",
  payload: {
    userId: currentUser.id,
    userName: currentUser.name,
  },
});

Broadcast messages are not stored anywhere. They are fire-and-forget through the WebSocket. This makes them fast and cheap but unsuitable for anything that needs history or delivery guarantees.

We used broadcast on MindHyv for the “user is composing a message” indicator in the direct messaging feature. The actual messages were persisted via Postgres Changes, but the typing indicator was a broadcast — it only matters to users who are currently looking at the conversation.

Presence: Who Is Online

Presence tracks the state of connected clients on a channel. Unlike broadcast, presence maintains a synchronized state object that new subscribers receive immediately upon joining.

const roomChannel = supabase.channel("document-room-456");

// Track presence
roomChannel
  .on("presence", { event: "sync" }, () => {
    const state = roomChannel.presenceState();
    // state is a map of presence keys to arrays of state objects
    const onlineUsers = Object.values(state).flat();
    updateOnlineUsersList(onlineUsers);
  })
  .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") {
      // Track this user's presence
      await roomChannel.track({
        userId: currentUser.id,
        name: currentUser.name,
        avatarUrl: currentUser.avatarUrl,
        onlineAt: new Date().toISOString(),
      });
    }
  });

The sync event fires whenever the presence state changes. The presenceState() method returns the current state, which includes every tracked client on the channel. When a client disconnects (closes the tab, loses internet), Supabase automatically removes their presence after a brief timeout.

Presence is conflict-free. If two clients update their presence simultaneously, both updates are applied and the merged state is distributed. There is no last-write-wins race condition.

Building a Complete Notification System

Let us put the pieces together. Here is a notification system that combines Postgres Changes for persistent notifications with Presence for online status:

// types
interface Notification {
  id: string;
  user_id: string;
  title: string;
  body: string;
  read: boolean;
  created_at: string;
}

// State
let notifications: Notification[] = [];
let unreadCount = 0;

// Fetch initial notifications
async function loadNotifications() {
  const { data, error } = await supabase
    .from("notifications")
    .select("*")
    .eq("user_id", currentUser.id)
    .order("created_at", { ascending: false })
    .limit(50);

  if (data) {
    notifications = data;
    unreadCount = data.filter((n) => !n.read).length;
  }
}

// Subscribe to new notifications in real time
function subscribeToNotifications() {
  const channel = supabase
    .channel("my-notifications")
    .on(
      "postgres_changes",
      {
        event: "INSERT",
        schema: "public",
        table: "notifications",
        filter: `user_id=eq.${currentUser.id}`,
      },
      (payload) => {
        const newNotification = payload.new as Notification;
        notifications = [newNotification, ...notifications];
        unreadCount += 1;

        // Show browser notification if permitted
        if (Notification.permission === "granted") {
          new Notification(newNotification.title, {
            body: newNotification.body,
          });
        }
      }
    )
    .on(
      "postgres_changes",
      {
        event: "UPDATE",
        schema: "public",
        table: "notifications",
        filter: `user_id=eq.${currentUser.id}`,
      },
      (payload) => {
        const updated = payload.new as Notification;
        notifications = notifications.map((n) =>
          n.id === updated.id ? updated : n
        );
        unreadCount = notifications.filter((n) => !n.read).length;
      }
    )
    .subscribe();

  return channel;
}

// Mark notification as read
async function markAsRead(notificationId: string) {
  await supabase
    .from("notifications")
    .update({ read: true })
    .eq("id", notificationId);
  // The UPDATE subscription handles updating local state
}

// Initialize
await loadNotifications();
const channel = subscribeToNotifications();

// Cleanup on component destroy
function cleanup() {
  supabase.removeChannel(channel);
}

This pattern — fetch initial state, then subscribe to changes — is the standard approach for every real-time feature. The initial fetch ensures you have the current state. The subscription keeps it updated. Together, they give you a live UI without polling.

Mobile phone displaying push notifications from an application

The Database Trigger That Powers It

Notifications do not create themselves. On the backend, we use PostgreSQL triggers to generate notifications when relevant events happen:

-- Function to create a notification
CREATE OR REPLACE FUNCTION create_booking_notification()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO notifications (user_id, title, body)
  VALUES (
    NEW.business_owner_id,
    'New Booking',
    format('You have a new booking from %s on %s',
      (SELECT name FROM profiles WHERE id = NEW.customer_id),
      to_char(NEW.scheduled_at, 'Mon DD at HH:MI AM')
    )
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Trigger on new bookings
CREATE TRIGGER on_new_booking
  AFTER INSERT ON bookings
  FOR EACH ROW
  EXECUTE FUNCTION create_booking_notification();

The trigger fires when a booking is inserted. It creates a notification row. The Postgres Changes subscription picks up the new notification row and pushes it to the connected client. The user sees a toast notification in real time. No polling, no custom WebSocket server, no message queue.

This is the architecture we used on MindHyv for booking notifications, new follower alerts, and invoice payment confirmations. PostgreSQL triggers plus Supabase Realtime replaced what would otherwise be a pub/sub system, a WebSocket server, and a notification service.

Performance Considerations

Supabase Realtime works well for most startup-scale applications, but there are limits to keep in mind:

Channel limits. Each Supabase project has a limit on concurrent connections and channels based on your plan. The free tier supports up to 200 concurrent connections. The Pro plan goes up to 500 by default, expandable to thousands.

Filter specificity. The more specific your filters, the less work the server does. A subscription to an entire table with no filter sends all changes to all clients. A subscription filtered by user_id only sends relevant events. Always filter.

Payload size. Postgres Changes sends the full row by default. If your rows are large (JSONB columns with kilobytes of data), consider using a leaner query after receiving the event ID rather than relying on the payload directly.

Reconnection. The Supabase client handles reconnection automatically, but during the disconnect window, you miss events. For critical data, re-fetch the current state on reconnection:

channel.subscribe(async (status) => {
  if (status === "SUBSCRIBED") {
    // Re-fetch current state to catch anything missed during disconnect
    await loadNotifications();
  }
});

When Not to Use Realtime

Not every feature needs real-time updates. Polling every 30 seconds is simpler and perfectly acceptable for dashboards, analytics views, and anything where a slight delay is tolerable.

We do not use real-time for:

  • Analytics dashboards — data is aggregated and a 30-second polling interval is fine.
  • Admin panels — typically used by one person at a time, no collaboration needed.
  • Forms and data entry — the user submits data, not the other way around.

We do use real-time for:

  • Notification systems — users expect instant delivery.
  • Chat and messaging — latency matters.
  • Collaborative features — multiple users editing or viewing the same resource.
  • Live feeds — social feeds, activity streams, status updates.
  • Presence indicators — online/offline status, “currently viewing” lists.

The cost of real-time is a persistent WebSocket connection per client. That is negligible for most applications, but if your users are primarily passive readers who visit once a day, polling is simpler and uses fewer server resources.

Network cables and technology connections enabling WebSocket communication

Cleanup Matters

Every subscription is a WebSocket channel. If you subscribe in a component and do not unsubscribe when the component unmounts, you leak channels. In a SvelteKit app:

import { onDestroy } from "svelte";

const channel = subscribeToNotifications();

onDestroy(() => {
  supabase.removeChannel(channel);
});

In a React app, use a cleanup function in useEffect. In any framework, the principle is the same: unsubscribe when the consumer goes away. We have debugged production issues where a client accumulated dozens of orphaned channels because a SPA navigated between pages without cleaning up. The symptoms were duplicate events and degraded performance.

Our Stack for Real-Time Products

For real-time features, our stack is: SvelteKit on the frontend, Supabase (PostgreSQL + Realtime + Auth) on the backend, and Vercel or Cloudflare for hosting. This gives us server-rendered pages for SEO and initial load, with real-time updates hydrating the page after load.

The combination of PostgreSQL triggers, Row Level Security, and Supabase Realtime eliminates most of the custom backend code that real-time features traditionally require. We wrote about our broader architecture approach in how we structure monorepos.

If you are building a product that needs real-time features and want a team that has shipped them in production, reach out at hello@threshline.com.