Skip to content

Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.

The Problem

Events are triggered on the server but the client doesn’t receive them:

// Server
pusher.trigger('my-channel', 'my-event', { message: 'hello' });

// Client
channel.bind('my-event', (data) => {
  console.log(data);  // Never fires
});

Or subscribing to a private channel fails:

Pusher: Error: No callbacks on subscriptions for pusher:subscription_error

Or the connection drops and doesn’t reconnect:

Pusher: Connection state: disconnected
Pusher: Connection state: unavailable

Why This Happens

Pusher Channels is a hosted WebSocket service for real-time communication, with a public key for the browser and a secret key for your server. Most “events not received” bugs are not WebSocket bugs at all — they are misconfigurations of which app, which cluster, or which channel the two sides are talking to.

Channel naming is the second source of trouble. Pusher uses a prefix convention to decide whether a channel needs authentication: private-* and presence-* channels go through your auth endpoint, and the server signs the subscription with the secret. Public channels (no prefix) require no auth. Typos in the prefix make the difference between “works immediately” and “403 every time.”

Common causes:

  • App credentials are separate for client and server — the client uses the key (public) and the server uses the appId, key, secret, and cluster. Using the wrong cluster or key on either side means messages go to the wrong app.
  • Private and presence channels require server-side authentication — channels starting with private- or presence- need an auth endpoint. The client sends a request to your server, which signs the subscription with the Pusher secret. Without this endpoint, subscription fails.
  • The cluster must match — Pusher apps are region-specific (us2, eu, ap1, etc.). If the client connects to us2 but the server triggers on eu, events are sent to different clusters.
  • Events must match exactlychannel.bind('my-event', ...) only receives events named exactly my-event. A server triggering myEvent (no hyphen) does not match.
  • WebSocket port blocked by network — corporate proxies, some VPNs, and aggressive ad blockers can break the WebSocket upgrade. pusher-js falls back to long-polling, but only if enabledTransports allows it.

Version History That Changes the Failure Mode

Pusher’s client library and protocol have both moved forward in the last two years. The default behavior changed enough that examples written for v7 can break against v8.

  • pusher-js v7.x (2020–2023) — older client. The auth endpoint was configured via the top-level auth option: auth: { endpoint: '/api/pusher/auth' }. The default cluster was mt1. Many tutorials still use this shape and break silently on v8.
  • pusher-js v8.0 (April 2023) — current major. Renamed the auth option to channelAuthorization (with userAuthentication for the new sign-in protocol). The old auth option still works but logs a deprecation warning. Examples mixing both shapes are a common source of “auth not firing” reports.
  • pusher-js v8.4 (early 2024) — improved presence-channel client lib with explicit pusherClient.signin() and end-to-end encryption for private-encrypted-* channels. Adds typed Members API.
  • Pusher Channels signed channels (April 2024) — server-side feature that lets you sign a channel name with HMAC so even subscription requires possession of the secret. Useful for preventing channel enumeration. Requires updated server SDKs.
  • Pusher Channels v8 server protocol — supports batch triggers (up to 10 events per call), webhooks for connection lifecycle, and per-event encryption. Older server libraries may not expose triggerBatch.
  • Pusher Beams 2.0 — separate product for push notifications (iOS/Android/web push), not WebSockets. People sometimes install @pusher/push-notifications-server thinking it is the Channels SDK.
  • Soketi (open-source alternative) — drop-in protocol-compatible replacement for Pusher Channels. Same client SDK works against a self-hosted Soketi server. If your Pusher bill grew faster than your traffic, Soketi is the most common escape hatch.

If your codebase mixes auth: { endpoint } (v7) and channelAuthorization: { endpoint } (v8) options, only the v8 form is honored on v8+ and the v7 form is silently ignored. Standardize on one shape and lock the version in package.json.

Fix 1: Server Setup

npm install pusher           # Server
npm install pusher-js         # Client
// lib/pusher-server.ts
import Pusher from 'pusher';

export 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!,  // e.g., 'us2', 'eu', 'ap1'
  useTLS: true,
});

// Trigger an event
export async function sendNotification(userId: string, message: string) {
  await pusher.trigger(`private-user-${userId}`, 'notification', {
    message,
    timestamp: new Date().toISOString(),
  });
}

// Trigger to multiple channels
export async function broadcastMessage(channelIds: string[], data: any) {
  // Max 10 channels per trigger call
  const batches = [];
  for (let i = 0; i < channelIds.length; i += 10) {
    batches.push(channelIds.slice(i, i + 10));
  }

  for (const batch of batches) {
    await pusher.trigger(batch, 'new-message', data);
  }
}
// lib/pusher-client.ts
import PusherClient from 'pusher-js';

export const pusherClient = new PusherClient(
  process.env.NEXT_PUBLIC_PUSHER_KEY!,
  {
    cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    // Auth endpoint for private/presence channels
    channelAuthorization: {
      endpoint: '/api/pusher/auth',
      transport: 'ajax',
    },
  },
);

Fix 2: Channel Authentication Endpoint

// app/api/pusher/auth/route.ts — authenticate private/presence channels
import { pusher } from '@/lib/pusher-server';
import { auth } from '@/auth';

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'Unauthorized' }, { status: 403 });
  }

  const body = await req.text();
  const params = new URLSearchParams(body);
  const socketId = params.get('socket_id')!;
  const channelName = params.get('channel_name')!;

  // Verify the user should access this channel
  if (channelName.startsWith('private-user-')) {
    const channelUserId = channelName.replace('private-user-', '');
    if (channelUserId !== session.user.id) {
      return Response.json({ error: 'Forbidden' }, { status: 403 });
    }
  }

  // For presence channels — include user data
  if (channelName.startsWith('presence-')) {
    const presenceData = {
      user_id: session.user.id,
      user_info: {
        name: session.user.name,
        avatar: session.user.image,
      },
    };
    const authResponse = pusher.authorizeChannel(socketId, channelName, presenceData);
    return Response.json(authResponse);
  }

  // For private channels
  const authResponse = pusher.authorizeChannel(socketId, channelName);
  return Response.json(authResponse);
}

Fix 3: React Integration

// hooks/usePusher.ts — custom hook
'use client';

import { useEffect, useRef, useState } from 'react';
import { pusherClient } from '@/lib/pusher-client';
import type { Channel, PresenceChannel } from 'pusher-js';

// Subscribe to a channel and bind events
export function useChannel(channelName: string) {
  const [channel, setChannel] = useState<Channel | null>(null);

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

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

  return channel;
}

// Bind to a specific event
export function useEvent<T = any>(
  channel: 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]);
}

// Presence channel hook
export function usePresenceChannel(channelName: string) {
  const [members, setMembers] = useState<Map<string, any>>(new Map());
  const channelRef = useRef<PresenceChannel | null>(null);

  useEffect(() => {
    const channel = pusherClient.subscribe(channelName) as PresenceChannel;
    channelRef.current = channel;

    channel.bind('pusher:subscription_succeeded', (data: any) => {
      setMembers(new Map(Object.entries(data.members)));
    });

    channel.bind('pusher:member_added', (member: any) => {
      setMembers(prev => new Map(prev).set(member.id, member.info));
    });

    channel.bind('pusher:member_removed', (member: any) => {
      setMembers(prev => {
        const next = new Map(prev);
        next.delete(member.id);
        return next;
      });
    });

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

  return { members, channel: channelRef.current };
}
// components/Chat.tsx — real-time chat
'use client';

import { useChannel, useEvent } from '@/hooks/usePusher';
import { useState } from 'react';

interface Message {
  id: string;
  text: string;
  userId: string;
  userName: string;
  timestamp: string;
}

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

  // Listen for new messages
  useEvent<Message>(channel, 'new-message', (message) => {
    setMessages(prev => [...prev, message]);
  });

  // Listen for message deletions
  useEvent<{ messageId: string }>(channel, 'message-deleted', ({ messageId }) => {
    setMessages(prev => prev.filter(m => m.id !== messageId));
  });

  async function sendMessage(text: string) {
    await fetch('/api/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ roomId, text }),
    });
    // Server triggers Pusher event — will come back through the subscription
  }

  return (
    <div>
      <div>
        {messages.map(m => (
          <div key={m.id}>
            <strong>{m.userName}:</strong> {m.text}
          </div>
        ))}
      </div>
      <input
        onKeyDown={(e) => {
          if (e.key === 'Enter') {
            sendMessage(e.currentTarget.value);
            e.currentTarget.value = '';
          }
        }}
        placeholder="Type a message..."
      />
    </div>
  );
}

Fix 4: Server-Side Event Triggering

// app/api/messages/route.ts
import { pusher } from '@/lib/pusher-server';
import { auth } from '@/auth';

export async function POST(req: Request) {
  const session = await auth();
  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const { roomId, text } = await req.json();

  // Save to database
  const message = await db.insert(messages).values({
    id: crypto.randomUUID(),
    roomId,
    text,
    userId: session.user.id,
    userName: session.user.name ?? 'Anonymous',
    timestamp: new Date().toISOString(),
  }).returning();

  // Trigger Pusher event
  await pusher.trigger(`private-room-${roomId}`, 'new-message', message[0]);

  return Response.json(message[0]);
}

// Trigger from Server Actions
'use server';

export async function updateDocument(docId: string, content: string) {
  await db.update(documents).set({ content }).where(eq(documents.id, docId));

  // Notify all viewers
  await pusher.trigger(`private-doc-${docId}`, 'content-updated', {
    content,
    updatedBy: (await auth())?.user?.id,
    updatedAt: new Date().toISOString(),
  });
}

Fix 5: Connection Management

'use client';

import { pusherClient } from '@/lib/pusher-client';
import { useEffect, useState } from 'react';

function ConnectionStatus() {
  const [state, setState] = useState(pusherClient.connection.state);

  useEffect(() => {
    function handleStateChange(states: { current: string }) {
      setState(states.current);
    }

    pusherClient.connection.bind('state_change', handleStateChange);
    return () => {
      pusherClient.connection.unbind('state_change', handleStateChange);
    };
  }, []);

  const colors: Record<string, string> = {
    connected: 'green',
    connecting: 'yellow',
    disconnected: 'red',
    unavailable: 'red',
    failed: 'red',
  };

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
      <div style={{
        width: '8px', height: '8px', borderRadius: '50%',
        backgroundColor: colors[state] || 'gray',
      }} />
      <span>{state}</span>
    </div>
  );
}

Fix 6: Batch Events and Rate Limits

// Pusher limits: 10 channels per trigger, 10KB per event
// For larger payloads, send a reference and let clients fetch

// Instead of:
await pusher.trigger('channel', 'large-update', hugeDataObject);  // May exceed 10KB

// Do this:
await pusher.trigger('channel', 'data-updated', {
  type: 'document',
  id: 'doc-123',
  updatedAt: new Date().toISOString(),
});
// Client fetches the actual data via API when it receives the event

// Batch trigger — up to 10 events in one API call
await pusher.triggerBatch([
  { channel: 'private-user-1', name: 'notification', data: { message: 'Hello' } },
  { channel: 'private-user-2', name: 'notification', data: { message: 'World' } },
]);

Still Not Working?

Events triggered but client does not receive them — check that the channel name and event name match exactly between server and client. Also verify the cluster matches: PUSHER_CLUSTER on the server and NEXT_PUBLIC_PUSHER_CLUSTER on the client must be the same value (e.g., both us2).

Private channel subscription fails with 403 — the auth endpoint is rejecting the subscription. Check that your /api/pusher/auth route is accessible, that the user is authenticated, and that pusher.authorizeChannel() is called with the correct socketId and channelName.

Connection drops frequently — Pusher connections can drop due to network instability. The client auto-reconnects by default. If connections drop immediately, check that useTLS: true is set and that no firewall is blocking WebSocket connections on port 443. Enable debug logging: PusherClient.logToConsole = true.

Events received by the sender — by default, the triggering client also receives the event. To exclude the sender, pass the socket_id: pusher.trigger('channel', 'event', data, { socket_id: senderSocketId }). Get the socket ID from pusherClient.connection.socket_id on the client.

Auth endpoint is hit but channel still does not subscribe — verify that the response body matches Pusher’s expected shape exactly: { "auth": "<key>:<hmac>" } for private channels and { "auth": "...", "channel_data": "..." } for presence channels. Returning JSON.stringify of the wrong shape produces a silent failure on the client.

v7 auth: { endpoint } option ignored on v8+ — pusher-js v8 renamed the option to channelAuthorization. The old key is silently dropped. Search your code for auth: inside new Pusher(...) and migrate it.

Presence channel members is empty after subscribe — your auth endpoint did not return a channel_data field. Presence channels require user identity. The body should include channel_data: JSON.stringify({ user_id, user_info }). v8’s pusher.authorizeChannel(socketId, channelName, presenceData) handles this for you — call it instead of constructing the body manually.

Hitting concurrent-connection or message-rate limits — the free Pusher tier is small. Check the Pusher dashboard for limit warnings before assuming it is a code bug. For self-hosted parity, run Soketi locally with the same SDK and credentials.

For related real-time issues, see Fix: Liveblocks Not Working, Fix: Supabase Not Working, Fix: Socket.IO Not Connecting, and Fix: tRPC Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles