Benchmarks
This page reports the scalability benchmark results of Realtime and the PG service on a single-instance deployment. It covers five areas — Broadcast (public and private channels), payload size, connection and authorization rate, Presence, and Postgres Changes — and gives the recommended production capacity and configuration parameters.
The subject under test is a single-instance Realtime Server. Re-run the benchmarks against your own workload before relying on these numbers.
Methodology
Test environment
| Item | Configuration |
|---|---|
| Realtime Server | 2 vCPU / 4 GB × 1 |
| PostgreSQL | 1 vCPU / 2 GB |
Tenant quotas (during the test)
| Quota | Value | Notes |
|---|---|---|
max_concurrent_users | 30,000 | Upper bound on active WebSocket connections per tenant. New connections are rejected above it (TooManyConnections). |
max_joins_per_second | 3,000 | Upper bound on channel joins per second per tenant. Join requests are rejected above it (TooManyJoins). |
max_events_per_second | 30,000 | Judged on a 60-second average |
max_presence_events_per_second | 10,000 | Counted by deliveries |
max_bytes_per_second | 10 MB/s | Inbound traffic only; no effect on high fan-out |
db_pool (authorization pool) | 10 | Determines the authorization throughput ceiling |
subs_pool_size (subscription pool) | 10 | Determines the CDC circuit-breaker threshold |
Test method
| Item | Description |
|---|---|
| Protocol | Raw WebSocket + Phoenix protocol, one join per connection |
| Ramp-up | Stepped ramp at 50/s, then sustained sending at steady state |
| Metrics | Rate metrics use a 60-second sliding average; latency is measured from send to first client receipt |
| Observed data | Connection success rate, delivery rate, deliveries per second, p50/p95/p99 latency, server CPU and memory |
Metric definitions
| Metric | Definition |
|---|---|
| Messages sent per second | One message published to a channel counts as 1, regardless of subscriber count |
| Deliveries per second | Messages pushed to clients = messages sent × subscriber count |
| Channel join rate | One count per client that connects and joins a channel |
| Delivery rate | Messages actually received ÷ messages expected |
1. Broadcast over WebSocket (public)
Public channels skip authorization. All connections subscribe to the same topic, and a single sender broadcasts at a fixed rate. This measures the capacity of the pure fan-out path.
1.1 Connection count
| Connections | Server memory | Delivery rate | Result |
|---|---|---|---|
| 20,000 | 82% | 100% | Stable |
| 24,000 | 91.6% | 100% | Near limit |
| 25,000 | Inflection | 100% | Unusable |
Memory grows linearly with the connection count: memory ≈ 35% + 4 percentage points × (connections ÷ 1000). Single-instance memory is the first constraint on connections: 20,000 is the recommended ceiling, and memory is exhausted at 25,000.
1.2 Delivery volume
| Deliveries per second | Server CPU | Latency | Result |
|---|---|---|---|
| 5,000 | 18% | Low | Stable |
| 19,591 | 52% | Low | Stable |
| 27,000 | 70% | Normal | Safe line |
| 33,988 | 88% | Starting to rise | Near limit |
| 40,000 | ~95% | Seconds | Degraded |
| 45,800 | 94.8% plateau | p50 21 s / p95 52 s | Capacity ceiling, no loss |
CPU correlates linearly with delivery volume and is independent of the connection count: CPU ≈ 6.5% + 2.35% × (deliveries per second ÷ 1000). At 45,800/s the system still delivers everything without loss, but latency is unacceptable — an "experience degrades before functionality does" failure mode.
2. Broadcast over Private Channels
Every publish on a private channel runs an RLS authorization check and writes to the message table, and fan-out within a channel is serial. All tests ran with 4,000 connections; every connection joined successfully and none dropped.
2.1 Send and delivery combinations
| Scenario | Topology | Sent/s | Delivered/s | First-report p50 | Delivery rate | Result |
|---|---|---|---|---|---|---|
| High-frequency steady | 500 rooms × 8 users, 5 msg/s | 2.5k | 17.2k | 77 ms | 100% | Stable |
| Low-frequency steady | 2,000 rooms × 2 users, 2.5 msg/s | 5.0k | 4.9k | 64 ms | 100% | Stable |
| High-frequency overload | 500 rooms × 8 users, 10 msg/s | 4.6k | 24.7k | 1,586 ms | 78% | Latency degraded |
| Mid-frequency overload | 2,000 rooms × 2 users, 5 msg/s | 10.0k | 8.1k | 93 ms (p95 6.4 s) | 82% | Latency degraded |
"Steady" and "overload" contrast the capacity boundary under the same load model. All runs used 4,000 connections; all joined successfully and none dropped.
Private broadcast capacity is constrained from two directions: messages sent per second reflects authorization and dispatch cost, deliveries per second reflects fan-out cost. Sending 2.5k/s with 17.2k/s delivered and sending 5.0k/s with 4.9k/s delivered both hold tens-of-milliseconds latency at 100% delivery. At 24.7k/s delivered (4.6k/s sent), first-report latency rises to 1.59 s; at 10.0k/s sent, p95 latency reaches 6.4 s and delivery rate drops to 82%. For a single instance, keep both of these true: sent per second ≤ 6,000, delivered per second ≤ 17,000.
2.2 Connection count
| Connections | Server memory | Delivery rate | Result |
|---|---|---|---|
| 8,000 | — | 100% | Stable |
| 16,000 | 72.8% | 100% | Stable |
| 20,000 | 88.3% | 100% | Near limit |
| 24,000 | 100% | 2.13% lost | Unusable |
Private broadcast has higher resident memory than public broadcast (each channel retains authorization and subscription state), so its connection ceiling is lower.
3. Payload Size
Public broadcast scenario, 8,000 connections, fixed send rate, only the message size varies.
| Payload | Delivery rate | Fan-out span p50 | Egress bandwidth | Result |
|---|---|---|---|---|
| 64 B | 100% | 387 ms | — | Stable |
| 1 KB | 100% | 387 ms | — | Stable |
| 10 KB | 100% | — | — | Stable |
| 100 KB | 3 of 5 sent | 5,222 ms | 114 MiB/s ≈ 1 Gbps | Unusable |
Single-instance egress bandwidth is about 150 MB/s (~1.2 Gbps), with the inflection point between 10 KB and 100 KB. The bandwidth bottleneck shows up as the sender being unable to send, not as packet loss on the receiver.
4. Connection and Authorization Rate
Joining a private channel runs one RLS authorization query, which occupies the authorization connection pool, so the connection rate has a hard ceiling.
| Connection rate | Join success rate | Result |
|---|---|---|
| 50/s | 100% | Stable |
| 100/s | 15,998 / 16,000 | Stable |
| 120/s | 100% | Near ceiling |
| 130/s | 0 delivered | Tripped |
| 200/s | 58.3% rejected (9,330 failed) | Tripped |
The mechanism is authorization pool ÷ per-authorization time ≈ 10 ÷ 80 ms ≈ 125/s. Above the limit the server returns IncreaseConnectionPool and recovers after a 30-second window. The limit rejects everything rather than degrading, so clients must implement reconnection backoff with jitter to avoid a second trip caused by mass reconnection.
5. Presence
Each Presence state change is broadcast to every member of the channel, so cost grows with the square of the member count.
| Members per topic | Scenario | Result |
|---|---|---|
| 8 | 16,000 connections, 10% updates | 12,670/s, p50 85 ms, zero errors |
| 8 | 25% updates | 23,497/s, p50 796 ms, zero errors |
| 8 | 70% updates | ~78,000/s, p50 3,444 ms, memory 97.5% |
| 10,000 | Single hot topic | CPU 99.6% / memory 100%, 174 KB snapshot per user, 4,228 connections dropped |
Quota requirement is calculated as:
Required quota = members² × update frequency
Per-topic update frequency ceiling = quota ÷ members
At a quota of 1,000/s:
| Members | Allowed update frequency |
|---|---|
| 8 | 125/s |
| 200 | 5/s |
| 1,000 | 1/s |
| 10,000 | 0.1/s (unusable) |
Conclusion: keep members per topic under 200. Do not use Presence for lobby-style scenarios.
6. Postgres Changes
Every change is delivered one subscription at a time, and Postgres Changes is processed by a single-threaded poller to preserve ordering.
| Subscriptions | Subscription reconciliation | Delivery (4 rounds) | Polling peak | Result |
|---|---|---|---|---|
| 16,000 | 100% | 4 × 16,000, 0 missing | 390.8 ms | Stable |
| 17,000 | 100% | 4 × 17,000, 0 missing | 612.2 ms | Stable |
| 18,000 | 17,743 (257 short) | Degraded | 840.7 ms | Tripped |
| 18,000 (retry) | 17,774 (226 short) | Degraded | 430.8 ms | Tripped |
End-to-end latency at 16,000: p50 1,317 ms, p95 ≤ 1,694 ms, p99 ≤ 1,710 ms. Sustained change rate measured 3,000–5,000/s. Under mixed load with broadcast and Presence, CDC latency degrades about 5.8× (from 2.6 s to 15 s).
The trip is caused by poller failures clearing subscriptions; when clients re-register, they hit the subscription pool ceiling. Enlarging the subscription pool only postpones the trip — it does not fix the root cause.
7. Capacity Summary (recommended)
| No. | Dimension | Recommended (production) |
|---|---|---|
| 1 | Public broadcast — concurrent connections | ≤ 20,000 |
| 2 | Private broadcast — concurrent connections | ≤ 18,000 |
| 3 | Public broadcast — deliveries per second | ≤ 27,000/s (CPU 70%) |
| 4 | Private broadcast — messages sent per second | ≤ 6,000/s |
| 5 | Private broadcast — deliveries per second | ≤ 17,000/s |
| 6 | Write-only (no receivers) | Unlimited (≥ 28,600/s did not hit a bottleneck) |
| 7 | Connection / channel join rate | ≤ 60/s |
| 8 | Message size | ≤ 4 KB |
| 9 | CDC subscriptions per tenant | ≤ 14,000–15,000 |
| 10 | CDC — changes per second | ≤ 3,000/s |
| 11 | Presence members per topic | ≤ 200 |
| 12 | Presence — deliveries per second | ≤ 23,000/s |
These are single-instance safety lines. Beyond them, latency rises first; only sustained pressure causes delivery loss or disconnects. Data is never lost silently. When your workload exceeds the recommendations, add instances and shard by room or tenant — adding memory alone does not help, because the bottleneck is CPU.
8. Configuration Recommendations
All parameters below are standard settings adjustable on the platform side; no code change or service restart is required.
| Setting (config key) | Recommended | Rationale | Benefit |
|---|---|---|---|
Database connection pool size (db_pool) | 10 | Private channel joins and REST publishes each need one RLS check; throughput ≈ "pool size ÷ 80 ms each" | Join throughput from 125/s to ~375/s, red line from 60/s to 180/s |
Postgres Changes connection pool size (subs_pool_size) | 20 | This pool is used only to create subscriptions; the CDC trip threshold equals the pool size | Postpones the CDC trip — a mitigation, not a fix |
Max events per second (max_events_per_second) | 25,000 | Counted by deliveries; measured safe line is 27,000/s (CPU 70%) | Aligns with actual capacity, avoids needless throttling |
Max presence events per second (max_presence_events_per_second) | 10,000 | Each state change is broadcast to every client in the channel and also counts toward Max events per second | Supports 200-member topics with second-level updates |