Skip to main content

Overview

WebSocket transport multiplexes multiple HTTP-like requests over a single WebSocket connection, eliminating sub-request limits when using the SDK inside Cloudflare Workers or Durable Objects.

Why WebSocket transport?

Cloudflare Workers and Durable Objects have sub-request limits:
  • Workers: 1,000 sub-requests per request
  • Durable Objects: 1,000 sub-requests per Durable Object instance
Each SDK operation (exec, file read, process management) typically makes 1-3 HTTP requests. Complex workflows can quickly exhaust these limits. WebSocket transport solves this by:
  • Using only 1 sub-request for the WebSocket upgrade
  • Multiplexing unlimited operations over that single connection
  • Maintaining request/response semantics for easy migration

Enabling WebSocket transport

Basic setup

import { getSandbox } from '@cloudflare/sandbox';

export default {
  async fetch(request: Request, env: Env) {
    const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
      useWebSocket: true // Enable WebSocket transport
    });

    // All operations now use WebSocket
    await sandbox.exec('npm install');
    await sandbox.exec('npm test');
    
    return new Response('OK');
  }
};

Inside a Durable Object

import { Sandbox, getSandbox } from '@cloudflare/sandbox';

export class MyDurableObject extends DurableObject {
  async fetch(request: Request) {
    const sandbox = getSandbox(this.env.SANDBOX, 'do-sandbox', {
      useWebSocket: true
    });

    // Run many operations without hitting sub-request limits
    for (let i = 0; i < 100; i++) {
      await sandbox.exec(`echo "Operation ${i}"`);
    }

    return new Response('Completed 100 operations');
  }
}

How it works

Connection lifecycle

  1. Upgrade request: SDK sends HTTP upgrade request to container
  2. WebSocket established: Container accepts and maintains connection
  3. Request multiplexing: SDK sends JSON-encoded requests with unique IDs
  4. Response matching: Container sends responses with matching IDs
  5. Cleanup: Connection closes when sandbox is destroyed or explicitly disconnected

Request/response protocol

Requests and responses use a simple JSON protocol: Request format:
{
  type: 'request',
  id: 'req-abc123',      // Unique request ID
  method: 'POST',         // HTTP method
  path: '/exec',          // API endpoint
  body: { command: 'ls' } // Request payload
}
Response format:
{
  type: 'response',
  id: 'req-abc123',  // Matches request ID
  status: 200,       // HTTP status
  body: { ... },     // Response payload
  done: true         // Final response for this request
}
Stream chunk format:
{
  type: 'stream',
  id: 'req-abc123',
  event: 'log',        // Optional event type
  data: '{"output": "line"}' // SSE-format data
}

Concurrent requests

Multiple requests can be in-flight simultaneously:
// All three requests share the same WebSocket connection
const [result1, result2, result3] = await Promise.all([
  sandbox.exec('npm install'),
  sandbox.exec('npm test'),
  sandbox.exec('npm run build')
]);
Each request gets a unique ID and responses are matched accordingly.

Connection management

Automatic connection

Connections are established automatically on first use:
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});

// Connection established here (on first operation)
await sandbox.exec('echo "Hello"');

// Reuses existing connection
await sandbox.exec('echo "World"');

Explicit connection control

For advanced use cases, manually control connections:
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});

// Establish connection early
await sandbox.client.transport.connect();

// Perform operations
await sandbox.exec('command1');
await sandbox.exec('command2');

// Disconnect when done
sandbox.client.transport.disconnect();

Connection sharing

Multiple operations automatically share the same connection:
// Only ONE WebSocket connection is used
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});

// All operations share the connection
await sandbox.readFile('/file1.txt');
await sandbox.writeFile('/file2.txt', 'data');
await sandbox.exec('ls');
const processes = await sandbox.listProcesses();

Streaming over WebSocket

Streaming operations work seamlessly:
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});

const process = await sandbox.startProcess({
  command: 'npm run dev'
});

// Stream logs over WebSocket
for await (const log of process.logs()) {
  console.log(log.output);
}
The WebSocket transport converts stream chunks to SSE format for compatibility with existing code.

Error handling

Connection errors

Handle connection failures gracefully:
try {
  const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
    useWebSocket: true,
    containerTimeouts: {
      connect: 30000 // 30 second connection timeout
    }
  });

  await sandbox.exec('echo "test"');
} catch (error) {
  if (error.message.includes('WebSocket')) {
    console.error('Failed to establish WebSocket connection');
    // Fallback to HTTP or retry
  }
}

Request timeouts

Configure request-level timeouts:
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true,
  containerTimeouts: {
    request: 120000 // 2 minute request timeout
  }
});

try {
  await sandbox.exec('long-running-command');
} catch (error) {
  if (error.message.includes('timeout')) {
    console.error('Request timed out');
  }
}

Connection loss

When the WebSocket closes unexpectedly, pending requests are rejected:
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});

try {
  // Long-running operation
  await sandbox.exec('sleep 300');
} catch (error) {
  if (error.message.includes('WebSocket closed')) {
    console.error('Connection lost during operation');
    // Reconnect and retry
  }
}

Performance characteristics

Latency

WebSocket transport has similar latency to HTTP for individual requests:
  • First request: +10-30ms (connection establishment)
  • Subsequent requests: Similar to HTTP (no additional overhead)

Throughput

WebSocket excels with many small requests:
// HTTP: 100 sub-requests
for (let i = 0; i < 100; i++) {
  await sandbox.exec(`echo ${i}`);
}

// WebSocket: 1 sub-request (upgrade) + 100 multiplexed operations
const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true
});
for (let i = 0; i < 100; i++) {
  await sandbox.exec(`echo ${i}`);
}

Memory usage

WebSocket maintains a connection with pending request tracking:
  • Per connection: ~2-5 KB base overhead
  • Per pending request: ~200 bytes
  • Stream buffers: ~16 KB per active stream

Comparison with HTTP transport

FeatureHTTP TransportWebSocket Transport
Sub-requests used1-3 per operation1 total (upgrade)
Connection overheadNoneInitial upgrade (~20ms)
Concurrent requestsSupportedSupported
StreamingServer-Sent EventsWebSocket messages
Best forSimple workflowsComplex workflows, DO context
MemoryLowerSlightly higher

When to use WebSocket transport

✅ Use WebSocket when:

  • Running inside a Durable Object with many operations
  • Executing complex workflows with 50+ SDK calls
  • Approaching Worker sub-request limits
  • Long-running processes with streaming output
  • Batch operations across multiple files/commands

❌ Use HTTP when:

  • Simple one-off operations (1-10 SDK calls)
  • Running outside Workers (Node.js, browser)
  • Debugging (easier to inspect HTTP traffic)
  • Maximum compatibility (no WebSocket support needed)

Configuration options

Transport-specific options

const sandbox = getSandbox(env.SANDBOX, 'my-sandbox', {
  useWebSocket: true,
  containerTimeouts: {
    connect: 30000,  // WebSocket connection timeout (ms)
    request: 120000  // Individual request timeout (ms)
  }
});

Fallback to HTTP

Implement fallback logic for maximum reliability:
async function createSandbox(env: Env, id: string) {
  try {
    // Try WebSocket first
    return getSandbox(env.SANDBOX, id, { useWebSocket: true });
  } catch (error) {
    console.warn('WebSocket failed, falling back to HTTP');
    // Fallback to HTTP
    return getSandbox(env.SANDBOX, id, { useWebSocket: false });
  }
}

Advanced patterns

Connection pooling

Reuse connections across multiple operations:
class SandboxPool {
  private sandbox: Sandbox;

  constructor(env: Env) {
    this.sandbox = getSandbox(env.SANDBOX, 'pooled', {
      useWebSocket: true
    });
  }

  async execute(command: string) {
    // All operations share one WebSocket connection
    return await this.sandbox.exec(command);
  }

  disconnect() {
    this.sandbox.client.transport.disconnect();
  }
}

Request prioritization

Implement priority queues over a single connection:
class PriorityQueue {
  private high: Array<() => Promise<any>> = [];
  private low: Array<() => Promise<any>> = [];

  async process(sandbox: Sandbox) {
    // Process high priority first
    while (this.high.length > 0) {
      const task = this.high.shift()!;
      await task();
    }

    // Then low priority
    while (this.low.length > 0) {
      const task = this.low.shift()!;
      await task();
    }
  }

  addHigh(task: () => Promise<any>) {
    this.high.push(task);
  }

  addLow(task: () => Promise<any>) {
    this.low.push(task);
  }
}

Batch operations

Execute many operations efficiently:
const sandbox = getSandbox(env.SANDBOX, 'batch', {
  useWebSocket: true
});

const files = ['file1.txt', 'file2.txt', 'file3.txt', /* ... 100 files */];

// Process all files over one WebSocket connection
const results = await Promise.all(
  files.map(file => sandbox.readFile(file))
);

Debugging

Enable debug logging

import { createLogger } from '@repo/shared';

const logger = createLogger({ 
  level: 'debug',
  component: 'websocket'
});

const sandbox = getSandbox(env.SANDBOX, 'debug', {
  useWebSocket: true,
  logger: logger
});

// Logs all WebSocket messages
await sandbox.exec('echo "test"');

Monitor connection state

const transport = sandbox.client.transport;

if (transport.isConnected()) {
  console.log('WebSocket connected');
} else {
  console.log('WebSocket disconnected');
}

Inspect message flow

Log request/response pairs for debugging:
class DebugTransport {
  async fetch(path: string, options?: RequestInit) {
    console.log('→ Request:', { path, options });
    const response = await this.transport.fetch(path, options);
    console.log('← Response:', { status: response.status });
    return response;
  }
}

Troubleshooting

WebSocket upgrade fails

Verify the container supports WebSocket:
try {
  const sandbox = getSandbox(env.SANDBOX, 'test', {
    useWebSocket: true
  });
  await sandbox.exec('echo "test"');
} catch (error) {
  console.error('Upgrade failed:', error.message);
  // Fall back to HTTP
}

Messages out of order

Responses may arrive out of order, but they’re matched by ID:
// Request 1 may complete after Request 2
const [r1, r2] = await Promise.all([
  sandbox.exec('sleep 5 && echo "first"'),  // Slow
  sandbox.exec('echo "second"')              // Fast
]);

console.log(r1.stdout); // "first" (even though it finished last)
console.log(r2.stdout); // "second"

Connection drops under load

Increase timeouts for high-concurrency scenarios:
const sandbox = getSandbox(env.SANDBOX, 'heavy-load', {
  useWebSocket: true,
  containerTimeouts: {
    connect: 60000,  // 1 minute
    request: 300000  // 5 minutes
  }
});

Memory leaks with streaming

Always consume or cancel streams:
const process = await sandbox.startProcess({ command: 'npm run dev' });

try {
  for await (const log of process.logs()) {
    console.log(log.output);
    if (shouldStop) break;
  }
} finally {
  // Clean up the stream
  await sandbox.killProcess(process.id);
}

Implementation details

For contributors and advanced users:

Transport abstraction

Both HTTP and WebSocket transports implement ITransport:
packages/sandbox/src/clients/transport/types.ts
interface ITransport {
  fetch(path: string, options?: RequestInit): Promise<Response>;
  fetchStream(path: string, body?: unknown, method?: 'GET' | 'POST'): Promise<ReadableStream<Uint8Array>>;
  getMode(): TransportMode;
  connect(): Promise<void>;
  disconnect(): void;
  isConnected(): boolean;
}

Connection establishment

WebSocket upgrade uses different mechanisms based on context:
  • Inside DO: Uses stub.fetch() with upgrade headers
  • Browser/Node: Uses standard new WebSocket(url)

Message encoding

All messages are JSON-encoded:
// SDK → Container
webSocket.send(JSON.stringify({
  type: 'request',
  id: generateRequestId(),
  method: 'POST',
  path: '/exec',
  body: { command: 'ls' }
}));

// Container → SDK
webSocket.send(JSON.stringify({
  type: 'response',
  id: 'req-abc123',
  status: 200,
  body: { stdout: 'file.txt\n', stderr: '', exitCode: 0 },
  done: true
}));

Pending request tracking

The SDK maintains a map of pending requests:
packages/sandbox/src/clients/transport/ws-transport.ts:40
private pendingRequests: Map<string, PendingRequest> = new Map();
Each request stores its resolve/reject functions and optional stream controller.

Build docs developers (and LLMs) love