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.
Directives: Conditional And Incremental Fields
@skip and @include let one document serve multiple screens without duplicating queries:
query OrderDetail($id: ID!, $withItems: Boolean!) {
order(id: $id) {
...OrderRow
items @include(if: $withItems) { id quantity }
}
}@defer and @stream split one response into incremental chunks so the shell paints before slow fields resolve:
query OrderDetail($id: ID!) {
order(id: $id) {
...OrderRow
... @defer {
recommendations { id name } # arrives in a later chunk
}
items @stream(initialCount: 5) { # streamed in batches
id quantity
}
}
}Requires server support (graphql-js ≥17, Apollo Router) and a client that understands multipart/mixed responses. Reach for it on below-the-fold or expensive aggregates — not on everything.
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.
Fragment Masking (client-preset)
The modern @graphql-codegen/client-preset generates a typed graphql() function and masks fragment data: a component can only read the fields its own fragment declared, even if the parent over-fetched.
// codegen.ts
generates: {
'./src/gql/': { preset: 'client' },
}const OrderRow = graphql(`
fragment OrderRow on Order { id status total { amount currency } }
`);
function Row(props: { order: FragmentType<typeof OrderRow> }) {
const order = useFragment(OrderRow, props.order); // unmasks only declared fields
return <span>{order.status}</span>;
}This kills the most common GraphQL bug — one component silently reading a field another component requested — at the type level. Prefer it over the older near-operation-file / per-operation hooks for new projects.
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.
Where Your Schema Comes From: Federation & BFF
In a multi-team org the single endpoint you query is usually composed, not hand-written:
| Approach | What it is | Frontend impact |
|---|---|---|
| Monolith schema | One server owns every type | Simplest; that team is the bottleneck |
| Apollo Federation | A gateway composes subgraphs owned by different teams | One endpoint, types span teams; watch cross-subgraph latency |
| Schema stitching | Older manual merge of remote schemas | Legacy; prefer federation |
| BFF (Backend for Frontend) | A thin GraphQL layer per client, wrapping REST/gRPC | You own the schema; tailor it to the screen and aggregate downstream calls |
A BFF is often the pragmatic win: the backend stays REST/gRPC, and a small GraphQL layer (frequently owned by the frontend team) buys query flexibility without forcing the whole org onto GraphQL. With federation, an @key directive marks a type as resolvable across subgraphs — a single entity reference can silently add a gateway round trip, so treat cross-subgraph fields like any other latency source.
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.
Eviction And Garbage Collection
Normalized caches grow forever unless you prune them. After deleting an entity, evict it and run GC so dangling references are swept:
cache.evict({ id: cache.identify(order) });
cache.gc(); // removes objects no longer reachable from a root querygc() only collects objects with no remaining references — orphan a list entry without evicting it and it lingers. For long-lived sessions (kiosks, desktop apps) schedule periodic gc() or cap retention with field policies.
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.
Testing Components That Query
Mock at the network or client boundary — never stub the hooks themselves.
Apollo MockedProvider — declarative request/response pairs:
const mocks = [{
request: { query: OrderDetailDocument, variables: { id: '1' } },
result: { data: { order: { __typename: 'Order', id: '1', status: 'PAID' } } },
}];
render(<MockedProvider mocks={mocks}><OrderDetailScreen id="1" /></MockedProvider>);MSW with a GraphQL handler — client-agnostic, matches by operation name, reusable in Storybook and E2E:
graphql.query('OrderDetail', () =>
HttpResponse.json({ data: { order: { id: '1', status: 'PAID' } } }),
);Prefer MSW for one mock layer across unit, Storybook, and integration tests; prefer MockedProvider for tight Apollo cache-behavior assertions.
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 |