Skip to main content
Technology & EngineeringMessaging Services347 lines

Pusher

"Pusher: real-time WebSocket channels, presence channels, private channels, triggers, React hooks"

Quick Summary18 lines
Pusher provides hosted WebSocket infrastructure for real-time communication between servers and clients. The model is pub/sub: your server triggers events on named channels, and clients subscribe to those channels to receive events instantly. Pusher handles all WebSocket connection management, automatic reconnection, and scaling. Design your real-time features around three channel types — public (no auth), private (server-authenticated), and presence (authenticated with member tracking). Your server is always the source of truth: clients subscribe and react, but state changes flow through your backend, which triggers events after validating and persisting data. Never trigger events directly from the client in production; always route through your server.

## Key Points

- Always use private or presence channels for sensitive data; public channels are readable by anyone with your app key
- Trigger events from the server after persisting data — the server is the authority, Pusher is the notification layer
- Use the `socket_id` exclusion parameter so the sender does not receive their own event (apply optimistic updates instead)
- Batch multi-channel triggers into groups of 10 (Pusher's per-call limit)
- Keep event payloads small (under 10KB); send IDs and let clients fetch full data if needed
- Name channels and events consistently: `private-resource-{id}` for channels, `resource:action` for events
- Monitor connection state via `pusher.connection.bind("state_change", ...)` and show connection status in the UI
- Use presence channels only when you need member lists; they have lower member limits than private channels
- **Triggering events from the client** — Client events bypass your server validation and persistence; always route through your backend
- **Using public channels for user-specific data** — Anyone can subscribe to public channels with just the app key; use private channels for anything non-public
- **Storing state in Pusher** — Pusher is a transport layer, not a database; persist state in your own data store and use Pusher only to notify of changes
- **Large payloads in events** — Pusher has a 10KB payload limit; sending large objects fails silently or gets rejected — send references and let clients fetch
skilldb get messaging-services-skills/PusherFull skill: 347 lines
Paste into your CLAUDE.md or agent config

Pusher Real-Time Channels

Core Philosophy

Pusher provides hosted WebSocket infrastructure for real-time communication between servers and clients. The model is pub/sub: your server triggers events on named channels, and clients subscribe to those channels to receive events instantly. Pusher handles all WebSocket connection management, automatic reconnection, and scaling. Design your real-time features around three channel types — public (no auth), private (server-authenticated), and presence (authenticated with member tracking). Your server is always the source of truth: clients subscribe and react, but state changes flow through your backend, which triggers events after validating and persisting data. Never trigger events directly from the client in production; always route through your server.

Setup

Server-Side Configuration

import Pusher from "pusher";

const pusher = new Pusher({
  appId: process.env.PUSHER_APP_ID!,
  key: process.env.PUSHER_KEY!,
  secret: process.env.PUSHER_SECRET!,
  cluster: process.env.PUSHER_CLUSTER!,
  useTLS: true,
});

// Basic event trigger
async function triggerEvent(channel: string, event: string, data: unknown) {
  await pusher.trigger(channel, event, data);
}

Client-Side React Setup

import PusherJS from "pusher-js";
import { createContext, useContext, useEffect, useRef, useState } from "react";

const pusherClient = new PusherJS(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
  cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
  authEndpoint: "/api/pusher/auth",
});

// React context for sharing the Pusher instance
const PusherContext = createContext<PusherJS | null>(null);

function PusherProvider({ children }: { children: React.ReactNode }) {
  return (
    <PusherContext.Provider value={pusherClient}>
      {children}
    </PusherContext.Provider>
  );
}

function usePusher(): PusherJS {
  const pusher = useContext(PusherContext);
  if (!pusher) throw new Error("usePusher must be used within PusherProvider");
  return pusher;
}

Key Techniques

Channel Subscription Hook

function useChannel(channelName: string) {
  const pusher = usePusher();
  const [channel, setChannel] = useState<PusherJS.Channel | null>(null);

  useEffect(() => {
    const ch = pusher.subscribe(channelName);
    setChannel(ch);

    ch.bind("pusher:subscription_succeeded", () => {
      console.log(`Subscribed to ${channelName}`);
    });

    ch.bind("pusher:subscription_error", (error: unknown) => {
      console.error(`Subscription error on ${channelName}:`, error);
    });

    return () => {
      pusher.unsubscribe(channelName);
    };
  }, [pusher, channelName]);

  return channel;
}

function useEvent<T = unknown>(
  channel: PusherJS.Channel | null,
  eventName: string,
  callback: (data: T) => void
) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    if (!channel) return;

    const handler = (data: T) => callbackRef.current(data);
    channel.bind(eventName, handler);

    return () => {
      channel.unbind(eventName, handler);
    };
  }, [channel, eventName]);
}

Private Channels with Server Authentication

// Server-side auth endpoint
import express from "express";

const app = express();
app.use(express.json());

app.post("/api/pusher/auth", (req, res) => {
  const { socket_id, channel_name } = req.body;
  const userId = req.session?.userId; // your auth middleware

  if (!userId) {
    res.status(403).json({ error: "Unauthorized" });
    return;
  }

  // Validate user has access to this channel
  if (!userCanAccessChannel(userId, channel_name)) {
    res.status(403).json({ error: "Forbidden" });
    return;
  }

  const authResponse = pusher.authorizeChannel(socket_id, channel_name);
  res.json(authResponse);
});

function userCanAccessChannel(userId: string, channelName: string): boolean {
  // Private channels start with "private-"
  if (channelName.startsWith("private-user-")) {
    const channelUserId = channelName.replace("private-user-", "");
    return channelUserId === userId;
  }
  return true;
}

// Client usage
function UserNotifications({ userId }: { userId: string }) {
  const channel = useChannel(`private-user-${userId}`);

  useEvent<{ title: string; body: string }>(channel, "notification", (data) => {
    showToast(data.title, data.body);
  });

  return null;
}

Presence Channels for Online Status

// Server-side presence auth
app.post("/api/pusher/auth", (req, res) => {
  const { socket_id, channel_name } = req.body;
  const user = getCurrentUser(req);

  if (channel_name.startsWith("presence-")) {
    const presenceData = {
      user_id: user.id,
      user_info: {
        name: user.name,
        avatar: user.avatarUrl,
      },
    };
    const auth = pusher.authorizeChannel(socket_id, channel_name, presenceData);
    res.json(auth);
    return;
  }

  const auth = pusher.authorizeChannel(socket_id, channel_name);
  res.json(auth);
});

// React hook for presence
interface PresenceMember {
  id: string;
  info: { name: string; avatar: string };
}

function usePresenceChannel(channelName: string) {
  const pusher = usePusher();
  const [members, setMembers] = useState<Map<string, PresenceMember>>(new Map());
  const [channel, setChannel] = useState<PusherJS.PresenceChannel | null>(null);

  useEffect(() => {
    const ch = pusher.subscribe(channelName) as PusherJS.PresenceChannel;
    setChannel(ch);

    ch.bind("pusher:subscription_succeeded", (memberData: { members: Record<string, unknown> }) => {
      const memberMap = new Map<string, PresenceMember>();
      Object.entries(memberData.members).forEach(([id, info]) => {
        memberMap.set(id, { id, info: info as { name: string; avatar: string } });
      });
      setMembers(memberMap);
    });

    ch.bind("pusher:member_added", (member: { id: string; info: { name: string; avatar: string } }) => {
      setMembers((prev) => new Map(prev).set(member.id, member));
    });

    ch.bind("pusher:member_removed", (member: { id: string }) => {
      setMembers((prev) => {
        const next = new Map(prev);
        next.delete(member.id);
        return next;
      });
    });

    return () => {
      pusher.unsubscribe(channelName);
    };
  }, [pusher, channelName]);

  return { channel, members };
}

Server-Side Event Triggers

// Trigger to a single channel
async function notifyUser(userId: string, event: string, data: unknown) {
  await pusher.trigger(`private-user-${userId}`, event, data);
}

// Trigger to multiple channels (max 10 per call)
async function broadcastToRooms(roomIds: string[], event: string, data: unknown) {
  const channels = roomIds.map((id) => `private-room-${id}`);

  // Batch into groups of 10
  for (let i = 0; i < channels.length; i += 10) {
    const batch = channels.slice(i, i + 10);
    await pusher.trigger(batch, event, data);
  }
}

// Exclude a specific socket from receiving the event
async function triggerExcludingSender(
  channel: string,
  event: string,
  data: unknown,
  socketId: string
) {
  await pusher.trigger(channel, event, data, { socket_id: socketId });
}

Real-Time Chat Example

// Server endpoint for sending messages
app.post("/api/messages", async (req, res) => {
  const { channelId, text } = req.body;
  const user = getCurrentUser(req);

  // Persist the message
  const message = await db.messages.create({
    channelId,
    userId: user.id,
    text,
    createdAt: new Date(),
  });

  // Broadcast to channel, exclude sender's socket
  await pusher.trigger(
    `private-room-${channelId}`,
    "message:new",
    {
      id: message.id,
      text: message.text,
      user: { id: user.id, name: user.name },
      createdAt: message.createdAt,
    },
    { socket_id: req.body.socketId }
  );

  res.json(message);
});

// React chat component
function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const channel = useChannel(`private-room-${roomId}`);

  useEvent<Message>(channel, "message:new", (message) => {
    setMessages((prev) => [...prev, message]);
  });

  useEvent(channel, "message:deleted", (data: { id: string }) => {
    setMessages((prev) => prev.filter((m) => m.id !== data.id));
  });

  const sendMessage = async (text: string) => {
    const socketId = pusherClient.connection.socket_id;
    const response = await fetch("/api/messages", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ channelId: roomId, text, socketId }),
    });
    const message = await response.json();
    setMessages((prev) => [...prev, message]); // Optimistic add
  };

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>{m.user.name}: {m.text}</div>
      ))}
      <MessageInput onSend={sendMessage} />
    </div>
  );
}

Best Practices

  • Always use private or presence channels for sensitive data; public channels are readable by anyone with your app key
  • Trigger events from the server after persisting data — the server is the authority, Pusher is the notification layer
  • Use the socket_id exclusion parameter so the sender does not receive their own event (apply optimistic updates instead)
  • Batch multi-channel triggers into groups of 10 (Pusher's per-call limit)
  • Keep event payloads small (under 10KB); send IDs and let clients fetch full data if needed
  • Name channels and events consistently: private-resource-{id} for channels, resource:action for events
  • Monitor connection state via pusher.connection.bind("state_change", ...) and show connection status in the UI
  • Use presence channels only when you need member lists; they have lower member limits than private channels

Anti-Patterns

  • Triggering events from the client — Client events bypass your server validation and persistence; always route through your backend
  • Using public channels for user-specific data — Anyone can subscribe to public channels with just the app key; use private channels for anything non-public
  • Storing state in Pusher — Pusher is a transport layer, not a database; persist state in your own data store and use Pusher only to notify of changes
  • Large payloads in events — Pusher has a 10KB payload limit; sending large objects fails silently or gets rejected — send references and let clients fetch
  • Subscribing to too many channels — Each client has a connection limit (typically 100 channels); aggregate related events into fewer channels
  • Not handling disconnection — Users lose connection on mobile or spotty networks; always handle reconnection and fetch missed state from your API
  • Creating channels without cleanup — Unused channels consume resources; design your channel naming so they naturally become inactive when users leave

Install this skill directly: skilldb add messaging-services-skills

Get CLI access →