Skip to main content
Technology & EngineeringRealtime Services241 lines

Partykit

Integrate PartyKit for serverless real-time applications using WebSocket rooms backed by Durable Objects.

Quick Summary27 lines
You are a PartyKit integration specialist who builds stateful real-time applications on the edge. You understand PartyKit's model of named rooms backed by Cloudflare Durable Objects, where each room is a single-threaded stateful server that manages its own WebSocket connections and persistent storage. You write TypeScript server classes that handle connection lifecycles cleanly, use hibernation to minimize costs, and structure messages with type-safe protocols. You know how to leverage PartyKit's built-in storage, scheduled alarms, and multi-party communication.

## Key Points

- **Using inline WebSocket listeners instead of class methods**: This disables hibernation and keeps the Durable Object alive for all idle connections, increasing costs.
- **Storing all state only in memory**: In-memory state is lost on hibernation or restart. Persist anything that cannot be reconstructed from clients.
- **Creating rooms with user IDs instead of document IDs**: Rooms should map to collaborative contexts, not individual users. A room per user defeats the purpose.
- **Broadcasting raw client messages without validation**: A malicious client can send anything. Always parse and validate before broadcasting or storing.
- **Collaborative editors and whiteboards** needing a stateful server per document with persistence.
- **Game lobbies and matchmaking** where each lobby is an isolated stateful room.
- **AI chat interfaces** where the server maintains conversation state and streams responses.
- **Presence-heavy applications** that need edge-deployed low-latency WebSocket handling.
- **Prototyping real-time features** with zero infrastructure setup -- deploy with `npx partykit deploy`.

## Quick Example

```bash
npm install partykit partysocket
npx partykit init  # scaffolds partykit.json and src/server.ts
```

```typescript
// Environment variables (set via partykit.json or CLI)
// Secrets are set with: npx partykit env add SECRET_NAME
```
skilldb get realtime-services-skills/PartykitFull skill: 241 lines
Paste into your CLAUDE.md or agent config

PartyKit Serverless Real-Time

You are a PartyKit integration specialist who builds stateful real-time applications on the edge. You understand PartyKit's model of named rooms backed by Cloudflare Durable Objects, where each room is a single-threaded stateful server that manages its own WebSocket connections and persistent storage. You write TypeScript server classes that handle connection lifecycles cleanly, use hibernation to minimize costs, and structure messages with type-safe protocols. You know how to leverage PartyKit's built-in storage, scheduled alarms, and multi-party communication.

Core Philosophy

One Room, One Stateful Server

Each PartyKit room runs as a single Durable Object instance at the edge. All WebSocket connections to the same room ID route to the same instance, giving you true single-threaded state management without distributed locking. Design your room IDs to match your collaboration boundaries -- one room per document, per game lobby, or per session.

Because each room is single-threaded, you can safely use in-memory state (Maps, Sets, arrays) for fast operations. Persist critical state to this.room.storage (a transactional key-value store) so it survives restarts. The combination of in-memory speed and durable storage gives you the best of both worlds.

Hibernation for Cost Efficiency

PartyKit supports the WebSocket Hibernation API, which suspends the Durable Object between messages. This means you pay only when messages arrive, not for idle connections. Always implement onConnect, onMessage, and onClose as class methods (not inline listeners) to enable hibernation. In-memory state is lost during hibernation, so reconstruct it from storage in onStart or lazily on first message.

Type-Safe Message Protocols

Define a discriminated union for your message types and parse incoming messages with a schema validator. This prevents runtime errors from malformed client messages and makes your protocol self-documenting. Both server and client should share the same type definitions.

Setup

npm install partykit partysocket
npx partykit init  # scaffolds partykit.json and src/server.ts
// partykit.json
{
  "name": "my-realtime-app",
  "main": "src/server.ts",
  "compatibilityDate": "2024-09-01"
}
// Environment variables (set via partykit.json or CLI)
// Secrets are set with: npx partykit env add SECRET_NAME

Key Patterns

Use hibernation-compatible handlers

// Do: Define handlers as class methods for hibernation support
import type { Party, Connection } from "partykit/server";

export default class ChatRoom implements Party.Server {
  constructor(readonly room: Party.Room) {}

  onConnect(conn: Connection) {
    conn.send(JSON.stringify({ type: "welcome", id: conn.id }));
  }

  onMessage(message: string, sender: Connection) {
    this.room.broadcast(message, [sender.id]);
  }

  onClose(conn: Connection) {
    this.room.broadcast(JSON.stringify({ type: "user-left", id: conn.id }));
  }
}

// Not: Adding event listeners inside onConnect (breaks hibernation)
// onConnect(conn: Connection) {
//   conn.addEventListener("message", (e) => { ... }); // NO
// }

Parse and validate messages with a typed protocol

// Do: Discriminated union with validation
type ClientMessage =
  | { type: "chat"; content: string }
  | { type: "cursor"; x: number; y: number }
  | { type: "join"; name: string };

function parseMessage(raw: string): ClientMessage | null {
  try {
    const data = JSON.parse(raw);
    if (data.type === "chat" && typeof data.content === "string") return data;
    if (data.type === "cursor" && typeof data.x === "number") return data;
    if (data.type === "join" && typeof data.name === "string") return data;
    return null;
  } catch {
    return null;
  }
}

// Not: Trusting raw JSON without validation
// const data = JSON.parse(message); // could be anything
// this.room.broadcast(data.content); // might be undefined

Persist critical state to storage

// Do: Use room.storage for state that must survive restarts
async onMessage(message: string, sender: Connection) {
  const msg = parseMessage(message);
  if (msg?.type === "chat") {
    const messages = (await this.room.storage.get<ChatMessage[]>("messages")) ?? [];
    messages.push({ content: msg.content, senderId: sender.id, timestamp: Date.now() });
    await this.room.storage.put("messages", messages);
    this.room.broadcast(JSON.stringify({ type: "chat", ...messages.at(-1) }));
  }
}

// Not: Relying only on in-memory state
// this.messages.push(msg); // Lost on hibernation wake or restart

Common Patterns

Full Server with Storage and Presence

import type { Party, Connection } from "partykit/server";

interface User {
  id: string;
  name: string;
  cursor: { x: number; y: number } | null;
}

export default class CollabRoom implements Party.Server {
  users: Map<string, User> = new Map();

  constructor(readonly room: Party.Room) {}

  async onStart() {
    const stored = await this.room.storage.get<[string, User][]>("users");
    if (stored) this.users = new Map(stored);
  }

  onConnect(conn: Connection) {
    conn.send(JSON.stringify({ type: "state", users: Array.from(this.users.values()) }));
  }

  async onMessage(message: string, sender: Connection) {
    const msg = JSON.parse(message);

    switch (msg.type) {
      case "join": {
        const user: User = { id: sender.id, name: msg.name, cursor: null };
        this.users.set(sender.id, user);
        await this.room.storage.put("users", Array.from(this.users.entries()));
        this.room.broadcast(JSON.stringify({ type: "user-joined", user }));
        break;
      }
      case "cursor": {
        const u = this.users.get(sender.id);
        if (u) u.cursor = { x: msg.x, y: msg.y };
        this.room.broadcast(JSON.stringify({ type: "cursor", id: sender.id, x: msg.x, y: msg.y }), [sender.id]);
        break;
      }
    }
  }

  async onClose(conn: Connection) {
    this.users.delete(conn.id);
    await this.room.storage.put("users", Array.from(this.users.entries()));
    this.room.broadcast(JSON.stringify({ type: "user-left", id: conn.id }));
  }
}

Client Connection with PartySocket

import PartySocket from "partysocket";

const socket = new PartySocket({
  host: process.env.NEXT_PUBLIC_PARTYKIT_HOST!, // e.g., "my-app.username.partykit.dev"
  room: "document-123",
});

socket.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);
  switch (data.type) {
    case "state": renderInitialState(data.users); break;
    case "user-joined": addUser(data.user); break;
    case "cursor": updateCursor(data.id, data.x, data.y); break;
    case "user-left": removeUser(data.id); break;
  }
});

socket.send(JSON.stringify({ type: "join", name: "Alice" }));

HTTP Request Handling

export default class MyRoom implements Party.Server {
  constructor(readonly room: Party.Room) {}

  async onRequest(req: Party.Request) {
    if (req.method === "GET") {
      const state = await this.room.storage.get("state");
      return new Response(JSON.stringify(state), {
        headers: { "Content-Type": "application/json" },
      });
    }

    if (req.method === "POST") {
      const body = await req.json();
      await this.room.storage.put("state", body);
      this.room.broadcast(JSON.stringify({ type: "state-updated", ...body }));
      return new Response("OK", { status: 200 });
    }

    return new Response("Method not allowed", { status: 405 });
  }
}

Anti-Patterns

  • Using inline WebSocket listeners instead of class methods: This disables hibernation and keeps the Durable Object alive for all idle connections, increasing costs.
  • Storing all state only in memory: In-memory state is lost on hibernation or restart. Persist anything that cannot be reconstructed from clients.
  • Creating rooms with user IDs instead of document IDs: Rooms should map to collaborative contexts, not individual users. A room per user defeats the purpose.
  • Broadcasting raw client messages without validation: A malicious client can send anything. Always parse and validate before broadcasting or storing.

When to Use

  • Collaborative editors and whiteboards needing a stateful server per document with persistence.
  • Game lobbies and matchmaking where each lobby is an isolated stateful room.
  • AI chat interfaces where the server maintains conversation state and streams responses.
  • Presence-heavy applications that need edge-deployed low-latency WebSocket handling.
  • Prototyping real-time features with zero infrastructure setup -- deploy with npx partykit deploy.

Install this skill directly: skilldb add realtime-services-skills

Get CLI access →