-
Notifications
You must be signed in to change notification settings - Fork 748
Description
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:
onTapcompletion: 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
- Create a SwiftUI view with tab navigation
- Use multiple
@StateObjectQuery instances at different levels (page level and child components) - Call
watch()when tabs appear - 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
@MainActorannotation - β 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:
- The
@StateObjectQuery instances might be impacting SwiftUI's view diffing performance - The
@MainActorisolation and Task-based implementation could be interfering with SwiftUI's main thread scheduling - The
@Publishedproperty update timing/frequency might have changed - 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:
- Investigate how Apollo iOS 2.0's async architecture affects SwiftUI's main thread scheduling
- Provide guidance on optimal SwiftUI integration patterns for Apollo iOS 2.0
- 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
- Swift 6.2 MainActor support: [Feature] Support Swift 6.2 Default Actor Isolation - MainActorΒ #3601
- Apollo iOS 2.0 Swift Concurrency RFC: RFC: 2.0 API Changes - Swift ConcurrencyΒ #3411