Steven's Knowledge

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

ScenarioGraphQL fit
Many clients (web, iOS, Android) read the same backendHigh
Highly nested or aggregated reads (dashboards, profile screens)High
Need to evolve schema with versionless deprecationHigh
Mostly simple CRUD on a single clientLow — REST is fine
File uploads as primary use caseLow — REST handles this better
Public API consumed by unknown third partiesModerate — 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-query

Result: 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

ClientCacheBundleDXWhen to pick
Apollo ClientNormalized~30 KBBest ecosystem, complexDefault for most apps
urqlDocument or normalized (Graphcache)~6 KBLean, modularBundle-conscious teams
RelayNormalized + compiler~40 KBStrict, fragment-drivenLargest apps with experienced teams
TanStack Query + graphql-requestNone (or per-query)~12 KBSimplestIf 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 / @stream for 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

CategoryWhereFrontend handles
NetworkTransport failedRetry / offline UI
GraphQL syntaxBad query (codegen catches at build time)Should never reach prod
Resolver errorServer threwShow error, log
ValidationInput invalidMap to form field errors
Authextensions.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

SymptomCauseFix
Cache shows stale data after mutationMutation didn't return changed fieldsSelect the fields in the mutation
null everywhere after cache.modifyMissing keyFields for the typeDefine keyFields
Bundle too largePulling whole Apollo on a small appTry urql or graphql-request + TanStack Query
Subscriptions silently failWrong transport / proxy strips upgradeUse graphql-ws, verify WS in dev
Slow queryResolver N+1Backend fix (DataLoader); use @defer if available
Fragment fields undefinedComponent requested fragment, parent query didn't include itAlways spread fragments in the parent query
Codegen driftsNo CI checkRun codegen in CI, fail on diff

On this page