Skip to content

Repository files navigation

PowerSync Load Testing

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.

How it works

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.

Connection integrity checksum

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.

Setup

Prerequisites

Install and configure

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>

Database

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).

PowerSync Sync Config

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:deploy

Config files: powersync/service.yaml (connection + auth) and powersync/sync-config.yaml (Sync Config).

Auth

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.

Deploy

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.

Scenarios

1. Broadcast (one-to-many)

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 2540

Orchestrator (1 task):

  1. Seeds — no-op for this scenario (cleanup only)
  2. Waits for clients — polls client_ready_signals every 5s until all 10,000 clients report initial sync complete (or stalls for 120s with no new clients)
  3. Writes exactly one row — inserts a single broadcast_rows row into Supabase with write_timestamp = Date.now()
  4. Waits for metrics to stabilize — polls latency_samples until it has samples from all 10,000 clients (or no new samples for 120s)
  5. Aggregates results — computes P50/P95/P99/Min/Max/Mean and writes to scenario_results

Clients (100 tasks x 100 clients = 10,000):

  1. Each task staggers its 100 connections at the global rate of 20 conn/sec (interleaved round-robin across all 100 tasks)
  2. Each client opens a PowerSync sync stream, completes initial sync, signals readiness via client_ready_signals
  3. Clients keep the stream open (keepOpen: true) waiting for new data
  4. When the orchestrator's row arrives via the sync stream, each client computes latency = Date.now() - write_timestamp and records it to latency_samples
  5. 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.


2. Scoped fan-out (many-to-many)

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):

  1. Seeds — creates scopes (pairwise and small groups) and scope_members rows linking clients to scopes (idempotent, skips if already seeded)
  2. Waits for clients — polls until 10,000 clients complete initial sync
  3. 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)
  4. Sustains 100 writes/sec for 300 seconds — each iteration picks a random writer, picks one of their scopes, and inserts a scoped_rows row with write_timestamp. Produces ~30,000 total writes.
  5. Persists metadata — writes expected-recipient count and total writes to run_metadata
  6. Waits for metrics stability, then aggregates P50/P95/P99/Min/Max/Mean

Clients (100 tasks x 100 clients = 10,000):

  1. Same staggered connection as broadcast
  2. Each client opens a sync stream scoped to its own memberships (via Sync Config matching its user ID)
  3. After initial sync, each incoming scoped_rows row triggers a latency measurement: Date.now() - write_timestamp
  4. 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.


2b. Scoped fan-out, per-write (fan-out tail)

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.


3. Initial sync (backlog on connect)

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 560

Orchestrator (1 task):

  1. Seeds — inserts 1,000 backlog_rows rows per client for all 100 clients (100,000 rows total, ~280 bytes each). Idempotent — skips if the count already matches.
  2. Waits for clients — polls until all 100 clients report sync complete
  3. No writes — this scenario has no runOrchestrator; the orchestrator just waits
  4. Waits for metrics stability — polls sync_complete_samples for 100 entries
  5. Aggregates results — P50/P95/P99/Min/Max/Mean of sync durations

Clients (1 task x 100 clients):

  1. Each client connects with empty bucket state (a first-time connection)
  2. Opens a sync stream with keepOpen: false, so it closes once the backlog has landed
  3. Counts rows as they arrive. PowerSync emits a checkpoint_complete for the client's empty starting state before streaming anything, so the client skips any checkpoint where rowCount === 0 and records duration = Date.now() - startTime plus rows_synced on 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.

Environment variables

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)

Project structure

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

Adding a scenario

  1. Create src/scenarios/my-scenario/ with index.ts, client.ts, and optionally orchestrator.ts and seeder.ts
  2. Implement ScenarioDefinition (see src/scenarios/types.ts)
  3. Register in src/scenarios/registry.ts
  4. Add tables to sql/schema.sql and Sync Config to powersync/sync-config.yaml

AI disclosure

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.

About

Load testing framework for PowerSync Cloud: measures sync latency under high client concurrency across broadcast, messaging, and catch-up scenarios.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages