A reusable baseline for measuring PowerSync Cloud sync latency under high client concurrency. It connects directly to the PowerSync Sync Stream (bypassing the SDK) and records delivery times, so results reflect the sync engine rather than any particular client SDK.
Point it at your own PowerSync instance and Supabase project, pick a scenario, and scale it to whatever client count you want to characterise.
Orchestrator ──writes──> Supabase ──replication──> PowerSync Cloud ──sync stream──> Clients
(source DB) (measure latency)
Each scenario launches two Cloud Run jobs:
- Orchestrator (1 task) — seeds data, waits for all clients to connect, performs writes, waits for metrics, then aggregates P50/P95/P99/Min/Max/Mean results.
- Clients (many tasks) — open sync streams to PowerSync, complete initial sync, signal readiness, then measure latency on incoming rows.
Clients only measure rows received after initial sync (after the first checkpoint_complete). Raw samples are batched to Supabase. Locally, both roles run in a single process.
Every scenario tracks connection integrity:
| Metric | Description |
|---|---|
| Connected at write time | Clients that completed initial sync before the orchestrator started writes |
| Clients that received | Distinct clients that submitted at least one metric sample |
| Total disconnects | Times a client disconnected after becoming ready |
These are printed alongside latency results and stored in scenario_results.
- Node.js 22+, pnpm
- PowerSync Cloud instance
- Supabase project
- PowerSync CLI:
pnpm add -g powersync(used to link and deploy the config) - Google Cloud SDK (for Cloud Run deployment)
pnpm install
cp .env.template .env
# Fill in POWERSYNC_ENDPOINT, JWT_SECRET_BASE64, SUPABASE_URL, SUPABASE_KEY,
# DATABASE_URI=<supabase-uri>, DATABASE_HOSTNAME=<supabase-hostname>, DEFAULT_PASSWORD=<supabase-db-password>Run sql/schema.sql in the Supabase SQL Editor.
The powersync_role creation at the end of that file is commented out on purpose, because it needs a password you supply. Either uncomment it with your own secret, or connect PowerSync as an existing role (which is what powersync/service.yaml does by default).
Link the repo to your own PowerSync Cloud instance first. This generates powersync/cli.yaml (gitignored, since it holds your instance/org/project IDs):
powersync link
pnpm powersync:deployConfig files: powersync/service.yaml (connection + auth) and powersync/sync-config.yaml (Sync Config).
Clients authenticate with HS256 JWTs. Generate a secret and set it as JWT_SECRET_BASE64 in .env, then add the same key in the PowerSync Dashboard under Client Auth (KID: powersync-load-testing):
openssl rand -base64 32 | tr '+/' '-_' | tr -d '='Scenarios are isolated via the JWT: each token includes a scenario claim (e.g. "scenario": "broadcast"), and every query in the Sync Config filters on auth.parameter('scenario'). This means multiple scenarios can share the same PowerSync instance and Supabase tables without interfering with each other — a client running broadcast will never see scoped-fanout data, even though both use the same connection.
Builds the Docker image and creates orchestrator + client Cloud Run jobs:
./deploy/deploy.sh <scenario> [project-id] [region]The project-id and region arguments default to powersync-load-testing and us-central1 in deploy/deploy.sh. Those are placeholders, so pass your own Google Cloud project or edit the defaults.
One row is written and fanned out to every connected client. The simplest fan-out shape: maximum breadth, minimum write volume.
| Parameter | Value |
|---|---|
| Clients | 10,000 |
| Writes | 1 |
| Fan-out | All clients |
| Metric | End-to-end sync latency per client |
# Local (10 clients)
SCENARIO=broadcast CLIENTS_PER_TASK=10 pnpm run:local
# Cloud Run (10,000 clients)
./deploy/deploy.sh broadcast # only needed if code changed
./deploy/run-scenario.sh broadcast 10000 100 powersync-load-testing us-central1 2540Orchestrator (1 task):
- Seeds — no-op for this scenario (cleanup only)
- Waits for clients — polls
client_ready_signalsevery 5s until all 10,000 clients report initial sync complete (or stalls for 120s with no new clients) - Writes exactly one row — inserts a single
broadcast_rowsrow into Supabase withwrite_timestamp = Date.now() - Waits for metrics to stabilize — polls
latency_samplesuntil it has samples from all 10,000 clients (or no new samples for 120s) - Aggregates results — computes P50/P95/P99/Min/Max/Mean and writes to
scenario_results
Clients (100 tasks x 100 clients = 10,000):
- Each task staggers its 100 connections at the global rate of 20 conn/sec (interleaved round-robin across all 100 tasks)
- Each client opens a PowerSync sync stream, completes initial sync, signals readiness via
client_ready_signals - Clients keep the stream open (
keepOpen: true) waiting for new data - When the orchestrator's row arrives via the sync stream, each client computes
latency = Date.now() - write_timestampand records it tolatency_samples - Clients stay alive until timeout (so metrics flush completes)
What's measured: Time from the single DB write until each of the 10,000 clients receives it through PowerSync's sync stream.
Many clients write concurrently, and each write reaches only the clients that share its scope. A scope is an arbitrary grouping of clients held in scopes / scope_members: 2 members models a pairwise channel, 3-10 members models a small shared one. This is the shape most partitioned workloads reduce to, whatever the domain.
| Parameter | Value |
|---|---|
| Clients | 10,000 |
| Active writers | 3,000 |
| Write rate | 100 writes/s sustained |
| Scope sizes | 70% pairwise (2 members), 30% small groups of 3-10 |
| Fan-out | 2-10 recipients per write |
| Duration | 5 minutes |
| Metric | Per-recipient sync latency |
# Local (10 clients, 5 writers, 10 writes/s for 30s)
SCENARIO=scoped-fanout TOTAL_CLIENTS=10 pnpm seed
SCENARIO=scoped-fanout CLIENTS_PER_TASK=10 TOTAL_CLIENTS=10 \
WRITER_COUNT=5 WRITES_PER_SECOND=10 TEST_DURATION_SECONDS=30 \
pnpm run:local
# Cloud Run (10,000 clients — seed locally first)
SCENARIO=scoped-fanout TOTAL_CLIENTS=10000 pnpm seed
./deploy/deploy.sh scoped-fanout # only needed if code changed
./deploy/run-scenario.sh scoped-fanout 10000 100 powersync-load-testing us-central1 2540 \
"WRITER_COUNT=3000,WRITES_PER_SECOND=100,TEST_DURATION_SECONDS=300"Orchestrator (1 task):
- Seeds — creates
scopes(pairwise and small groups) andscope_membersrows linking clients to scopes (idempotent, skips if already seeded) - Waits for clients — polls until 10,000 clients complete initial sync
- Loads scope memberships — fetches which scopes each of the 3,000 designated writers belongs to, plus full membership for every scope (to know expected recipients)
- Sustains 100 writes/sec for 300 seconds — each iteration picks a random writer, picks one of their scopes, and inserts a
scoped_rowsrow withwrite_timestamp. Produces ~30,000 total writes. - Persists metadata — writes expected-recipient count and total writes to
run_metadata - Waits for metrics stability, then aggregates P50/P95/P99/Min/Max/Mean
Clients (100 tasks x 100 clients = 10,000):
- Same staggered connection as broadcast
- Each client opens a sync stream scoped to its own memberships (via Sync Config matching its user ID)
- After initial sync, each incoming
scoped_rowsrow triggers a latency measurement:Date.now() - write_timestamp - A client only receives rows written into scopes it belongs to (PowerSync handles the routing)
What's measured: Time from each write until delivery to all intended recipients. The orchestrator records expected recipients so results show what percentage actually received.
Backlog drift: Before the aggregate percentiles are printed, the orchestrator logs per-sample latency bucketed by write_timestamp in 30-second windows:
Illustrative shape of that output (invented numbers, not measured results):
[drift] Per-sample latency by write-time window (30s buckets):
[drift] window samples P50 P95 P99
[drift] 0-30 s 10000 100ms 200ms 500ms
[drift] 30-60 s 10000 200ms 400ms 1.0s
[drift] 60-90 s 10000 300ms 600ms 1.5s
[drift] 90-120s 10000 400ms 800ms 2.0s
Flat percentiles across windows = steady state. Climbing percentiles = the system is falling behind (later writes take longer to deliver than earlier ones). This surfaces backlog that a single aggregate P50/P95 would flatten out.
Same write pattern as scoped-fanout, but aggregates latency per write instead of per (write, recipient). For each write, the duration is max(recipient receive time) - write time, and percentiles are computed across that one-value-per-write series. This surfaces fan-out tail (the slowest recipient for each write) without letting high-fanout writes dominate the distribution with hundreds of thousands of samples.
| Parameter | Value |
|---|---|
| Same as scoped-fanout | -- |
| Metric | Per-write slowest-recipient latency (N writes, not N × fanout samples) |
# Local
SCENARIO=scoped-fanout-per-write TOTAL_CLIENTS=10 pnpm seed
SCENARIO=scoped-fanout-per-write CLIENTS_PER_TASK=10 TOTAL_CLIENTS=10 \
WRITER_COUNT=5 WRITES_PER_SECOND=10 TEST_DURATION_SECONDS=30 \
pnpm run:local
# Cloud Run
SCENARIO=scoped-fanout-per-write TOTAL_CLIENTS=10000 pnpm seed
./deploy/deploy.sh scoped-fanout-per-write
./deploy/run-scenario.sh scoped-fanout-per-write 10000 100 powersync-load-testing us-central1 2540 \
"WRITER_COUNT=3000,WRITES_PER_SECOND=100,TEST_DURATION_SECONDS=300"Shares the seeder, orchestrator, and client with scoped-fanout, so only the aggregation step differs. Rows in scoped_rows are separated by scenario name, but scopes and scope_members are shared and have no scenario column, and the seeder clears them wholesale. Run these two scenarios one at a time, not concurrently.
Clients connect for the first time and sync a backlog that accumulated while they were away.
| Parameter | Value |
|---|---|
| Clients | 100 concurrently |
| Backlog | 1,000 rows per client |
| Total seeded rows | 100,000 (1,000 x 100 clients) |
| Metric | Time from connection open to checkpoint_complete |
# Local (10 clients, 100 rows each — auto-seeds)
SCENARIO=initial-sync CLIENTS_PER_TASK=10 TOTAL_CLIENTS=10 \
BACKLOG_ROWS_PER_CLIENT=100 pnpm run:local
# Cloud Run (100 clients, 1,000 rows each — auto-seeds)
./deploy/deploy.sh initial-sync # only needed if code changed
./deploy/run-scenario.sh initial-sync 100 100 powersync-load-testing us-central1 560Orchestrator (1 task):
- Seeds — inserts 1,000
backlog_rowsrows per client for all 100 clients (100,000 rows total, ~280 bytes each). Idempotent — skips if the count already matches. - Waits for clients — polls until all 100 clients report sync complete
- No writes — this scenario has no
runOrchestrator; the orchestrator just waits - Waits for metrics stability — polls
sync_complete_samplesfor 100 entries - Aggregates results — P50/P95/P99/Min/Max/Mean of sync durations
Clients (1 task x 100 clients):
- Each client connects with empty bucket state (a first-time connection)
- Opens a sync stream with
keepOpen: false, so it closes once the backlog has landed - Counts rows as they arrive. PowerSync emits a
checkpoint_completefor the client's empty starting state before streaming anything, so the client skips any checkpoint whererowCount === 0and recordsduration = Date.now() - startTimeplusrows_syncedon the first checkpoint that actually carries rows
What's measured: Time from connection open until the client has fully caught up on all 1,000 rows assigned to it.
| Variable | Required | Default | Description |
|---|---|---|---|
SCENARIO |
Yes | -- | broadcast, scoped-fanout, scoped-fanout-per-write, or initial-sync |
POWERSYNC_ENDPOINT |
Yes | -- | PowerSync instance URL |
JWT_SECRET_BASE64 |
Yes | -- | Base64url-encoded HS256 secret |
SUPABASE_URL |
Yes | -- | Supabase project URL |
SUPABASE_KEY |
Yes | -- | Supabase service role key |
DATABASE_URI |
Yes | -- | Supabase Postgres connection string (used by PowerSync CLI) |
DATABASE_HOSTNAME |
Yes | -- | Supabase database hostname, e.g. db.your-project.supabase.co |
DEFAULT_PASSWORD |
Yes | -- | Password for the replication role, referenced by powersync/service.yaml (used by powersync deploy) |
ROLE |
No | local |
local (single process), orchestrator, or client |
CLIENTS_PER_TASK |
No | 1 |
Connections per Cloud Run task |
TOTAL_CLIENTS |
No | 100 |
Total client count (orchestrator uses this to know when all are ready) |
CONNECT_RATE_PER_SEC |
No | 20 |
Global connect rate; interleaved across tasks |
TIMEOUT_SECONDS |
No | 600 |
Max run duration |
WRITER_COUNT |
No | 10 |
Clients designated as active writers (scoped fan-out) |
WRITES_PER_SECOND |
No | 100 |
Target write rate (scoped fan-out) |
TEST_DURATION_SECONDS |
No | 300 |
Sustained load duration (scoped fan-out) |
BACKLOG_ROWS_PER_CLIENT |
No | 1000 |
Backlog rows seeded per client (initial sync) |
src/
index.ts Entry point, role dispatch
config.ts Environment variable loading
auth.ts HS256 JWT generation
ndjson.ts NDJSON stream parser
db/supabase-client.ts Supabase client factory
sync/sync-client.ts PowerSync sync stream client
metrics/types.ts Sample and result type definitions
metrics/collector.ts Batched sample collection
metrics/store.ts Post-run aggregation + pagination
metrics/aggregator.ts Percentile math
roles/orchestrator.ts Orchestrator lifecycle (Cloud Run)
roles/client.ts Client lifecycle (Cloud Run)
roles/local.ts Single-process mode
scenarios/types.ts Scenario interface definitions
scenarios/registry.ts Scenario name → implementation mapping
scenarios/broadcast/ 1 write → every client
scenarios/scoped-fanout/ writes → clients sharing a scope
scenarios/scoped-fanout-per-write/ same writes, aggregated per write
scenarios/initial-sync/ per-client backlog on connect
deploy/
deploy.sh Build + create Cloud Run jobs
run-scenario.sh Execute a scenario (launches both jobs)
logs.sh Tail orchestrator logs for current execution
sql/schema.sql Supabase table definitions
powersync/ PowerSync sync config
- Create
src/scenarios/my-scenario/withindex.ts,client.ts, and optionallyorchestrator.tsandseeder.ts - Implement
ScenarioDefinition(see src/scenarios/types.ts) - Register in src/scenarios/registry.ts
- Add tables to sql/schema.sql and Sync Config to powersync/sync-config.yaml
Parts of this repository were written with AI assistance (Claude Code) and reviewed by a human before publishing. It is meant as a baseline you adapt to your own instance and scale targets, so give the code and the SQL a read before pointing it at infrastructure you care about.