Skip to content

~20s stall when re-fetching data after TTL eviction of a sync stream subscription (connection stays connected=true, no data delivered) #157

Description

@elcode100

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

  1. Log in, subscribe to counters with ttl: 60. Data syncs and displays normally.
  2. Log out (unsubscribeAll() + disconnect()) — the TTL starts.
  3. Wait > 60 seconds.
  4. Log back in or fully restart the app (killed, not backgrounded) and let it
    connect + subscribe again.
  5. Shortly after connecting, the expired bucket is cleared locally (expected —
    the local table drops to 0 rows).
  6. 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!

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