Environment
- Client: powersync-swift 1.14.4 (also reproduced on 1.14.3), iOS app, HTTP sync stream
- PowerSync Service: 1.23.2, self-hosted (Docker via Coolify), MongoDB bucket storage
- Source DB: Postgres (self-hosted Supabase), Supabase JWT auth
- Sync config: Sync Streams, edition 3, no subscription parameters used (
params: [:])
Context
I am testing the Sync Stream TTL mechanism in a small counter test app before using it
in a production app. For this test app alone, auto_subscribe: true would be perfectly
fine (small dataset, always needed). But in the production app I want on-demand
subscriptions with TTL so that stale local data gets cleaned up automatically and is
re-fetched from the backend when needed again.
The TTL eviction itself works as expected. The problem is what happens after the
eviction: once the local cache has been cleared, it takes ~20 seconds (sometimes more,
sometimes less) until the data is re-downloaded from the server into the local SQLite
database — during which the connection reports connected=true but nothing is delivered.
Important: This does NOT affect the normal connect() path. Initial sync with
auto_subscribe: true is very fast, and subscribing before connect() is also fast.
The stall occurs specifically in the TTL flow: subscription expired → bucket cleared
locally → re-subscribe on the live connection → long wait for the re-fetch.
Stream definition
streams:
counters:
# no auto_subscribe → on-demand, client subscribes explicitly
query: SELECT * FROM public.counters WHERE user_id = auth.user_id()
users:
auto_subscribe: true
query: SELECT * FROM public.users WHERE id = auth.user_id()
Client code
Subscriptions are managed via a small wrapper. The default TTL is 7 days; for testing
I set it to 60 seconds (also tested with 5 minutes — same behavior; with 7 days it
would be the same, just after 7 days):
import Foundation
import PowerSync
@Observable
@MainActor
final class StreamSubscriptionManager {
static let shared = StreamSubscriptionManager()
nonisolated static let defaultTTL: TimeInterval = 7 * 24 * 60 * 60 // 7 days; tests override via the ttl parameter (e.g. 60s)
@ObservationIgnored
private var subscriptions: [String: any SyncStreamSubscription] = [:]
private var database: (any PowerSyncDatabaseProtocol)? {
PowerSyncStore.shared.database
}
private init() {}
func subscribe(
_ streamName: String,
ttl: TimeInterval = StreamSubscriptionManager.defaultTTL
) async {
guard subscriptions[streamName] == nil else { return }
guard let database else { return }
do {
subscriptions[streamName] = try await database
.syncStream(name: streamName, params: [:])
.subscribe(ttl: ttl, priority: nil)
} catch {
print("Could not subscribe to \(streamName): \(error.localizedDescription)")
}
}
func unsubscribe(_ streamName: String) async {
if let sub = subscriptions[streamName] {
try? await sub.unsubscribe()
subscriptions.removeValue(forKey: streamName)
}
}
func unsubscribeAll() async {
for (_, sub) in subscriptions {
try? await sub.unsubscribe()
}
subscriptions.removeAll()
}
}
Connecting follows the standard pattern from the docs (Supabase connector providing
endpoint + access token via fetchCredentials):
func connect() async {
guard let database else { return }
do {
try await database.connect(connector: connector)
} catch {
self.error = error
print("Could not connect: \(error.localizedDescription)")
}
}
App flow: connect() runs first, then subscribe("counters", ttl: 60). On logout,
unsubscribeAll() is called (which starts the TTL), followed by disconnect().
Reproduction
- Log in, subscribe to
counters with ttl: 60. Data syncs and displays normally.
- Log out (
unsubscribeAll() + disconnect()) — the TTL starts.
- Wait > 60 seconds.
- Log back in or fully restart the app (killed, not backgrounded) and let it
connect + subscribe again.
- Shortly after connecting, the expired bucket is cleared locally (expected —
the local table drops to 0 rows).
- The re-fetch then stalls for ~20 seconds before the data arrives from the
server. During the stall, sync status reports connected=true,
downloading=false. Then the client appears to reconnect internally, and the
data arrives immediately after.
This happens consistently on both paths (fresh login after TTL expiry, and full app
restart after TTL expiry).
Logs
Notes on the log lines:
- Lines starting with
🟣 / 🔵 / 🔌 [Counters] / [Status] are my own debug prints
(a SQLite watch query on the counters table, and a 1s poller logging every change
of database.currentStatus).
- Lines starting with
[StreamingSyncClient] come from the PowerSync SDK itself.
🟣 [Counters] emission #2 @ 18:58:51 — 0 rows → cache cleared mid-watch (expected TTL eviction)
[StreamingSyncClient] Validated and applied checkpoint
[StreamingSyncClient] crud upload: notify completion
🔌✅ [Status] @ 18:58:51 — connected=true connecting=false downloading=false hasSynced=true
← ~19 seconds, connection stays connected=true, nothing is delivered →
🔵 [Counters] ✅ emission #3 @ 18:59:10 — 21 rows → data re-downloaded from server and stored locally
[StreamingSyncClient] crud upload: notify completion
[StreamingSyncClient] Validated and applied checkpoint
An earlier run of the same repro additionally captured the status flip right before
the data arrived, showing the internal reconnect:
18:28:50 emission — 0 rows (bucket cleared)
18:28:51 connected=true, connecting=false, downloading=false
← ~20s, nothing received →
18:29:11 connected=false, connecting=true ← spontaneous internal reconnect
18:29:11 emission — 21 rows (data arrives instantly after the reconnect)
18:29:12 connected=true
What I ruled out
- Bucket operation history / download size: Ran the service
compact command
(removed 231 stale ops from the bucket) and defragmented via no-op row touches.
No change.
- Service version / reverse proxy buffering: Upgraded the service 1.21.0 → 1.23.2
(which includes the X-Accel-Buffering fix for /sync/stream). No change.
retryDelay: Setting retryDelay: 1 on connect() made no difference — the
connection never enters a retry state during the stall (status stays
connected=true the whole time).
- Server-side checkpoint idleness: Writing a row to Postgres during the stall
does not unblock it.
- Control cases: With
auto_subscribe: true, or when calling subscribe()
before connect(), everything is instant. The stall only occurs when the
active subscription set changes on a live connection (which is inherent to the
TTL-eviction → re-subscribe flow).
Suspicion
The behavior matches issues that were fixed in the sibling SDKs but may not be
ported to Swift yet:
- Dart: "Fix changes in active Sync Stream subscriptions causing a reconnect
delay" — powersync.dart PR #410 (closes #409): the sync client now breaks out
of the iteration when the core extension requests a close, instead of posting
AbortCurrentIteration and racing while new streams attach.
- Kotlin: reconnect-delay fix for prefetched tokens (PR #343), same underlying
pattern of a stop command causing the iteration to pause and wait out a delay.
It looks like the Swift sync client gets stuck in the old iteration after the
subscription set changes, until an internal reconnect (visible in the logs above)
forces a new /sync/stream request — at which point the data arrives immediately.
Happy to provide more logs (including service-side logs) or test a fix. Thanks!
Environment
params: [:])Context
I am testing the Sync Stream TTL mechanism in a small counter test app before using it
in a production app. For this test app alone,
auto_subscribe: truewould be perfectlyfine (small dataset, always needed). But in the production app I want on-demand
subscriptions with TTL so that stale local data gets cleaned up automatically and is
re-fetched from the backend when needed again.
The TTL eviction itself works as expected. The problem is what happens after the
eviction: once the local cache has been cleared, it takes ~20 seconds (sometimes more,
sometimes less) until the data is re-downloaded from the server into the local SQLite
database — during which the connection reports
connected=truebut nothing is delivered.Important: This does NOT affect the normal
connect()path. Initial sync withauto_subscribe: trueis very fast, and subscribing beforeconnect()is also fast.The stall occurs specifically in the TTL flow: subscription expired → bucket cleared
locally → re-subscribe on the live connection → long wait for the re-fetch.
Stream definition
Client code
Subscriptions are managed via a small wrapper. The default TTL is 7 days; for testing
I set it to 60 seconds (also tested with 5 minutes — same behavior; with 7 days it
would be the same, just after 7 days):
Connecting follows the standard pattern from the docs (Supabase connector providing
endpoint + access token via
fetchCredentials):App flow:
connect()runs first, thensubscribe("counters", ttl: 60). On logout,unsubscribeAll()is called (which starts the TTL), followed bydisconnect().Reproduction
counterswithttl: 60. Data syncs and displays normally.unsubscribeAll()+disconnect()) — the TTL starts.connect + subscribe again.
the local table drops to 0 rows).
server. During the stall, sync status reports
connected=true,downloading=false. Then the client appears to reconnect internally, and thedata arrives immediately after.
This happens consistently on both paths (fresh login after TTL expiry, and full app
restart after TTL expiry).
Logs
Notes on the log lines:
🟣 / 🔵 / 🔌 [Counters] / [Status]are my own debug prints(a SQLite watch query on the
counterstable, and a 1s poller logging every changeof
database.currentStatus).[StreamingSyncClient]come from the PowerSync SDK itself.An earlier run of the same repro additionally captured the status flip right before
the data arrived, showing the internal reconnect:
What I ruled out
compactcommand(removed 231 stale ops from the bucket) and defragmented via no-op row touches.
No change.
(which includes the
X-Accel-Bufferingfix for/sync/stream). No change.retryDelay: SettingretryDelay: 1onconnect()made no difference — theconnection never enters a retry state during the stall (status stays
connected=truethe whole time).does not unblock it.
auto_subscribe: true, or when callingsubscribe()before
connect(), everything is instant. The stall only occurs when theactive subscription set changes on a live connection (which is inherent to the
TTL-eviction → re-subscribe flow).
Suspicion
The behavior matches issues that were fixed in the sibling SDKs but may not be
ported to Swift yet:
delay" — powersync.dart PR #410 (closes #409): the sync client now breaks out
of the iteration when the core extension requests a close, instead of posting
AbortCurrentIterationand racing while new streams attach.pattern of a stop command causing the iteration to pause and wait out a delay.
It looks like the Swift sync client gets stuck in the old iteration after the
subscription set changes, until an internal reconnect (visible in the logs above)
forces a new
/sync/streamrequest — at which point the data arrives immediately.Happy to provide more logs (including service-side logs) or test a fix. Thanks!