Skip to content

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

FixDevs ·

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 is a hosted WebSocket service for real-time communication. Issues typically come from:

  • 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) doesn’t match.

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 doesn’t 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.

For related real-time issues, see Fix: Liveblocks Not Working and Fix: Supabase 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