When working with REST APIs, you run into over-fetching (pulling too much data) and under-fetching (missing data, N+1 requests). GraphQL lets the client request exactly the data it needs. With the Apollo iOS client, you can use type-safe GraphQL in Swift.
💡 Quick Note: Apollo iOS 1.0+ provides full type safety through Swift code generation. This guide covers the Apollo iOS 1.x APIs.
Table of Contents
- GraphQL vs REST
- Apollo iOS Setup
- Schema and Code Generation
- Query and Mutation
- Caching Strategies
- Pagination
- Optimistic UI
- Subscriptions (Real-time)
- Error Handling
- Using Fragments
- Auth Token with a Custom Interceptor
- Best Practices
GraphQL vs REST
Feature | REST | GraphQL |
|---|---|---|
Data fetching | Fixed endpoints | Flexible queries |
Over-fetching | ✅ Common | ❌ None |
Under-fetching | ✅ N+1 requests | ❌ Single query |
Versioning | /v1, /v2 | Schema evolution |
Caching | HTTP cache (basic) | Normalized cache |
Type safety | Manual | Automatic (codegen) |
Tooling | Swagger/OpenAPI | GraphiQL, Apollo Studio |
Apollo iOS Setup
1// Package.swift2dependencies: [3 .package(url: "https://github.com/apollographql/apollo-ios.git", from: "1.0.0"),4]5// Target dependency: "Apollo", "ApolloWebSocket"1// Apollo client setup2import Apollo3 4class Network {5 static let shared = Network()6 let apollo: ApolloClient7 8 init() {9 let url = URL(string: "https://api.example.com/graphql")!10 let store = ApolloStore(cache: InMemoryNormalizedCache())11 let provider = DefaultInterceptorProvider(store: store)12 let transport = RequestChainNetworkTransport(13 interceptorProvider: provider,14 endpointURL: url15 )16 apollo = ApolloClient(networkTransport: transport, store: store)17 }18}Schema and Code Generation
1# Queries/GetProducts.graphql2query GetProducts($first: Int!, $after: String) {3 products(first: $first, after: $after) {4 edges {5 node {6 id7 name8 price9 category {10 id11 name12 }13 reviews {14 rating15 text16 }17 }18 }19 pageInfo {20 hasNextPage21 endCursor22 }23 }24}25 26# Mutations/CreateOrder.graphql27mutation CreateOrder($input: CreateOrderInput!) {28 createOrder(input: $input) {29 id30 status31 total32 items {33 product { name }34 quantity35 }36 }37}Code generation automatically produces Swift types. It's run with the apollo-ios-cli generate command.
Query and Mutation
1// Query2func fetchProducts() async throws -> [GetProductsQuery.Data.Products.Edge.Node] {3 return try await withCheckedThrowingContinuation { continuation in4 Network.shared.apollo.fetch(5 query: GetProductsQuery(first: 20, after: nil),6 cachePolicy: .fetchIgnoringCacheCompletely7 ) { result in8 switch result {9 case .success(let response):10 let products = response.data?.products.edges.map { $0.node } ?? []11 continuation.resume(returning: products)12 case .failure(let error):13 continuation.resume(throwing: error)14 }15 }16 }17}18 19// Mutation20func createOrder(items: [OrderItemInput]) async throws -> CreateOrderMutation.Data.CreateOrder {21 let input = CreateOrderInput(items: items)22 return try await withCheckedThrowingContinuation { continuation in23 Network.shared.apollo.perform(mutation: CreateOrderMutation(input: input)) { result in24 switch result {25 case .success(let response):26 if let order = response.data?.createOrder {27 continuation.resume(returning: order)28 }29 case .failure(let error):30 continuation.resume(throwing: error)31 }32 }33 }34}Caching Strategies
Apollo iOS uses a normalized cache — every object is stored by its id and updated automatically:
1// Cache policy options2.returnCacheDataElseFetch // Cache first, network if missing3.fetchIgnoringCacheData // Always network4.returnCacheDataDontFetch // Cache only5.returnCacheDataAndFetch // Show cache, update in backgroundPagination
Cursor-based pagination for infinite scroll:
1class ProductListViewModel: ObservableObject {2 @Published var products: [ProductNode] = []3 private var endCursor: String?4 private var hasNextPage = true5 6 func loadMore() async {7 guard hasNextPage else { return }8 let result = try? await fetchProducts(after: endCursor)9 products.append(contentsOf: result?.nodes ?? [])10 endCursor = result?.pageInfo.endCursor11 hasNextPage = result?.pageInfo.hasNextPage ?? false12 }13}Optimistic UI
1// Update the UI without waiting for the mutation result2Network.shared.apollo.perform(3 mutation: ToggleFavoriteMutation(productId: id),4 optimisticUpdate: { cache in5 // Update the cache immediately6 try? cache.updateObject(ofType: ProductNode.self, withKey: "Product:\(id)") { product in7 product.isFavorite = !product.isFavorite8 }9 }10)Subscriptions (Real-time)
1// Real-time updates via WebSocket2let subscription = Network.shared.apollo.subscribe(3 subscription: OrderStatusSubscription(orderId: orderId)4) { result in5 switch result {6 case .success(let response):7 if let status = response.data?.orderStatusChanged.status {8 self.orderStatus = status9 }10 case .failure(let error):11 print("Subscription error: \(error)")12 }13}Error Handling
GraphQL partial errors — some fields can succeed while others fail:
1func handleResult(_ result: GraphQLResult<GetProductsQuery.Data>) {2 if let errors = result.errors {3 for error in errors {4 print("GraphQL Error: \(error.message ?? "Unknown")")5 // The path shows which field failed6 print("Path: \(error.path ?? [])")7 }8 }9 if let data = result.data {10 // Partial data is usable11 self.products = data.products.edges.map { $0.node }12 }13}Using Fragments
Fragments let you share repeated groups of fields — they apply the DRY principle in GraphQL:
1# Fragments/ProductFields.graphql2fragment ProductFields on Product {3 id4 name5 price6 imageUrl7 category {8 id9 name10 }11}12 13# Fragments/UserFields.graphql14fragment UserFields on User {15 id16 name17 email18 avatar19}20 21# Queries/GetProductDetail.graphql22query GetProductDetail($id: ID!) {23 product(id: $id) {24 ...ProductFields25 description26 stock27 reviews {28 id29 rating30 text31 author {32 ...UserFields33 }34 }35 }36}37 38# Queries/GetCart.graphql39query GetCart {40 cart {41 items {42 quantity43 product {44 ...ProductFields45 }46 }47 totalPrice48 }49}Benefits of using fragments:
Benefit | Description |
|---|---|
Prevents code duplication | You don't rewrite the same fields in every query |
Type consistency | Code generation turns fragments into separate Swift structs |
Easier maintenance | Adding/removing fields happens in one place |
Cache compatibility | Apollo's normalized cache automatically maps fragments |
Auth Token with a Custom Interceptor
You can add an auth token to every request automatically using the Apollo iOS interceptor chain:
1// AuthInterceptor.swift2class AuthInterceptor: ApolloInterceptor {3 let id = "AuthInterceptor"4 private let tokenProvider: TokenProvider5 6 init(tokenProvider: TokenProvider) {7 self.tokenProvider = tokenProvider8 }9 10 func interceptAsync<Operation: GraphQLOperation>(11 chain: RequestChain,12 request: HTTPRequest<Operation>,13 response: HTTPResponse<Operation>?,14 completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void15 ) {16 Task {17 if let token = try? await tokenProvider.getValidToken() {18 request.addHeader(name: "Authorization", value: "Bearer \(token)")19 }20 chain.proceedAsync(request: request, response: response, interceptor: self, completion: completion)21 }22 }23}24 25// LoggingInterceptor - for debugging26class LoggingInterceptor: ApolloInterceptor {27 let id = "LoggingInterceptor"28 29 func interceptAsync<Operation: GraphQLOperation>(30 chain: RequestChain,31 request: HTTPRequest<Operation>,32 response: HTTPResponse<Operation>?,33 completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void34 ) {35 let operationName = Operation.operationName36 let startTime = CFAbsoluteTimeGetCurrent()37 print("[GraphQL] --> \(operationName)")38 39 chain.proceedAsync(request: request, response: response, interceptor: self) { result in40 let duration = CFAbsoluteTimeGetCurrent() - startTime41 switch result {42 case .success:43 print("[GraphQL] <-- \(operationName) (\(String(format: "%.2f", duration * 1000))ms)")44 case .failure(let error):45 print("[GraphQL] <-- \(operationName) FAIL: \(error)")46 }47 completion(result)48 }49 }50}51 52// Custom InterceptorProvider53class NetworkInterceptorProvider: DefaultInterceptorProvider {54 let tokenProvider: TokenProvider55 56 init(store: ApolloStore, tokenProvider: TokenProvider) {57 self.tokenProvider = tokenProvider58 super.init(store: store)59 }60 61 override func interceptors<Operation: GraphQLOperation>(62 for operation: Operation63 ) -> [ApolloInterceptor] {64 var interceptors = super.interceptors(for: operation)65 interceptors.insert(AuthInterceptor(tokenProvider: tokenProvider), at: 0)66 interceptors.insert(LoggingInterceptor(), at: 0)67 return interceptors68 }69}Best Practices
- Use fragments — share repeated fields
- Persisted queries — don't send the query string in production, send a hash
- Batch queries — send multiple queries in a single request
- Code generation in CI — regenerate automatically on every schema change
- Error boundary — partial data handling
- Interceptor chain — write auth, logging, and retry logic as interceptors
- Fragment colocation — place fragments next to the view that uses them
- Query complexity — avoid deeply nested queries, set a server-side depth limit
Easter Egg
You found a hidden gem!
There's a hidden detail in this section. Want to uncover it?
GOLDEN TIP
The most valuable insight in this article
This tip holds the article's most important takeaway.
Reader Reward
Congratulations! Since you read this article all the way to the end, I have a special gift for you:
Sources:
Tags
iOS Development News
Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.
We respect your privacy. You can unsubscribe at any time.

