| name | graphql-for-mobile |
| description | GraphQL server design tuned for mobile -- persisted queries, batching, N+1 mitigation, and Apollo client integration. Use when building or reviewing a GraphQL API for mobile apps. |
GraphQL for Mobile
Instructions
GraphQL's value for mobile is tight payload shaping and cross-resource composition in one round-trip. Its risks are unbounded queries, N+1 database loads, and shipping raw queries from the app. Mitigate all three.
1. Schema Design
Favor a graph of small, stable types over RPC-style fields.
type Article {
id: ID!
title: String!
summary: String
author: User!
comments(first: Int = 20, after: String): CommentConnection!
publishedAt: DateTime!
}
type Query {
article(id: ID!): Article
feed(first: Int = 20, after: String, filter: FeedFilter): ArticleConnection!
}
Use Relay-style connections (Connection, Edge, PageInfo) for all lists. Cursors are opaque, page size defaults to 20, maxes at 100.
2. Persisted Queries
Do not let the mobile client send arbitrary GraphQL strings in production. Use persisted queries: at build time, every query is hashed and registered on the server; the app sends only the hash.
POST /graphql
{"id": "sha256:ab12...", "variables": {"id": "a_1"}}
Benefits: smaller requests, query allowlisting, CDN-cacheable reads (GET with hash), no accidental schema exploration from shipped binaries.
Apollo iOS / Apollo Kotlin generate persisted IDs via their build plugins; wire them into the pipeline.
3. N+1 Mitigation with Dataloader
Resolvers that fetch child objects trigger one DB hit per parent unless batched.
const userLoader = new DataLoader<string, User>(async (ids) => {
const users = await db.users.findMany({ where: { id: { in: ids as string[] } } });
const byId = new Map(users.map(u => [u.id, u]));
return ids.map(id => byId.get(id)!);
});
const resolvers = {
Article: {
author: (article, _, ctx) => ctx.loaders.user.load(article.authorId),
},
};
Every request builds a fresh loader set (never share across requests -- caches must not leak between users).
4. Query Cost and Depth Limits
Reject expensive queries before resolving them.
- Max depth: 8–10. Reject deeper.
- Max complexity: compute a weighted cost (lists multiply by
first).
- Max aliases / duplicate fields: cap to prevent amplification attacks.
Libraries: graphql-depth-limit, graphql-query-complexity (Node); graphql-java cost analyzer.
5. Mutations
Mutations take a single input object and return a typed payload with a result union or an errors list:
input CreateCommentInput { articleId: ID!, body: String!, clientMutationId: String }
type CreateCommentPayload { comment: Comment, userErrors: [UserError!]! }
type Mutation { createComment(input: CreateCommentInput!): CreateCommentPayload! }
userErrors surfaces validation errors without failing the whole HTTP request. HTTP 200 is returned; only infrastructure failures become non-200. Include clientMutationId to pair optimistic UI updates with server responses.
6. Subscriptions
Prefer WebSockets (graphql-ws) for realtime. Keep subscription payloads small and resumable via a lastEventId. For most mobile apps, polling or periodic refetch is simpler than subscriptions -- reach for subs only when sub-second latency matters (chat, live scores, collaborative edit).
7. Caching
- HTTP-level: persisted-query GETs are CDN-cacheable by query hash + variables hash.
- Client-level: Apollo Normalized Cache keyed by
__typename:id. Every type that can appear in multiple queries must expose id and __typename.
- Ensure stable
id fields; never reuse ids across types without a composite key.
8. Apollo Client on Mobile
Apollo Kotlin (Android):
val apollo = ApolloClient.Builder()
.serverUrl("https://api.example.com/graphql")
.autoPersistedQueries()
.normalizedCache(MemoryCacheFactory(maxSizeBytes = 10 * 1024 * 1024))
.addHttpInterceptor(AuthHeaderInterceptor(tokenStore))
.build()
val feed = apollo.query(FeedQuery(first = 20)).fetchPolicy(FetchPolicy.CacheAndNetwork).execute()
Apollo iOS has an equivalent API (ApolloClient, NormalizedCache, RequestChainNetworkTransport).
9. Error Handling
GraphQL returns 200 even for partial failures. The client must inspect both errors (top-level GraphQL errors) and data. Map stable error codes through extensions.code so clients can act (UNAUTHENTICATED, RATE_LIMITED, VALIDATION_FAILED).
Checklist