A React Native (Expo) demo app built with PowerSync and Supabase that explores three different approaches to signaling PowerSync network quality to end users. PowerSync handles sync between the local SQLite database and Supabase (Postgres), while Supabase provides the backend database and anonymous authentication.
Each hook takes a different angle, and each has known shortcomings. Pick the one that best matches what you want to communicate to users.
Calibrate the thresholds for your app. Every app, dataset, and user network profile is different. The constants exported by each hook (
PING_RTT_THRESHOLDS,OPS_PER_SECOND_THRESHOLDS, and the durations passed touseDownloadSpeed) are arbitrary defaults chosen for this demo. Measure real sessions on real devices with your own Sync Config and data shapes, then tune the boundaries until "good / moderate / poor" actually line up with the experience your users are having. Do not ship these numbers as-is.
src/hooks/useLivePing.ts issues a one-shot GET to the PowerSync URL and measures the round-trip time.
export const PING_RTT_THRESHOLDS = {
good: 150, // <= 150 ms -> good
moderate: 500 // <= 500 ms -> moderate, else poor
};| Quality | Condition |
|---|---|
| Good | RTT <= 150 ms |
| Moderate | RTT <= 500 ms |
| Poor | RTT > 500 ms |
| Offline | PowerSync disconnected or fetch failed |
| Unknown | Button not pressed yet |
Shortcomings:
- Latency, not bandwidth. A fast ping does not mean sync will be fast. A user on a high-latency satellite link with plenty of bandwidth will look "poor" here even though large syncs may still complete in reasonable time, and a user on a low-latency mobile link with saturated upstream may look "good" while sync stalls.
- Single sample. One request can be skewed by a cold TCP/TLS handshake, a transient radio wakeup, or background contention on the device.
- Not representative of the sync stream. The ping hits the HTTP endpoint but does not open the
/sync/streamchannel, so it cannot detect issues that only manifest under a long-lived streaming connection.
src/hooks/useDownloadSpeed.ts POSTs to the same /sync/stream endpoint the SDK uses, measures bytes received via XMLHttpRequest's onprogress, and reports Mbps / KB/s.
The UI exposes a compressed vs uncompressed switch:
- Compressed (
Accept-Encoding: gzip, br) - measures the decompressed payload size the SDK sees. Reflects perceived sync UX, but inflates numbers vs the actual wire speed because PowerSync NDJSON payloads compress roughly 3-5x. - Uncompressed (
Accept-Encoding: identity) - forces the server to send raw bytes. Closer to true network bandwidth.
Shortcomings:
- Duration bias. The longer the test runs, the higher the reported throughput tends to climb. TCP slow-start, TLS warmup, and server-side buffering all depress the early numbers. A 2-second test will almost always report a lower number than a 20-second test on the same connection.
- Compression ambiguity. The compressed number is not your network speed, and the uncompressed number is not the throughput the SDK actually experiences. Users need both to understand what they are seeing.
- Shares the network with the active sync. If a real PowerSync sync is running concurrently, the speed test competes with it for bandwidth and under-reports.
- One-shot, button-driven. No historical averaging, no smoothing. A single bad sample can mislead.
src/hooks/useNetworkStatus.ts watches PowerSync's downloadProgress.downloadedOperations while dataFlowStatus.downloading is true, and classifies throughput in ops/sec.
export const OPS_PER_SECOND_THRESHOLDS = {
good: 10000, // >= 10,000 ops/sec -> good
moderate: 5000, // >= 5,000 ops/sec -> moderate
// anything below -> poor
};| Quality | Condition |
|---|---|
| Good | >= 10,000 ops/sec |
| Moderate | >= 5,000 ops/sec |
| Poor | < 5,000 ops/sec |
| Offline | PowerSync disconnected |
| Unknown | No download measured yet |
After a download finishes, the last measured ops/sec stays visible.
Shortcomings:
- Duration bias, same as the speed test. The longer a download runs, the higher ops/sec tends to climb as the connection warms up. A quick 500-op delta looks slower than a sustained 100k-op sync on the same network.
- Operations are not equal-sized. Ops/sec is a count, not a data-rate. One operation might be a 50-byte row insert, the next a multi-kilobyte document update. A user syncing many tiny rows will appear "good" while a user syncing fewer but larger operations on the same network will appear "poor".
- Poor fit for incremental sync. The thresholds are calibrated for bulk initial-sync scenarios. Once the user is caught up, normal incremental traffic delivers a handful of ops at a time and the classification will swing to "poor" or "unknown" despite the network being perfectly healthy.
- Only works during active download. When PowerSync is idle there is nothing to measure, so the hook goes quiet.
- Want to show users "is PowerSync reachable and responsive right now" as a quick health check?
useLivePing. - Want to show users "how fast is my network for PowerSync traffic specifically" on demand?
useDownloadSpeed(be clear in the UI whether you are showing compressed or uncompressed). - Want to show users live feedback during a large initial sync?
useNetworkStatus, knowing it is only meaningful while downloading and only for workloads whose ops are roughly uniform in size.
A production app would likely combine all three, gated by which one has fresh data at any given moment.
- Three independent network-quality signals with classification and live UI
- Seed/delete buttons to generate large datasets (20k customers) for testing sync performance
- Download progress bar showing real-time sync progress (X of Y operations)
- Anonymous auth via Supabase, no sign-in required
- Node.js 18+
- pnpm
- Xcode (for iOS) or Android Studio (for Android)
- A Supabase project
- A PowerSync instance connected to your Supabase project
-
Enable anonymous sign-in in your Supabase dashboard:
- Go to Authentication > Providers > Anonymous Sign-In and enable it.
-
Create a
customerstable. This demo uses aserialprimary key; PowerSync clients always seeidas text, so the Sync Config below casts it:
create table public.customers (
id serial not null,
name text not null,
created_at text not null,
constraint customers_pkey primary key (id)
) tablespace pg_default;The insert/update/delete policies above are wide open because this is a demo. In a real app, scope them to the authenticated user (for example
auth.uid() = owner_id).
- Create the
powersyncpublication. PowerSync replicates from Postgres via logical replication and reads the tables listed in a publication namedpowersync. On Supabase,wal_level = logicaland the replication role are already set up for you, so you only need to create the publication:
create publication powersync for table public.customers;- Configure the PowerSync Sync Config (edition 3 / sync streams). In the PowerSync dashboard, set the Sync Config for your instance to:
config:
edition: 3
streams:
customers:
auto_subscribe: true
query: SELECT * FROM customers-
Install dependencies
pnpm install
-
Configure environment variables
cp .env.template .env
Edit
.env:EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key EXPO_PUBLIC_POWERSYNC_URL=https://your-instance.powersync.journeyapps.com -
Generate native projects
pnpm prebuild
-
Run the app
pnpm ios # or pnpm android
- Expo SDK 54 (bare workflow via prebuild)
- PowerSync (
@powersync/react-native+@powersync/op-sqlite) - Supabase (
@supabase/supabase-js) - op-sqlite (
@op-engineering/op-sqlite) for the SQLite driver