Summary
On the Capacitor SDK on iOS, the first sync over the default NDJSON-HTTP connection method stalls for a fixed ~21s before any (or any further) operations are applied. The same account on SyncStreamConnectionMethod.WEB_SOCKET shows no stall at all — progress ticks incrementally from the first second.
The stall is a fixed cost, independent of checkpoint size: a 154-op first checkpoint and a 3993-op first checkpoint both complete at ~22.7s. It reproduces 4/4 on cold runs and disappears entirely on a warm database.
This matters because CapacitorRemote.supportsStreamingBinaryResponses returns false (correctly — binary payloads are pathologically slow over the Capacitor SQLite bridge), so NDJSON-HTTP is the default and effectively the only viable path for Capacitor iOS users. Everyone therefore pays this ~21s on first sync.
Environment
|
|
@powersync/capacitor |
0.6.1 |
@powersync/web |
1.38.2 |
@capacitor-community/sqlite |
8.1.0 |
@capacitor/core |
8.3.4 |
| Platform |
iOS 26.5, iPhone 17 Pro simulator |
| Build config |
Release |
| Backend |
PowerSync Cloud |
Steps to reproduce
- Build a Capacitor app using
CapacitorSQLiteAdapter (default native adapter).
- Fully uninstall the app from the device/simulator so the SQLite DB and localStorage are empty (a mere relaunch is not enough — the stall only affects a genuinely fresh client).
- Install, launch, authenticate, and let the first sync run.
- Observe
status.downloadProgress.downloadedOperations over time.
- Repeat with
connectOptions: { connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET }.
Observed
Cold first sync, two accounts of different checkpoint sizes:
| first checkpoint |
NDJSON-HTTP (default) |
WEB_SOCKET |
| 154 ops |
0/154 for ~21s, then hasSynced at 22.7s |
steady ticks 27→50→86→124→154, hasSynced at 9.7s |
| 3993 ops |
775 ops at 1.3s, ~21s gap, then remainder — hasSynced at 22.7s |
reached only 1000/3993 after 117.6s, never completed |
NDJSON-HTTP, four consecutive cold runs on the 3993-op account:
| run |
775 ops at |
resumes at |
hasSynced |
gap |
| 1 |
+1.49s |
+22.4s |
22.7s |
20.9s |
| 2 |
+1.23s |
+21.7s |
21.9s |
20.4s |
| 3 |
+1.25s |
+22.0s |
22.4s |
20.7s |
| 4 |
+1.44s |
+22.8s |
23.1s |
21.4s |
Warm database (same build, DB already populated, app relaunched): 2547 ops applied in 0.9s, no gap.
No downloadError and no reconnects are recorded in any run. retryDelayMs is at its 5000ms default, so this is not a client retry backoff.
Expected
NDJSON-HTTP first sync should stream incrementally from the start, as WebSocket does, rather than pausing ~21s.
Hypothesis (unconfirmed)
The shape is consistent with WKWebView buffering the fetch response ReadableStream and flushing only when an internal buffer fills or an idle timeout (~20s) expires:
- The 3993-op payload fills the buffer once — the first ~775 ops flush at 1.3s — but the remainder is too small to fill it again and waits out the timeout.
- The 154-op payload never fills the buffer at all, so nothing is delivered until the timeout fires.
Both therefore land at ~22s despite a 26x difference in payload size. WebSocket is unaffected because it does not go through fetch at all.
If that is the mechanism, emitting periodic keepalive/padding bytes on the /sync/stream response would flush the buffer and remove the stall for all Capacitor iOS clients, without any client change.
Impact
Capacitor iOS users face a choice between two bad options on first sync:
- NDJSON-HTTP — fixed ~21s stall, but ~2350 ops/sec once streaming.
- WEB_SOCKET — no stall, but ~10–20 ops/sec sustained, so it becomes unusable on any real dataset (a 3993-op checkpoint had not finished after two minutes; on a physical device an 11.6k-op checkpoint took ~6 minutes).
The crossover is around 300 operations. Below that WebSocket is faster; above it, NDJSON-HTTP is faster and the margin widens quickly. There is currently no configuration that avoids both problems.
Summary
On the Capacitor SDK on iOS, the first sync over the default NDJSON-HTTP connection method stalls for a fixed ~21s before any (or any further) operations are applied. The same account on
SyncStreamConnectionMethod.WEB_SOCKETshows no stall at all — progress ticks incrementally from the first second.The stall is a fixed cost, independent of checkpoint size: a 154-op first checkpoint and a 3993-op first checkpoint both complete at ~22.7s. It reproduces 4/4 on cold runs and disappears entirely on a warm database.
This matters because
CapacitorRemote.supportsStreamingBinaryResponsesreturnsfalse(correctly — binary payloads are pathologically slow over the Capacitor SQLite bridge), so NDJSON-HTTP is the default and effectively the only viable path for Capacitor iOS users. Everyone therefore pays this ~21s on first sync.Environment
@powersync/capacitor@powersync/web@capacitor-community/sqlite@capacitor/coreSteps to reproduce
CapacitorSQLiteAdapter(default native adapter).status.downloadProgress.downloadedOperationsover time.connectOptions: { connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET }.Observed
Cold first sync, two accounts of different checkpoint sizes:
0/154for ~21s, thenhasSyncedat 22.7shasSyncedat 9.7shasSyncedat 22.7sNDJSON-HTTP, four consecutive cold runs on the 3993-op account:
hasSyncedWarm database (same build, DB already populated, app relaunched): 2547 ops applied in 0.9s, no gap.
No
downloadErrorand no reconnects are recorded in any run.retryDelayMsis at its 5000ms default, so this is not a client retry backoff.Expected
NDJSON-HTTP first sync should stream incrementally from the start, as WebSocket does, rather than pausing ~21s.
Hypothesis (unconfirmed)
The shape is consistent with WKWebView buffering the
fetchresponseReadableStreamand flushing only when an internal buffer fills or an idle timeout (~20s) expires:Both therefore land at ~22s despite a 26x difference in payload size. WebSocket is unaffected because it does not go through
fetchat all.If that is the mechanism, emitting periodic keepalive/padding bytes on the
/sync/streamresponse would flush the buffer and remove the stall for all Capacitor iOS clients, without any client change.Impact
Capacitor iOS users face a choice between two bad options on first sync:
The crossover is around 300 operations. Below that WebSocket is faster; above it, NDJSON-HTTP is faster and the margin widens quickly. There is currently no configuration that avoids both problems.