GraphQL
GraphQL from the frontend — schema thinking, fragments, caching, codegen, client comparison
GraphQL
GraphQL solves a specific set of problems — over-fetching, multi-client schemas, deeply nested data — and introduces a different set in return: cache complexity, learning curve, server-side N+1 risk that becomes a frontend problem when ignored.
When GraphQL Earns Its Keep
| Scenario | GraphQL fit |
|---|---|
| Many clients (web, iOS, Android) read the same backend | High |
| Highly nested or aggregated reads (dashboards, profile screens) | High |
| Need to evolve schema with versionless deprecation | High |
| Mostly simple CRUD on a single client | Low — REST is fine |
| File uploads as primary use case | Low — REST handles this better |
| Public API consumed by unknown third parties | Moderate — REST is friendlier |
If you're already on REST and shipping fine, GraphQL is rarely worth migrating to alone. The win shows up when multiple clients consume the same backend.
Schema-First Thinking
type Order {
id: ID!
status: OrderStatus!
total: Money!
customer: Customer!
items: [OrderItem!]!
createdAt: DateTime!
}
type Query {
order(id: ID!): Order
orders(after: String, first: Int = 20, status: OrderStatus): OrderConnection!
}
type Mutation {
cancelOrder(id: ID!): Order!
updateOrder(id: ID!, input: UpdateOrderInput!): Order!
}As a frontend engineer you ask of every query: what is the smallest set of fields this screen actually needs? The temptation to fetch the whole object is the over-fetching that GraphQL was meant to eliminate.
Queries: Ask for What You Need
query OrderDetail($id: ID!) {
order(id: $id) {
id
status
total { amount currency }
customer { id name }
items {
id quantity
product { id name imageUrl }
}
}
}Fragments — Reusable Field Sets
fragment OrderRow on Order {
id
status
total { amount currency }
createdAt
}
query OrderList {
orders(first: 20) {
edges { node { ...OrderRow } }
}
}
query OrderDetail($id: ID!) {
order(id: $id) {
...OrderRow
customer { id name }
items { id quantity }
}
}Co-locate the fragment with the component that consumes it. When the component needs a new field, you change one place.
Codegen Is Not Optional
# codegen.yml
schema: https://api.example.com/graphql
documents:
- 'src/**/*.{ts,tsx}'
generates:
src/gql/generated.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo # or -urql, -react-queryResult: every query/fragment becomes a typed hook.
import { useOrderDetailQuery } from '@/gql/generated';
function OrderDetailScreen({ id }: Props) {
const { data, loading, error } = useOrderDetailQuery({ variables: { id } });
if (loading) return <Spinner />;
if (error) return <ErrorView err={error} />;
return <OrderView order={data!.order!} />; // all fields typed
}Without codegen you re-invent types by hand, and they drift.
Mutations And Cache Updates
mutation CancelOrder($id: ID!) {
cancelOrder(id: $id) {
id
status # <- so the cache updates correctly
}
}Always select the fields you want updated in the cache. Returning just { id } from a mutation means the client can't refresh the changed fields automatically.
Apollo Cache Update
const [cancelOrder] = useCancelOrderMutation({
update: (cache, { data }) => {
if (!data) return;
cache.modify({
id: cache.identify(data.cancelOrder),
fields: { status: () => data.cancelOrder.status },
});
},
});Optimistic Response
cancelOrder({
variables: { id },
optimisticResponse: {
cancelOrder: { __typename: 'Order', id, status: 'CANCELLED' },
},
});UI flips instantly; if the mutation fails, Apollo rolls back automatically.
Pagination: Relay Spec Cursor Connections
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge { node: Order! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }query Orders($after: String) {
orders(first: 20, after: $after) {
edges { node { ...OrderRow } cursor }
pageInfo { hasNextPage endCursor }
}
}Apollo's fetchMore and field policies merge pages:
new InMemoryCache({
typePolicies: {
Query: {
fields: {
orders: relayStylePagination(),
},
},
},
});Client Comparison
| Client | Cache | Bundle | DX | When to pick |
|---|---|---|---|---|
| Apollo Client | Normalized | ~30 KB | Best ecosystem, complex | Default for most apps |
| urql | Document or normalized (Graphcache) | ~6 KB | Lean, modular | Bundle-conscious teams |
| Relay | Normalized + compiler | ~40 KB | Strict, fragment-driven | Largest apps with experienced teams |
| TanStack Query + graphql-request | None (or per-query) | ~12 KB | Simplest | If you don't need a normalized cache |
Apollo
- Pros: huge ecosystem, normalized cache, dev tools, mature
subscriptions-transport-ws. - Cons: cache reasoning has a learning curve; bundle size.
urql
- Pros: lean, plug-and-play exchanges, easy SSR.
- Cons: ecosystem smaller than Apollo.
Relay
- Pros: best-in-class compile-time guarantees, fragment co-location enforced.
- Cons: steepest learning curve, requires server to follow Relay spec.
N+1 As A Frontend Concern
A poorly written backend resolver can issue N database queries for an N-item list. The frontend often surfaces this as a single slow query.
Signs:
- Wall-clock time grows linearly with
first: N. - Server-side traces show repeated DB hits per field.
The fix is on the backend (DataLoader / batched joins), but the frontend should:
- Avoid speculative deep nesting in lists.
- Use
@defer/@streamfor fields that are slow but not critical for first paint.
Caching: The Hardest Part
Apollo and Relay use a normalized cache — every object is stored by its __typename:id. Two queries returning the same Order share the same cache entry; updating one updates everywhere.
Required Setup
Every type the cache normalizes needs an ID, or keyFields:
new InMemoryCache({
typePolicies: {
Order: { keyFields: ['id'] },
OrderItem: { keyFields: ['orderId', 'productId'] }, // composite
},
});Missing IDs fall back to the parent — invisible cache "leaks" that don't update on mutation.
Subscriptions
subscription OnOrderUpdated($id: ID!) {
orderUpdated(id: $id) { id status }
}useOrderUpdatedSubscription({
variables: { id },
onData: ({ data }) => {
// cache updates automatically because we selected `id` and the field changed
},
});Transport: prefer graphql-ws (WebSocket sub-protocol) over the deprecated subscriptions-transport-ws.
Error Handling
GraphQL responses can carry both data and errors. A 200 status does not mean success.
const { data, error } = useOrderQuery({ variables: { id } });
if (error) {
// network error or GraphQL error
}
if (data?.order === null) {
// explicit not-found (different from missing-field error)
}Error Categories
| Category | Where | Frontend handles |
|---|---|---|
| Network | Transport failed | Retry / offline UI |
| GraphQL syntax | Bad query (codegen catches at build time) | Should never reach prod |
| Resolver error | Server threw | Show error, log |
| Validation | Input invalid | Map to form field errors |
| Auth | extensions.code === 'UNAUTHENTICATED' | Refresh token / sign in |
Use error link / exchange to centralize:
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED')) {
return fromPromise(refreshToken()).flatMap(() => forward(operation));
}
});Persisted Queries
In production, send a hash instead of the full query string. Smaller payload, better CDN caching, prevents arbitrary queries from public clients.
const link = createPersistedQueryLink({ sha256 }).concat(httpLink);Pair with Automatic Persisted Queries (APQ) so unknown hashes fall back to sending the full query once.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Cache shows stale data after mutation | Mutation didn't return changed fields | Select the fields in the mutation |
null everywhere after cache.modify | Missing keyFields for the type | Define keyFields |
| Bundle too large | Pulling whole Apollo on a small app | Try urql or graphql-request + TanStack Query |
| Subscriptions silently fail | Wrong transport / proxy strips upgrade | Use graphql-ws, verify WS in dev |
| Slow query | Resolver N+1 | Backend fix (DataLoader); use @defer if available |
| Fragment fields undefined | Component requested fragment, parent query didn't include it | Always spread fragments in the parent query |
| Codegen drifts | No CI check | Run codegen in CI, fail on diff |