Building Real-Time Apps with PartyKit
Imagine building a multiplayer game, a live chat, or a collaborative drawing board without wrestling with WebSocket servers, scaling headaches, or custom state synchronization. That’s the promise of PartyKit—a lightweight, server‑less platform that lets you focus on the user experience while it handles the heavy lifting of real‑time communication. In this guide we’ll peel back the layers of PartyKit, walk through two complete examples, and sprinkle in pro tips that will keep your app fast, secure, and ready for production.
What is PartyKit?
PartyKit is a managed real‑time backend that abstracts WebSocket connections into “rooms”. Each room runs a tiny JavaScript sandbox where you can write server‑side logic that reacts to messages, updates shared state, and broadcasts to all participants. Because PartyKit runs on a global edge network, latency stays low no matter where your users are located.
Key features include:
- Automatic scaling – rooms spin up on demand and shut down when idle.
- Built‑in presence tracking – know who is online, when they join, and when they leave.
- Typed messages – enforce a contract between client and server for safer code.
- Server‑side persistence – optional KV store for short‑term data.
From a developer’s perspective, PartyKit feels like a blend of Firebase Realtime Database and Socket.io, but with a far smaller footprint and tighter control over the execution environment.
Getting Started
First, sign up at partykit.io and grab your API key. Then install the CLI, which will scaffold a new project and let you deploy rooms with a single command.
# Install the PartyKit CLI (Node.js required)
npm install -g @partykit/cli
# Create a new project called "realtime-demo"
party init realtime-demo
# Navigate into the project and start the dev server
cd realtime-demo
party dev
The party dev command spins up a local server that mimics the edge environment, letting you iterate quickly. When you’re ready, push your code to the cloud with party deploy. The CLI also generates a TypeScript definition file for your room, which you can import into your front‑end for type‑safe messaging.
Real‑Time Chat: A Minimal Working Example
Let’s build the classic “real‑time chat” app. It’s simple enough to fit in a few dozen lines, yet it showcases the core PartyKit concepts: room lifecycle, message handling, and broadcasting.
Server‑side Room Logic
Create a file named chat.room.ts inside the src folder. The exported class implements three lifecycle hooks: onConnect, onMessage, and onClose.
import { Party, PartySocket } from "partykit/server";
interface ChatMessage {
type: "chat";
payload: {
user: string;
text: string;
};
}
export default class ChatRoom extends Party {
// Store a simple array of recent messages (in‑memory, per room)
private history: ChatMessage["payload"][] = [];
// Called when a client opens a WebSocket connection
async onConnect(socket: PartySocket) {
// Send the existing chat history to the newcomer
socket.send({
type: "history",
payload: this.history,
});
}
// Called for every incoming message from any client
async onMessage(message: any, socket: PartySocket) {
// Validate the shape of the message (basic runtime check)
if (message.type !== "chat" || !message.payload?.text) {
socket.send({ type: "error", payload: "Invalid message format" });
return;
}
const chat: ChatMessage["payload"] = {
user: message.payload.user || "Anonymous",
text: message.payload.text,
};
// Append to history (keep only last 100 messages)
this.history.push(chat);
if (this.history.length > 100) this.history.shift();
// Broadcast the new chat line to everyone in the room
this.broadcast({
type: "chat",
payload: chat,
});
}
// Optional: clean up when the last client disconnects
async onClose() {
// Persist the history to a KV store if you need durability
}
}
Notice how the room’s state lives only for the duration of active connections. When the last participant leaves, PartyKit automatically tears down the sandbox, freeing resources.
Front‑End Integration
On the client side we’ll use the native WebSocket API. The example below is plain JavaScript, but you can easily adapt it to React, Vue, or Svelte.
const socket = new WebSocket("wss://realtime-demo.partykit.dev/chat");
// UI helpers (assume you have #messages and #input elements)
function addMessage({ user, text }) {
const el = document.createElement("li");
el.textContent = `${user}: ${text}`;
document.getElementById("messages").appendChild(el);
}
// Handle incoming messages
socket.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.type === "history") {
data.payload.forEach(addMessage);
} else if (data.type === "chat") {
addMessage(data.payload);
} else if (data.type === "error") {
console.error("Server error:", data.payload);
}
});
// Send a chat line when the user hits Enter
document.getElementById("input").addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const text = e.target.value.trim();
if (!text) return;
socket.send(
JSON.stringify({
type: "chat",
payload: { user: "Me", text },
})
);
e.target.value = "";
});
That’s it—open the page in two tabs, type a message, and watch it appear instantly on both sides. PartyKit takes care of routing, scaling, and reconnection logic behind the scenes.
Pro tip: Use socket.binaryType = "arraybuffer" if you plan to send binary payloads (e.g., images). PartyKit’s broadcast API works with both JSON and binary data.
Collaborative Whiteboard: Going Beyond Text
Text chat is a great starter, but many real‑time apps involve richer data—think drawing, game state, or live code editing. Let’s build a minimal collaborative whiteboard where users can draw lines with their mouse, and everyone sees the strokes instantly.
Room Logic for Drawing
We’ll store an array of line segments. Each segment contains start/end coordinates and a color. The room simply relays new strokes to all participants.
import { Party, PartySocket } from "partykit/server";
interface Stroke {
type: "stroke";
payload: {
from: { x: number; y: number };
to: { x: number; y: number };
color: string;
width: number;
};
}
export default class WhiteboardRoom extends Party {
private strokes: Stroke["payload"][] = [];
async onConnect(socket: PartySocket) {
// Send existing strokes so newcomers see the full canvas
socket.send({ type: "init", payload: this.strokes });
}
async onMessage(message: any, socket: PartySocket) {
if (message.type !== "stroke") {
socket.send({ type: "error", payload: "Unsupported message type" });
return;
}
// Add to the master list and broadcast
this.strokes.push(message.payload);
this.broadcast({ type: "stroke", payload: message.payload });
}
}
Because the state lives in memory, the whiteboard resets when the room becomes idle. For persistent canvases you can sync this.strokes to PartyKit’s KV store inside onClose and hydrate it in onConnect.
Canvas Front‑End
Below is a compact HTML/JS snippet that draws on a <canvas> element and streams each line to the server. It also listens for remote strokes and renders them in real time.
const canvas = document.getElementById("board");
const ctx = canvas.getContext("2d");
const socket = new WebSocket("wss://realtime-demo.partykit.dev/whiteboard");
// Resize canvas to fill the window
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener("resize", resize);
resize();
let drawing = false;
let lastPos = { x: 0, y: 0 };
const color = "#"+Math.floor(Math.random()*16777215).toString(16);
const width = 3;
// Helper to draw a line segment
function drawLine({ from, to, color, width }) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
// Mouse events
canvas.addEventListener("mousedown", (e) => {
drawing = true;
lastPos = { x: e.offsetX, y: e.offsetY };
});
canvas.addEventListener("mousemove", (e) => {
if (!drawing) return;
const newPos = { x: e.offsetX, y: e.offsetY };
const stroke = { from: lastPos, to: newPos, color, width };
drawLine(stroke);
socket.send(JSON.stringify({ type: "stroke", payload: stroke }));
lastPos = newPos;
});
canvas.addEventListener("mouseup", () => (drawing = false));
canvas.addEventListener("mouseleave", () => (drawing = false));
// Incoming strokes
socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
if (msg.type === "init") {
msg.payload.forEach(drawLine);
} else if (msg.type === "stroke") {
drawLine(msg.payload);
}
});
Open the page on two devices, start drawing, and watch the lines appear instantly on both screens. Because each stroke is a tiny JSON payload, bandwidth stays low even with dozens of concurrent artists.
Pro tip: Throttle themousemovehandler (e.g., usingrequestAnimationFrame) to avoid flooding the network with too many messages during fast drawing.
Scaling & Performance Considerations
PartyKit’s edge deployment already gives you sub‑30 ms latency for most regions, but there are patterns you can adopt to keep latency and costs in check as your user base grows.
- Stateless rooms: Keep per‑room state minimal. If you need large data structures, store them in the KV store or an external database.
- Message batching: For high‑frequency events (e.g., game physics), batch multiple updates into a single message before sending.
- Room sharding: Split heavy workloads across multiple room IDs (e.g., one room per game lobby) to avoid a single sandbox becoming a bottleneck.
- Client reconnection: Leverage PartyKit’s built‑in reconnection logic; on reconnect the client automatically receives the latest state via
onConnect.
Monitoring is also straightforward. The PartyKit dashboard shows active rooms, message rates, and error logs. Set up alerts for spikes in onMessage latency to catch performance regressions early.
Security & Authentication
Real‑time apps often need to know who a user is before allowing them to send or receive data. PartyKit integrates cleanly with any JWT‑based auth system.
Embedding User Info in the Connection
When you create a WebSocket, you can pass an Authorization header containing a signed JWT. The room can then decode the token and attach the user object to the socket.
import jwt from "jsonwebtoken";
export default class SecureRoom extends Party {
async onConnect(socket: PartySocket) {
const token = socket.request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
socket.close(4001, "Missing auth token");
return;
}
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
// Attach user info to the socket for later use
(socket as any).user = payload;
socket.send({ type: "welcome", payload: { user: payload.username } });
} catch (e) {
socket.close(4002, "Invalid token");
}
}
async onMessage(message: any, socket: PartySocket) {
const user = (socket as any).user?.username ?? "guest";
// Use `user` to enforce permissions, rate limits, etc.
}
}
On the client, generate the JWT using your authentication provider (e.g., Auth0, Firebase, or a custom backend) and attach it when opening the socket.
const token = await fetch("/api/get-jwt").then(r => r.text());
const socket = new WebSocket("wss://myapp.partykit.dev/secure", [], {
headers: { Authorization: `Bearer ${token}` },
});
Because the token is verified on each connection, you can safely trust the socket.user object throughout the room’s lifecycle.
Pro tip: Rotate your JWT secret regularly and set short expiration times (e.g., 15 minutes). Pair this with refresh tokens on your auth server for seamless user experiences.
Presence & User List Management
Many collaborative apps need to display who’s online. PartyKit provides a built‑in socket.presence API that automatically broadcasts join/leave events to all participants.
export default class PresenceRoom extends Party {
async onConnect(socket: PartySocket) {
// Mark this socket as “present” with a custom payload
socket.presence.set({ username: "User_" + Math.floor(Math.random() * 1000) });
// Send the current presence map to the newcomer
socket.send({
type: "presence",
payload: await this.presence.getAll(),
});
}
async onClose(socket: PartySocket) {
// Presence is automatically cleared on disconnect
}
}
The client receives a presence message whenever the list changes, allowing you to render a live participant roster.
socket.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
if (data.type === "presence") {
const list = Object.values(data.payload).map(p => p.username);
document.getElementById("users").textContent = list.join(", ");
}
});
Testing & Debugging Real‑Time Logic
Because PartyKit rooms run in a sandboxed JavaScript environment, you can unit‑test them with any Node test runner (Jest, Vitest, etc.). Export the room class, instantiate it, and mock the PartySocket interface.
import { describe, it, expect, vi } from "vitest";
import ChatRoom from "./chat.room";
class MockSocket {
messages = [];
send(msg) { this.messages.push(msg); }
}
describe("ChatRoom", () => {
it("broadcasts a new chat line", async () => {
const room = new ChatRoom();
const socket = new MockSocket();
await room.onConnect(socket);
await room.onMessage(
{ type: "chat", payload: { user: "Alice", text: "Hi!" } },
socket
);
// The mock socket should have received the broadcast