Skip to content

Performance regression: async watch() causes UI lag when re-watching queries in SwiftUIΒ #3622

@ryusei1026

Description

@ryusei1026

Description

In Apollo iOS 2.0, the watch() method became an async function, which causes a noticeable UI lag (~300ms) in SwiftUI apps. The lag occurs even when all watch() calls are disabled, suggesting a deeper architectural issue with how Apollo iOS 2.0 interacts with SwiftUI's rendering system.

Environment

  • Apollo iOS: 2.0
  • Swift: 6.2.1
  • iOS: 16.4+
  • UI Framework: SwiftUI

Current Behavior (Apollo iOS 2.0)

When a user taps a tab to switch views, there's a noticeable ~300ms delay before the UI updates (both tab indicator and content).

Usage Pattern

We use two different Apollo clients with separate Query instances throughout our app:

Page Level:

struct MyPage: View {
    @StateObject private var queryA = Query<QueryTypeA>(client: .apolloClientA)
    // ...
}

private struct TabContainer: View {
    @StateObject private var queryA = Query<QueryTypeA>(client: .apolloClientA)
    @StateObject private var queryB = Query<QueryTypeB>(client: .apolloClientB)
    // ...

    var body: some View {
        // Tab switching UI
        TabView() {
            ContentBlock()
        }
        .onFirstAppear {
            queryB.watch(query: ..., cachePolicy: .cacheFirst)
        }
    }
}

Child Component Level:

struct ContentBlock: View {
    @StateObject private var queryB = Query<QueryTypeB>(client: .apolloClientB)

    var body: some View {
        // Content rendering
        .onFirstAppear {
            queryB.watch(query: ..., cachePolicy: .cacheFirst)
        }
    }
}

We have:

  • Client A: Custom backend API
  • Client B: Third-party GraphQL API (Shopify Storefront API)

Both types exhibit the same lag issue.

Our Query Wrapper Implementation

@MainActor final class Query<Query: ApolloAPI.GraphQLQuery>: ObservableObject {
    @Published private(set) var response: Query.Data?
    private var watcher: GraphQLQueryWatcher<Query>?

    func watch(query: Query, cachePolicy: CachePolicy.Query.SingleResponse) {
        let oldWatcher = watcher
        fetching = true

        Task(priority: .userInitiated) { @MainActor in
            oldWatcher?.cancel()

            // Even with immediate cache read, there's still UI lag
            if let cachedData = await apolloClient.readCacheImmediately(query: query, cachePolicy: cachePolicy) {
                self.response = cachedData
                self.fetching = false
            }

            let newWatcher = await apolloClient.watch(query: query, cachePolicy: cachePolicy, resultHandler: { ... })
            self.watcher = newWatcher
        }
    }
}

Measured timings:

  • onTap completion: 0.14ms - 0.35ms (fast)
  • Cache read: 8.4ms (fast)
  • User perception: ~300ms delay (slow)

The delay occurs between the @Published property update and SwiftUI rendering.

Expected Behavior (Apollo iOS 1.x)

In Apollo iOS 1.x, watch() was a synchronous function that returned immediately:

func watch(query: Query, cachePolicy: CachePolicy) {
    if let watcher { watcher.cancel() }
    fetching = true

    watcher = apolloClient.watch(query: query, cachePolicy: cachePolicy, resultHandler: { ... })
    // ↑ Returns immediately, no Task needed
}

Result: UI updates instantly with no perceivable delay.

Steps to Reproduce

  1. Create a SwiftUI view with tab navigation
  2. Use multiple @StateObject Query instances at different levels (page level and child components)
  3. Call watch() when tabs appear
  4. Switch tabs and observe ~300ms delay

Key Finding: Lag Persists Even Without watch()

Critical Discovery:

  • Commented out all watch() calls across the entire page hierarchy (both client A and client B queries)
  • The ~300ms UI lag still occurs when switching tabs
  • This suggests the issue is not just with watch() itself, but with the Query class implementation or its interaction with SwiftUI

Attempted Fixes (No improvement)

  • βœ… Immediate cache reading before starting watch
  • βœ… Using Task(priority: .userInitiated)
  • βœ… Explicit @MainActor annotation
  • βœ… Removing animations
  • βœ… Disabling all watch() calls ← Still has lag!

Root Cause Analysis

Apollo iOS 1.x:

// ApolloClient.swift
func watch(...) -> Apollo.Cancellable {  // ← Synchronous
    client.watch(query: query, cachePolicy: cachePolicy, resultHandler: { ... })
}

Apollo iOS 2.0:

// ApolloClient.swift
func watch(...) async -> GraphQLQueryWatcher<Query> {  // ← Async
    let watcher = await client.watch(...)
    return watcher
}

Because watch() is now async, it must be called within a Task. However, even with watch() completely disabled, the lag persists, suggesting the issue goes deeper than just the watch() implementation.

Hypothesis

The async architecture of Apollo iOS 2.0 may be affecting SwiftUI's rendering pipeline in fundamental ways:

  1. The @StateObject Query instances might be impacting SwiftUI's view diffing performance
  2. The @MainActor isolation and Task-based implementation could be interfering with SwiftUI's main thread scheduling
  3. The @Published property update timing/frequency might have changed
  4. Something about the Query class or Apollo iOS 2.0 architecture fundamentally impacts SwiftUI performance, independent of whether watch() is actually called

Proposed Solution

This appears to be a deeper architectural issue with how Apollo iOS 2.0 interacts with SwiftUI's rendering system. Possible solutions:

  1. Investigate how Apollo iOS 2.0's async architecture affects SwiftUI's main thread scheduling
  2. Provide guidance on optimal SwiftUI integration patterns for Apollo iOS 2.0
  3. Consider offering a synchronous wrapper or alternative API for performance-critical UI contexts

Workaround

Currently considering downgrading to Apollo iOS 1.x until this performance issue is addressed, as the lag significantly impacts user experience.

Related

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