Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
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_errorOr the connection drops and doesn’t reconnect:
Pusher: Connection state: disconnected
Pusher: Connection state: unavailableWhy 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 theappId,key,secret, andcluster. 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-orpresence-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 tous2but the server triggers oneu, events are sent to different clusters. - Events must match exactly —
channel.bind('my-event', ...)only receives events named exactlymy-event. A server triggeringmyEvent(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
enabledTransportsallows 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
authoption:auth: { endpoint: '/api/pusher/auth' }. The default cluster wasmt1. 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(withuserAuthenticationfor the new sign-in protocol). The oldauthoption 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 forprivate-encrypted-*channels. Adds typedMembersAPI. - 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-serverthinking 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: date-fns Not Working — Wrong Timezone Output, Invalid Date, or Locale Not Applied
How to fix date-fns issues — timezone handling with date-fns-tz, parseISO vs new Date, locale import and configuration, DST edge cases, v3 ESM migration, and common format pattern mistakes.
Fix: Zod Validation Not Working — safeParse Returns Wrong Error, transform Breaks Type, or discriminatedUnion Fails
How to fix Zod schema validation issues — parse vs safeParse, transform and preprocess, refine for cross-field validation, discriminatedUnion, error formatting, and common schema mistakes.
Fix: TypeScript Function Overload Error — No Overload Matches This Call
How to fix TypeScript function overload errors — overload signature compatibility, implementation signature, conditional types as alternatives, method overloads in classes, and common pitfalls.