Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8fcb466
Add Rails comparison: Action Cable / Solid Cable / AsyncCable / AnyCable
irinanazarova Jun 28, 2026
1915aa5
docs: rename AsyncCable to Async::Cable (match the gem's own naming)
irinanazarova Jun 29, 2026
b136387
cable-bench-falcon: enable actioncable-next fastlane broadcasts
irinanazarova Jun 29, 2026
89a79a1
Add raw_transmit shim so fastlane broadcasts deliver on Falcon
irinanazarova Jun 29, 2026
73cd86e
Wire throughput runner for real Rails channels
irinanazarova Jun 29, 2026
2f68091
Fix throughput-multi publish-rate key (interval -> intervalMs)
irinanazarova Jun 29, 2026
5afa764
Falcon target: native raw_transmit + vendored fiber Executor
irinanazarova Jun 30, 2026
8a1b361
Puma target: honor WEB_CONCURRENCY for worker count
irinanazarova Jun 30, 2026
0cf66c3
avalanche-multi: support Rails targets (endpoint + channel/acProtocol…
irinanazarova Jun 30, 2026
37287d3
Add sharded multi-runner avalanche for AnyCable/Rails targets
irinanazarova Jun 30, 2026
47a9683
Configurable client reconnect backoff (reconnectBaseMs)
irinanazarova Jun 30, 2026
f862cc9
Jitter: enforce a real offline window (standard 2s network drop)
irinanazarova Jul 1, 2026
7b753c4
Jitter: clean fixed-length outage via cable.disconnect()/connect()
irinanazarova Jul 1, 2026
568163f
Native JS client per adapter: @rails/actioncable for Action Cable family
irinanazarova Jul 1, 2026
2883eb4
Stub browser globals so @rails/actioncable runs under Node
irinanazarova Jul 1, 2026
6495255
Native @rails/actioncable in jitter (native drop) + avalanche; shared…
irinanazarova Jul 1, 2026
0627042
Remove centrifugo; keep published result files
irinanazarova Jul 1, 2026
2326ef9
Fix review issues: jitter consumer leak, avalanche double-count
irinanazarova Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ node_modules/
dist/
*.log

# Bench result outputs go in backend/results/. The directory is tracked
# via .gitkeep so scripts can write into it on a fresh clone; the actual
# result files (CSV/JSON) are ignored.
# Bench result outputs go in backend/results/. Raw per-run dumps (timestamped
# CSV/JSON) are ignored, but the curated, published result files that the
# README and docs cite are kept in the repo.
backend/results/*
!backend/results/.gitkeep
!backend/results/rails-*.json
!backend/results/socketioxide-*.json

# AnyCable Pro binaries are licensed and MUST NEVER be committed to this
# public repo. These patterns match the asset names from the private
Expand Down
63 changes: 62 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Setups under test: default Socket.io, Socket.io + Connection State Recovery, uWe

Sixth target, [socketioxide](https://github.com/totodore/socketioxide) (Rust Socket.io server), benchmarked head-to-head with AnyCable on the same Railway hardware. Results in [its own section below](#socketioxide-rust-socketio); deep dive in [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md).

The same harness also drives the **Rails** comparison (Action Cable vs Solid Cable vs Async::Cable vs AnyCable) behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable): one Rails app, four adapters, same box. Results in [its own section below](#rails-action-cable--solid-cable--asynccable--anycable); deep dive in [`docs/rails-comparison.md`](./docs/rails-comparison.md).

Methodology, traps, and the bugs we caught in our own setup: [`docs/methodology.md`](./docs/methodology.md). Below: the numbers and how to rerun them.

## Headlines
Expand Down Expand Up @@ -110,6 +112,45 @@ Three knobs that shape the numbers. Full reasoning in [`docs/methodology.md`](./

**The takeaway.** Rust fixes Socket.io's capacity ceiling, the single-event-loop wall that caps Node around 120K. It leaves two things untouched: at-most-once delivery (no replay protocol) and deploy fragility (the WS layer still dies with its app). Both live in the protocol and the topology, so swapping the language to Rust leaves them intact, and socketioxide collapses under jitter and deploy storms at scale the same way Node Socket.io does. AnyCable holds 100% on both because the WS layer is a separate process with replay. Full numbers and the deploy story: [`docs/socketioxide-comparison.md`](./docs/socketioxide-comparison.md).

## Rails (Action Cable / Solid Cable / Async::Cable / AnyCable)

The repo behind [anycable.io/compare/rails-actioncable](https://anycable.io/compare/rails-actioncable). Four WebSocket adapters for the **same Rails 8.1 app**, same Railway box, same shared-tenant window: Action Cable on Redis (Puma), Solid Cable on the database (Puma), Async::Cable on Falcon (`socketry/async-cable`, fiber reactor), and AnyCable in RPC mode (Rails gRPC backend + `anycable-go` gateway). All four are Action Cable-compatible at the app and client level, so the channels and Turbo Streams are identical; only `config/cable.yml`, the runtime, and the process topology change. The Puma targets are in `cable-bench/`, the Falcon target in `cable-bench-falcon/`; the AnyCable JS driver serves all four over the existing `bench-*-anycable` endpoints, parameterized by `cableUrl` / `broadcastUrl` / `channel` / `acProtocol`.

The one wire difference: Action Cable, Solid Cable and Async::Cable speak the base `actioncable-v1-json` protocol (at-most-once, no resume); AnyCable speaks the extended `actioncable-v1-ext-json` protocol (per-stream history, resume on reconnect). Switching Puma for Falcon (Async::Cable) changes the runtime, not the guarantees.

All numbers below are **sharded** (13 drivers, ~250 to 770 cables each). A single bench-runner holding thousands of cables saturates its own event loop and produces wrong numbers in both directions; see [Running the latency test](#running-the-latency-and-jitter-test-shard-it).

**Roundtrip latency (steady network, 100% delivery).** AnyCable leads at every scale: fan-out runs in Go, off the Ruby process.

| Adapter | 1K p50 / p99 | 5K p50 / p99 |
| --- | --- | --- |
| Solid Cable | 62 / 119 ms | 74 / 164 ms |
| Async::Cable (Falcon) | 11 / 80 ms | 20 / 71 ms |
| Action Cable (Puma) | 9 / 47 ms | 13 / 57 ms |
| AnyCable | **4 / 23 ms** | **7 / 31 ms** |

**Delivery under jitter (5K, ~2 s drops).** The base protocol has no resume, so the three in-process adapters lose the same ~22%; AnyCable replays per-stream history.

| Adapter | Delivery |
| --- | --- |
| Solid Cable | **78.1%** |
| Action Cable | **78.1%** |
| Async::Cable (Falcon) | **78.1%** |
| AnyCable | **99.9%** |

**Capacity: 10K under load + idle-to-break.** All four hold 10K at 100% delivery; AnyCable keeps the tightest tail (p99 31 ms vs 84 ms Action Cable, 112 ms Async::Cable, 200 ms Solid Cable). Pushed to failure on identical 32 GB boxes (default 8-worker config): Puma Action Cable and Solid Cable wall at ~52K on a file-descriptor ceiling (~2.5 GB RAM, not memory-bound); Async::Cable on Falcon runs out of memory at ~97K (~290 KB/conn); AnyCable held **600K with zero failures** across a 50-runner fleet (~47 KB/conn, ~27 GB, ~84% of the box), not driven to failure: the load fleet maxed out, not the server, so treat 600K as a floor. Finding a gateway's true ceiling takes ~1 load driver per 10K connections (a single Node driver tops out near 10K cables), so capacity-to-break runs scale the driver count to the target. Raw data in [`backend/results/rails-capacity-break-2026-06-28.json`](./backend/results/rails-capacity-break-2026-06-28.json).

**Deploy survival (avalanche, 5K, real app redeploy).** In-process drops every connection, Puma and Falcon alike, and stays down ~7.5 to 8 s before ~96% reconnect; AnyCable's gateway never sees the deploy, so 0 s of downtime. "Down for" is how long connections stayed dropped before the reconnect storm settled.

| Adapter | Dropped | Down for | Reconnected |
| --- | --- | --- | --- |
| Action Cable | all 5,000 | 7.5 s | 96.3% (187 still out at cutoff) |
| Solid Cable | all 5,000 | 7.6 s | 95.7% (215 still out at cutoff) |
| Async::Cable (Falcon) | all 5,000 | 8.0 s | 96.4% (179 still out at cutoff) |
| AnyCable | **0** | **0 s** | n/a |

**The takeaway.** On Rails, AnyCable leads on latency at every scale (7 ms p50 at 5K) and wins the things that decide whether realtime holds up: 100% delivery under jitter where the base protocol drops about a fifth of broadcasts, and connections that survive every app deploy. Capacity ties at everyday sizes (all four hold 10K at 100%) but splits past that: AnyCable held 600K idle connections where the Puma adapters wall at ~52K on a file-descriptor ceiling and Falcon runs out of memory at ~97K. Async::Cable on Falcon is a real alternative runtime to Puma with latency in the same range, but shares the in-process limits (at-most-once, deploy-fragile) and is the most memory-hungry by far. Solid Cable's edge is operational (no Redis), at the cost of a polling-latency floor. Full numbers: [`docs/rails-comparison.md`](./docs/rails-comparison.md). Raw results: [`backend/results/rails-sharded-2026-06-28.json`](./backend/results/rails-sharded-2026-06-28.json).

## Repository layout

```
Expand All @@ -123,7 +164,7 @@ benchmark/
└── backend/
├── Dockerfile # One image; SERVICE_ENTRY picks the entry point
├── package.json
├── results/ # CSV/JSON output (gitignored)
├── results/ # published rails-*/socketioxide-* results tracked; raw run dumps ignored
└── src/
├── publisher.ts # Standalone HTTP publisher (legacy)
├── socketio/server.ts # /_broadcast + /publish-local
Expand Down Expand Up @@ -314,6 +355,26 @@ INCLUDE_AVALANCHE=1 # add 5 avalanche tests (auto-redeploys server)

Full sweep: ~90 minutes wall-clock.

### Running the latency (and jitter) test: shard it

**Latency and jitter must be sharded. One bench-runner holding thousands of client cables saturates its single Node event loop, and the measured receive latency then reflects the test driver queueing frames, not the server.** This bites hardest for the `actioncable-v1-ext-json` client (AnyCable), which does extra per-message offset/ack work, so an unsharded run makes AnyCable look several times slower than it is. Keep each shard light: **~250 client cables per bench-runner** (the nodejs page uses 250/shard; 1K = 4 shards, 5K = 20 shards, 10K = 40 shards). The coordinator merges the union latency distribution across shards (`mergeJitterResults`), so percentiles stay honest. See [`docs/methodology.md`](./docs/methodology.md#cross-shard-percentile-honesty).

Set `numShards` + `perShardN` on the spec in `tests-manifest.ts` (e.g. `numShards: 20, perShardN: 250` for 5K), then point `BENCH_RUNNER_URLS` at **at least that many ONLINE runners**. The default pool assumes `bench-runner` + `bench-runner-2..50`; if some are stopped, list the live ones explicitly:

```bash
cd backend
# Build a URL list from a contiguous range of online runners (here 14..33 = 20 shards):
URLS=$(for i in $(seq 14 33); do printf 'https://bench-runner-%s-production.up.railway.app,' "$i"; done)

BENCH_RUNNER_TOKEN=<your-token> \
BENCH_RUNNER_URLS="${URLS%,}" \
FILTER=latency \
OUTPUT_DIR=tmp/latency-run \
npm run bench:rebaseline
```

A spec without `numShards`/`perShardN` runs on a single bench-runner: fine for connection-count or delivery checks, wrong for latency. If a latency p50 jumps super-linearly with subscriber count (e.g. 15 ms at 1K to 225 ms at 5K), suspect an unsharded run before you suspect the server.

**Baselines vs the page numbers.** The page was captured during a noisy Railway shared-tenant window. The `baseline` field in `tests-manifest.ts` is what the same tests deliver on a quieter window: latencies ~50% better, everything else the same. So the page is the cautious "worst seen under shared-infra load" view, and the rebaseline tests against today's quieter floor. A green rebaseline says "we still beat today's floor", which is stricter than the page promises. When we refresh the page, baselines and page numbers move together.

Per-run history lives at `tmp/v1.6.14-bench-results/runs/{ISO-ts}/`. To watch each headline number move across runs:
Expand Down
113 changes: 113 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
},
"dependencies": {
"@anycable/core": "^1.1.6",
"@rails/actioncable": "^8.1.300",
"@socket.io/redis-adapter": "^8.3.0",
"@socket.io/redis-streams-adapter": "^0.3.1",
"centrifuge": "^5.7.0",
"express": "^4.21.0",
"ioredis": "^5.10.1",
"nats": "^2.29.3",
Expand Down
Loading
Loading