Skip to content

Capacitor iOS: NDJSON-HTTP first sync has a fixed ~21s stall that WebSocket does not #1063

Description

@Micromanner

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

  1. Build a Capacitor app using CapacitorSQLiteAdapter (default native adapter).
  2. 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).
  3. Install, launch, authenticate, and let the first sync run.
  4. Observe status.downloadProgress.downloadedOperations over time.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions