A working engineer's take on Apollo vs urql vs Relay - cache normalization, optimistic updates, pagination, codegen, and which one I default to for React apps.
It was a Wednesday afternoon at the creator economy platform I worked at, and our community feed had comment counters that were wrong by exactly one. Not approximately one. Exactly one. Refresh the page, the number was right. Click “like” on any post, wrong again.
The bug lived in our GraphQL client cache. Apollo’s normalized store had the post, the comments collection, and a write from our mutation that updated the count but did not invalidate the dependent fields. The network was fine. Aurora was fine. The lambda BFF was fine. We were lying to ourselves at the cache layer.
I’ve shipped React apps with all three big GraphQL clients - Apollo, urql, Relay. Here’s my actual opinion: Apollo with codegen, most of the time.
Apollo Client is the GraphQL client with the biggest community, a normalized in-memory cache by default, and a reactive query model that React engineers feel at home in. It does a lot. Some of what it does, you do not need. The defaults are sane.
urql is smaller, faster to wrap your head around, and lets you opt in to normalization via Graphcache. The base behavior is a document cache. Hash the query plus variables, store the result. If you have a thin React layer over a BFF that already shapes data well, urql gets out of your way.
Relay is the one Facebook wrote for Facebook. It assumes you control the schema, that the schema is wired for the Relay spec (connections, node interface, globally unique IDs), and that your team is willing to think in fragments and data masking. When all of that is true, it is the best of the three. When any of it is false, you will fight it.
The GraphQL client decision is almost entirely a cache decision. The HTTP layer is boring. The store is where bugs hide.
Apollo’s InMemoryCache normalizes by __typename plus id (or whatever key fields you tell it). You write typePolicies to teach it how merge functions work for tricky fields - paginated lists, nested counts, anything that isn’t a clean overwrite. Here’s a real-shaped one for a community feed.
import { InMemoryCache, FieldPolicy } from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'
const feedPolicy: FieldPolicy = {
...relayStylePagination(['filter', 'sort']),
read(existing, { args }) {
if (!existing) return undefined
const cursor = args?.after ?? null
if (cursor && !existing.edges.some(e => e.cursor === cursor)) {
return undefined
}
return existing
},
}
export const cache = new InMemoryCache({
typePolicies: {
Community: {
fields: { feed: feedPolicy },
},
Post: {
keyFields: ['id'],
fields: {
reactionsCount: {
merge(_existing, incoming: number) {
return incoming
},
},
},
},
},
})
That read function is the part most tutorials skip. If a paginated page request comes in for a cursor we don’t have in the store, return undefined so Apollo refetches. Otherwise you get phantom pages.
urql’s Graphcache covers the same ground with a different shape: resolvers to read derived state, updaters to handle mutation side effects. More explicit. Also more boilerplate as the schema grows.
Relay sits at the other extreme. The store is built from your fragments and the connection spec, and you mostly do not write merge functions. Follow the spec, normalization is automatic. Don’t, you can’t.
When your backend degrades, the Apollo cache is often the only reason the product isn’t a ghost town. Cached posts, cached reactions, cached counts keep users scrolling. Optimistic updates on likes and comments keep the surface responsive while writes back up. Normalization means you can serve stale-but-coherent data per entity. On a document cache, every dependent query shows holes.
The cache isn’t a perf optimization. It is a degradation strategy.
Optimistic UI is a contract you sign with your user. You tell them their action worked. If you can’t honor that, you owe them a clear rollback. The rollback is what most teams forget to wire.
Here’s an Apollo useMutation that does it honestly.
import { useMutation, gql } from '@apollo/client'
import type { ReactPostMutation, ReactPostMutationVariables } from './__generated__/graphql'
const REACT_POST = gql`
mutation ReactPost($postId: ID!, $reaction: ReactionType!) {
reactToPost(postId: $postId, reaction: $reaction) {
post { id reactionsCount viewerReaction }
}
}
`
export function useReactPost(postId: string, previousCount: number) {
return useMutation<ReactPostMutation, ReactPostMutationVariables>(REACT_POST, {
optimisticResponse: ({ reaction }) => ({
reactToPost: {
__typename: 'ReactPostPayload',
post: {
__typename: 'Post',
id: postId,
reactionsCount: previousCount + 1,
viewerReaction: reaction,
},
},
}),
update(cache, { data, errors }) {
if (errors?.length) return
const fresh = data?.reactToPost?.post
if (!fresh) return
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: { reactionsCount: () => fresh.reactionsCount },
})
},
onError: () => {
// optimistic write is auto-reverted by Apollo when the mutation throws
// toast the user, don't pretend it worked
},
})
}
The lesson here got driven home for me on the native side. We had a pipeline submitting branded mobile apps to the Apple App Store. Apple’s Connect API started silently throttling one morning, returning 200 OK with a body that looked normal but with the submission getting dropped server-side. Our retry treated 200 OK as truth. A few hundred customer app builds ended up with duplicate review records. The fix was a circuit breaker that verifies submission state with a separate GET, never the body of the POST.
The general rule I took from it: never trust the response of a write. Read-after-write against the upstream’s source of truth. That rule applies to optimistic UI too. A 200 OK from your GraphQL mutation is not proof the server’s view of the world matches yours. Reconcile against the response payload. Always wire a rollback.
Offset pagination is a bug waiting for traffic. The second a row inserts above the cursor, your pages either skip or duplicate. Use cursors.
All three clients support cursor pagination, all three follow the Relay connections shape (edges, node, pageInfo, endCursor), and all three give you a helper. Apollo’s relayStylePagination was in the typePolicies block above. urql has cursorPagination from @urql/exchange-graphcache/extras. Relay does it natively.
The mistake teams make is mixing pagination styles inside the same client. Cursor on one query, offset on another, custom-stitched merge function on a third. Pick one shape per app and enforce it in code review. The cache is much easier to reason about when every list looks the same.
TypedDocumentNode plus @graphql-codegen/cli eliminates an entire bug class. Variables typed, results typed, mutations typed, fragments typed. You stop hand-writing useQuery<Foo, FooVariables> because the generated hook already knows.
Here’s the codegen config I run in most projects.
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: process.env.GRAPHQL_SCHEMA_URL,
documents: ['src/**/*.{ts,tsx,graphql}'],
generates: {
'./src/__generated__/graphql.ts': {
plugins: [
'typescript',
'typescript-operations',
'typed-document-node',
],
config: {
useTypeImports: true,
avoidOptionals: { field: true, inputValue: false },
scalars: {
DateTime: 'string',
JSON: 'unknown',
},
},
},
'./src/__generated__/introspection.json': {
plugins: ['introspection'],
config: { minify: true },
},
},
}
export default config
The introspection.json is what Apollo’s possibleTypes consumes for union and interface support. Skip it and you’ll get cryptic fragment errors in production that you won’t reproduce locally.
I’ve seen teams hand-write GraphQL types for months because codegen was “on the backlog”. It’s not on the backlog. It’s the first PR after you pick a client.
For most React apps: Apollo Client, with InMemoryCache typePolicies tuned per type, relayStylePagination on every list, optimistic responses on every mutation that touches visible UI, and TypedDocumentNode codegen on day one. That’s the default. It scales from a side project to a SaaS with millions of customers without rewrites.
I reach for urql when the BFF is doing the heavy lifting and the React app is genuinely thin. If you don’t need normalization, you don’t need Apollo.
Relay only when the team owns the schema, the schema already follows the Relay spec, and the team is willing to commit a sprint or two to fragments-first thinking. When all of that is true, Relay is the cleanest. When it isn’t, you’re paying for opinions you can’t honor.
Cache keys are part of your public API. The same thing applies to your Apollo keyFields, your urql keys, your Relay node IDs. Change them like you’d change a database schema.
Thanks for reading. If you’ve got thoughts, send them my way.