Integrating WebSocket Services in a Serverless World
Connect to managed WebSocket providers since serverless functions cannot hold long-lived sockets.
Integrating WebSocket Services in a Serverless World is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 2 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.
Why Serverless and WebSockets Don't Mix
Serverless functions (like Next.js API routes deployed to Vercel, AWS Lambda, or similar platforms) are designed to be stateless and short-lived. Each invocation boots, handles a request, and terminates.
WebSockets, on the other hand, require a persistent, long-lived TCP connection between client and server. This creates a fundamental mismatch:
- Serverless runtimes have execution time limits (e.g. 10–30 seconds).
- There is no persistent process to hold an open socket across requests.
- Horizontal scaling means each new instance has no shared in-memory state.
The solution is to delegate real-time communication to a managed WebSocket provider — a dedicated service that maintains connections on your behalf while your serverless functions focus on business logic.
The Managed WebSocket Provider Pattern
Managed WebSocket providers expose an HTTP API that your serverless functions can call to push messages into persistent connections they maintain. Popular options include:
- Pusher / Pusher Channels — battle-tested, generous free tier, first-class Next.js support.
- Ably — low-latency pub/sub with edge-friendly token auth.
- Soketi — open-source, self-hosted Pusher-compatible server.
- PartyKit — built specifically for Next.js and Cloudflare Workers.
The typical flow is:
- Client connects to the provider's WebSocket endpoint and subscribes to a channel.
- Your Next.js Server Action or Route Handler calls the provider's REST API to publish an event.
- The provider fans the event out to all subscribed clients instantly.
Your serverless function never touches a socket directly — it simply makes an HTTP POST.
Setting Up Pusher in a Next.js 15 Project
Install both the server-side SDK (used in Server Actions / Route Handlers) and the client-side SDK (used in Client Components):
npm install pusher pusher-js
Add your Pusher credentials to .env.local. Never expose the secret key to the browser — prefix only the public values with NEXT_PUBLIC_:
// .env.local
PUSHER_APP_ID="your_app_id"
PUSHER_KEY="your_key"
PUSHER_SECRET="your_secret"
PUSHER_CLUSTER="eu"
NEXT_PUBLIC_PUSHER_KEY="your_key"
NEXT_PUBLIC_PUSHER_CLUSTER="eu"Creating a Singleton Pusher Server Instance
To avoid creating a new Pusher instance on every server-side call, export a singleton from a utility module. In Next.js 15 App Router the module is evaluated once per worker process (or edge invocation), so a module-level constant works perfectly.
This singleton is server-only — it imports the Node.js pusher package which must never reach the browser bundle. Mark the file accordingly with the Next.js convention.
// lib/pusher-server.ts
import Pusher from 'pusher';
// 'server-only' prevents accidental client-side imports
import 'server-only';
if (
!process.env.PUSHER_APP_ID ||
!process.env.PUSHER_KEY ||
!process.env.PUSHER_SECRET ||
!process.env.PUSHER_CLUSTER
) {
throw new Error('Missing Pusher environment variables');
}
export const pusherServer = 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,
});Publishing Events from a Server Action
Server Actions in Next.js 15 are perfect for triggering real-time events: the user submits a form, the action persists data, then immediately publishes to Pusher. All of this happens server-side — the client never sees the secret key.
The pusherServer.trigger(channel, event, data) call makes an HTTPS request to Pusher's API, which is well within serverless execution time limits.
// app/chat/actions.ts
'use server';
import { pusherServer } from '@/lib/pusher-server';
interface ChatMessage {
id: string;
user: string;
text: string;
sentAt: string;
}
export async function sendMessage(
channelName: string,
user: string,
text: string
): Promise<void> {
const message: ChatMessage = {
id: crypto.randomUUID(),
user,
text,
sentAt: new Date().toISOString(),
};
// Persist to your database here (e.g. Supabase, Prisma, etc.)
// await db.messages.create({ data: message });
// Publish to all subscribers on this channel
await pusherServer.trigger(channelName, 'new-message', message);
}Creating the Client-Side Pusher Hook
On the client, create a reusable React hook that wraps pusher-js. The hook should:
- Initialise the Pusher client once (via
useRefor module-level singleton). - Subscribe to the requested channel and event on mount.
- Clean up the subscription on unmount to prevent memory leaks.
Using a module-level singleton for the PusherClient instance avoids reconnecting every time the hook re-renders.
// 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! }
);
// hooks/usePusherChannel.ts
import { useEffect } from 'react';
import { pusherClient } from '@/lib/pusher-client';
export function usePusherChannel<T>(
channelName: string,
eventName: string,
onEvent: (data: T) => void
): void {
useEffect(() => {
const channel = pusherClient.subscribe(channelName);
channel.bind(eventName, onEvent);
return () => {
channel.unbind(eventName, onEvent);
pusherClient.unsubscribe(channelName);
};
}, [channelName, eventName, onEvent]);
}Building the Real-Time Chat Client Component
Wire the custom hook into a Client Component. The component renders an optimistic list of messages and uses the Server Action to send new ones. Incoming Pusher events are appended to state — no polling required.
Mark the component with 'use client' since it uses browser APIs (useState, useEffect) and the Pusher SDK.
'use client';
import { useState, useCallback, useTransition } from 'react';
import { usePusherChannel } from '@/hooks/usePusherChannel';
import { sendMessage } from './actions';
interface Message {
id: string;
user: string;
text: string;
sentAt: string;
}
export function ChatRoom({
channelName,
currentUser,
}: {
channelName: string;
currentUser: string;
}) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isPending, startTransition] = useTransition();
const handleNewMessage = useCallback((msg: Message) => {
setMessages((prev) => [...prev, msg]);
}, []);
usePusherChannel<Message>(channelName, 'new-message', handleNewMessage);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!input.trim()) return;
startTransition(() => sendMessage(channelName, currentUser, input));
setInput('');
}
return (
<div>
<ul>
{messages.map((m) => (
<li key={m.id}><strong>{m.user}:</strong> {m.text}</li>
))}
</ul>
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={isPending}>Send</button>
</form>
</div>
);
}Authenticating Private Channels
Public Pusher channels are open to anyone who knows the channel name. For private rooms or per-user data, use private channels (prefixed with private-). These require a server-side authentication step.
Pusher's client SDK automatically requests a token from an auth endpoint before subscribing. Create this endpoint as a Next.js Route Handler:
// app/api/pusher/auth/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pusherServer } from '@/lib/pusher-server';
import { auth } from '@/lib/auth'; // your session helper
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.text();
const params = new URLSearchParams(body);
const socketId = params.get('socket_id')!;
const channelName = params.get('channel_name')!;
// Optional: verify the user is allowed to join this channel
const authData = pusherServer.authorizeChannel(socketId, channelName, {
user_id: session.user.id,
user_info: { name: session.user.name },
});
return NextResponse.json(authData);
}Configuring the Client for Private Channels
Point the Pusher client SDK at your auth endpoint so it can obtain tokens before subscribing to private- channels. The channelAuthorization option specifies the URL and transport method.
Once configured, subscribing to a private channel looks identical to subscribing to a public one — the SDK handles the auth handshake transparently.
// lib/pusher-client.ts (updated)
import PusherClient from 'pusher-js';
export const pusherClient = new PusherClient(
process.env.NEXT_PUBLIC_PUSHER_KEY!,
{
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
channelAuthorization: {
endpoint: '/api/pusher/auth',
transport: 'ajax',
},
}
);
// Usage in a component — identical for public or private channels:
// usePusherChannel('private-room-42', 'new-message', handler);Triggering Events from a Route Handler
Server Actions are ideal for form submissions, but sometimes you need to trigger events from an external webhook — for example, a payment provider notifying your app that a transaction completed. In that case, use a Route Handler instead of a Server Action.
The pattern is the same: validate the incoming request, then call pusherServer.trigger() to fan out the event to connected clients.
// app/api/webhooks/payment/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pusherServer } from '@/lib/pusher-server';
interface PaymentPayload {
orderId: string;
status: 'paid' | 'failed';
userId: string;
}
export async function POST(req: NextRequest) {
// Validate webhook signature (provider-specific)
const signature = req.headers.get('x-webhook-signature');
if (!isValidSignature(signature, await req.text())) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const payload: PaymentPayload = await req.json();
// Notify the specific user on their private channel
await pusherServer.trigger(
`private-user-${payload.userId}`,
'payment-update',
{ orderId: payload.orderId, status: payload.status }
);
return NextResponse.json({ received: true });
}
function isValidSignature(sig: string | null, _body: string): boolean {
// Replace with real HMAC check for your provider
return sig !== null;
}Presence Channels: Knowing Who Is Online
Pusher presence channels (prefixed presence-) extend private channels with a member roster. Each authenticated subscriber is added to a shared list that all members can observe in real time.
This enables features like "3 people are viewing this document" or live user cursors. Key events fired by Pusher on the client:
pusher:subscription_succeeded— initial member list.pusher:member_added— a new user joined.pusher:member_removed— a user disconnected.
The auth endpoint is reused; the user data you pass in user_info becomes the member object visible to all subscribers — so only include non-sensitive fields like display names or avatars.
Knowledge Check: Triggering Events from Serverless
A Next.js 15 application is deployed to Vercel (serverless). A Server Action must notify all connected clients whenever a user submits a form. Which approach is correct?
Lesson Recap: WebSockets in a Serverless World
Here is what you learned in this lesson:
- Serverless and long-lived sockets are incompatible — functions terminate too quickly to hold WebSocket connections.
- Managed providers (Pusher, Ably, PartyKit) maintain persistent connections on your behalf. Your serverless code communicates with them via standard HTTPS calls.
- Server singleton — export a single
Pusherinstance from aserver-onlymodule to prevent accidental client-side inclusion of your secret key. - Server Actions and Route Handlers both call
pusherServer.trigger(channel, event, data)to publish events after business logic completes. - Private channels require a
/api/pusher/authRoute Handler that validates the user session before issuing a subscription token. - Presence channels add a live member roster on top of private channel security, enabling online indicators and collaborative features.
- Client hook — wrap
pusher-jsin ausePusherChannelhook that subscribes on mount and cleans up on unmount.
This pattern gives you reliable, scalable real-time features without managing any WebSocket infrastructure yourself.
Frequently asked questions
Is the “Integrating WebSocket Services in a Serverless World” lesson free?
Yes — the full text of “Integrating WebSocket Services in a Serverless World” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.
What will I learn in “Integrating WebSocket Services in a Serverless World”?
Connect to managed WebSocket providers since serverless functions cannot hold long-lived sockets. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.
Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?
No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 2 of 4, so you can start here or from the beginning and move at your own pace.
How long does the “Integrating WebSocket Services in a Serverless World” lesson take?
Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.
Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?
Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.
All lessons in this course
- Server-Sent Events from Route Handlers
- Integrating WebSocket Services in a Serverless World
- Streaming AI Responses Token-by-Token
- Presence, Cursors, and Live Collaboration State