How to Implement WebSocket Servers in workerd: A Complete Guide

To implement a WebSocket server in workerd, detect the upgrade request, return a 101 response with webSocket: true, call accept() on the resulting WebSocket object, and use send() and receive() for bidirectional communication.

The workerd runtime (Cloudflare's open-source Workers platform) enables full-duplex WebSocket connections directly within HTTP request handlers. This guide covers the complete implementation pattern, from basic upgrade handling to advanced hibernation features, based on the actual source code in the cloudflare/workerd repository.

The WebSocket Upgrade Flow in workerd

The architecture follows a strict state machine defined in src/workerd/api/web-socket.c++. When a client requests a WebSocket upgrade, workerd processes the connection through three distinct phases:

  1. Detection – Identify the Upgrade: websocket header in the incoming request
  2. Acceptance – Return a 101 response via Response.acceptWebSocket() to obtain the server-side WebSocket object
  3. Activation – Call ws.accept() to transition from AwaitingConnection to Accepted state before any I/O

The HTTP-to-WebSocket upgrade logic resides in src/workerd/api/http.c++ (around line 1213), where Response.acceptWebSocket() handles the protocol switch and returns the socket instance.

Basic WebSocket Server Implementation

Detecting Upgrade Requests

Check the Upgrade header to identify WebSocket handshake requests. While the C++ API provides headers.isWebSocket(), the JavaScript surface requires manual header inspection:

export default {
  async fetch(request, env, ctx) {
    const upgrade = request.headers.get('Upgrade');
    if (!upgrade || upgrade.toLowerCase() !== 'websocket') {
      return new Response('Expected WebSocket', { status: 400 });
    }
    // Proceed with upgrade...
  }
};

Accepting the Connection

Create a 101 response with webSocket: true to trigger the upgrade mechanism. The respondWith pattern returns a Response containing the webSocket property:

export default {
  async fetch(request, env, ctx) {
    if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
      return new Response('Not a WebSocket request', { status: 400 });
    }

    // Create the upgrade response
    const response = new Response(null, {
      status: 101,
      webSocket: true
    });
    
    // Obtain the server-side WebSocket object
    const ws = (await request.respondWith(response)).webSocket;
    
    // Mandatory: accept before any I/O
    ws.accept();
    
    // Now ready for bidirectional communication
    return new Response(null, { status: 101 });
  }
};

According to the implementation in src/workerd/api/web-socket.c++, calling ws.accept() transitions the internal state machine from AwaitingConnection to Accepted. Attempting to send or receive before this call throws an error.

Handling Messages

Use ws.receive() to read messages and ws.send() to transmit data. The receive() method resolves with either a string, a Uint8Array (for binary frames), or null when the client closes the connection:

export default {
  async fetch(request, env, ctx) {
    const upgrade = request.headers.get('Upgrade');
    if (upgrade?.toLowerCase() !== 'websocket') {
      return new Response('Expected WebSocket', { status: 400 });
    }

    const ws = (await request.respondWith(
      new Response(null, { status: 101, webSocket: true })
    )).webSocket;
    
    ws.accept();
    
    try {
      while (true) {
        const message = await ws.receive();
        if (message === null) break; // Connection closed
        
        // Echo server example
        await ws.send(`Server received: ${message}`);
      }
    } finally {
      await ws.close(1000, 'Normal closure');
    }
    
    return new Response(null, { status: 101 });
  }
};

The WebSocket::receive() implementation in src/workerd/api/web-socket.c++ polls the underlying kj::WebSocket and wraps the result in V8 promises.

Advanced Patterns

Coupling WebSockets for Proxying

Forward traffic between two sockets using WebSocket.couple(), implemented in src/workerd/api/web-socket.c++ (lines 19-42). This creates bidirectional pumps that run until both connections close:

export default {
  async fetch(request, env, ctx) {
    // Accept client connection
    const clientWs = (await request.respondWith(
      new Response(null, { status: 101, webSocket: true })
    )).webSocket;
    clientWs.accept();

    // Connect to backend
    const backendResp = await fetch('wss://backend.example.com/socket', {
      headers: { 'Upgrade': 'websocket' }
    });
    const backendWs = backendResp.webSocket;
    backendWs.accept();

    // Pipe both directions (returns when either side closes)
    await clientWs.couple(backendWs, request);
    
    return new Response(null, { status: 101 });
  }
};

WebSocket Hibernation

For long-running connections in Durable Objects, enable hibernation to allow the actor to be evicted while maintaining the connection. Set the hibernatable option when accepting:

export class ChatRoom {
  async fetch(request) {
    const ws = (await request.respondWith(
      new Response(null, { status: 101, webSocket: true })
    )).webSocket;
    
    // Enable hibernation support
    ws.accept({ allowHalfOpen: true, hibernatable: true });
    
    // Store in Durable Object state for persistence across hibernation
    this.state.acceptWebSocket(ws);
    
    return new Response(null, { status: 101 });
  }
}

The hibernation pathway is managed in src/workerd/io/hibernation-manager.c++, specifically in HibernationManagerImpl::acceptWebSocket.

Testing with WebSocketPair

For unit testing, use WebSocketPair to create linked client-server sockets without network overhead:

import { WebSocketPair } from 'workerd';

const [client, server] = new WebSocketPair();
server.accept();

await client.send('test message');
const received = await server.receive();
console.assert(received === 'test message');

See src/workerd/api/tests/websocket-constructor-test.js for comprehensive test patterns.

Durable Objects and WebSocket Servers

The canonical example in samples/durable-objects-chat/chat.js demonstrates stateful WebSocket management. In Durable Objects, use state.acceptWebSocket() to register the socket with the object's hibernation manager:

export class ChatRoom {
  constructor(state, env) {
    this.state = state;
    this.clients = new Set();
  }

  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === '/websocket') {
      const ws = (await request.respondWith(
        new Response(null, { status: 101, webSocket: true })
      )).webSocket;
      
      ws.accept();
      this.clients.add(ws);
      
      // Handle messages and broadcast
      this.handleSession(ws);
      
      return new Response(null, { status: 101 });
    }
    return new Response('Not found', { status: 404 });
  }
  
  async handleSession(ws) {
    try {
      while (true) {
        const msg = await ws.receive();
        if (msg === null) break;
        
        // Broadcast to all connected clients
        const promises = Array.from(this.clients).map(client => 
          client.send(msg)
        );
        await Promise.all(promises);
      }
    } finally {
      this.clients.delete(ws);
    }
  }
}

Summary

  • Detect upgrades by checking the Upgrade: websocket header in incoming requests
  • Create the WebSocket by returning a 101 response with webSocket: true and accessing the .webSocket property from respondWith()
  • Activate the socket by calling ws.accept() before any I/O operations
  • Use couple() to proxy traffic between two WebSockets efficiently
  • Enable hibernation with ws.accept({ hibernatable: true }) for long-lived Durable Object connections
  • Reference key files: src/workerd/api/web-socket.c++ for the state machine, src/workerd/api/http.c++ for upgrade handling, and src/workerd/io/hibernation-manager.c++ for persistence logic

Frequently Asked Questions

How do I detect a WebSocket upgrade request in workerd?

Check the Upgrade header for the value websocket. While the internal C++ API provides headers.isWebSocket(), the JavaScript environment requires manual inspection: request.headers.get('Upgrade')?.toLowerCase() === 'websocket'. This detection happens before calling Response.acceptWebSocket() in src/workerd/api/http.c++.

What is the difference between ws.accept() and Response.acceptWebSocket()?

Response.acceptWebSocket() (called implicitly when creating a 101 response with webSocket: true) returns the WebSocket object and performs the HTTP upgrade handshake. ws.accept() is called on that socket instance to transition the internal state machine from AwaitingConnection to Accepted, enabling send() and receive() operations. The first handles the protocol layer; the second handles the application state.

Can I implement a WebSocket proxy server in workerd?

Yes. Accept the client WebSocket with Response.acceptWebSocket(), create a backend WebSocket via fetch() to another WebSocket endpoint, then call clientWs.couple(backendWs, request). This method, implemented in src/workerd/api/web-socket.c++, sets up bidirectional pumps that forward frames between the sockets until either side closes.

How does WebSocket hibernation work in workerd?

Hibernation allows Durable Objects to be evicted from memory while keeping WebSocket connections alive. Pass { hibernatable: true } to ws.accept(), then register the socket with state.acceptWebSocket(). The runtime stores the connection in HibernationManagerImpl (defined in src/workerd/io/hibernation-manager.c++) and restores the object when messages arrive, enabling scalable long-running connections without resident memory costs.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →