Skip to content

Scaling & Multi-Instance

A single 4-vCPU / 8 GB Doable host comfortably serves 100–500 active users. Beyond that, scale horizontally.

What scales horizontally

Service Replicas Notes
web (Next.js) unlimited Stateless. Put a load balancer in front.
api (Hono) 2+ recommended Stateless once Redis is enabled. Each replica still spawns its own Vite dev servers β€” see "Project affinity" below.
ws (WebSockets) 1 (typical), N with sticky sessions Yjs rooms are in-memory. Sticky sessions or a Y-redis adapter are needed to scale a single hot room across replicas.

What does NOT scale horizontally (without work)

  • Per-project Vite dev servers β€” they live on whatever API replica first touched the project. Use project β†’ replica affinity at the load balancer (consistent hashing on projectId).
  • PROJECTS_ROOT/ β€” must be shared across API replicas. Use NFS, EFS, or a shared block volume.
  • PostgreSQL β€” scale vertically or use a managed service (Neon, RDS, Supabase).

Topology for 1000+ users

                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚ Load balancerβ”‚ (nginx/Cloudflare/ALB)
                 β””β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”€β”¬β”€β”€β”˜
                    β”‚   β”‚   β”‚
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚   └─────────────┐
       β–Ό                β–Ό                 β–Ό
   β”Œβ”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”           β”Œβ”€β”€β”€β”€β”€β”
   β”‚ web β”‚  …       β”‚ web β”‚           β”‚ web β”‚      stateless, scale freely
   β””β”€β”€β”¬β”€β”€β”˜          β””β”€β”€β”¬β”€β”€β”˜           β””β”€β”€β”¬β”€β”€β”˜
      β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
             β–Ό                β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”
        β”‚ api  β”‚   …     β”‚ api  β”‚              project-affine via consistent hash
        β””β”€β”€β”¬β”€β”€β”€β”˜         β””β”€β”€β”¬β”€β”€β”€β”˜              shared PROJECTS_ROOT (NFS/EFS)
           β”‚                β”‚
           β–Ό                β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”
        β”‚  ws  β”‚   …     β”‚  ws  β”‚              sticky sessions per project
        β””β”€β”€β”¬β”€β”€β”€β”˜         β””β”€β”€β”¬β”€β”€β”€β”˜
           β”‚                β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                    β–Ό
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚   Redis      β”‚   shared rate-limit, sessions, presence
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                    β”‚
                    β–Ό
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚ PostgreSQL   β”‚   single primary + read replica
            β”‚ (managed)    β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Enabling Redis

Set REDIS_URL in every replica's environment:

REDIS_URL=redis://redis.internal:6379

What flips on:

  • Rate limiting is now shared (@doable/shared/kv-store switches from memory to Redis).
  • OAuth state survives across replicas.
  • Auth nonces and short-lived tokens become cluster-wide.

Sticky sessions for ws

Use the load balancer's session-affinity feature on the cookie that y-websocket attaches, or on the URL path. Examples:

  • nginx-ingress (k8s):
    annotations:
      nginx.ingress.kubernetes.io/affinity: "cookie"
      nginx.ingress.kubernetes.io/session-cookie-name: "ws-affinity"
    
  • Cloudflare Load Balancer: enable session affinity by cookie.

For very large rooms, swap the in-memory Y.Doc store in services/ws for a Y-redis adapter.

Project affinity for api

The cleanest way is consistent hashing on the projectId URL segment. nginx example:

upstream api_pool {
  hash $upstream_target consistent;
}

map $request_uri $upstream_target {
  default                                  $remote_addr;
  ~^/(?:projects|preview|chat)/([0-9a-f-]{36}) $1;
}

server {
  location / { proxy_pass http://api_pool; }
}

This routes every request for the same project to the same API replica, so the project's Vite dev server is reused.

Shared filesystem

Mount the same directory at PROJECTS_ROOT on every API replica:

  • EFS / FSx for Lustre (AWS)
  • Filestore / Cloud NFS (GCP)
  • NFSv4 (self-hosted, simple)
  • OpenEBS / Longhorn (k8s, block storage with multi-attach)

Avoid object storage (S3) for project files β€” Vite needs a real POSIX filesystem.

PostgreSQL

  • Vertical: a 4 vCPU / 16 GB Postgres comfortably handles tens of thousands of registered users.
  • Horizontal: a managed read replica + the DATABASE_REPLICA_URL (you can wire one in by extending packages/db/src/index.ts) for analytics queries.
  • Connection pool: keep DATABASE_POOL_SIZE Γ— replicas ≀ pg_max_connections.

AI workers

If a single API replica's DoCorePool becomes the bottleneck:

  • Run a dedicated Copilot CLI server (copilot --server) on its own host and point all API replicas at it via COPILOT_CLI_URL.
  • Or run multiple DoCoreServer instances (packages/docore/src/docore-server.ts) and load-balance among them.

Observability

Wire the Tracer from @doable/docore to your OTel collector:

new DoCoreEngine({
  tracer: new Tracer({ sink: otelSink }),
});

Hono ships with timing middleware enabled β€” request times are in the Server-Timing response header.

When NOT to scale

If your active-user count is < 100 and your peak concurrent AI sessions < 20, do not add Redis, replicas, or shared filesystems. The single-VPS deployment is dramatically simpler to operate.