Apollo vs Relay: Choosing the Right GraphQL Client for React
Apollo vs Relay: strengths, trade‑offs, and when to choose each for React GraphQL apps, from caching and pagination to SSR, typing, and developer UX.
Image used for representation purposes only.
Overview
GraphQL gives clients fine‑grained control over data, but the client library you choose shapes everything from component design to performance and DX. Apollo Client and Relay are the two most proven React GraphQL clients. Both do normalized caching, pagination, optimistic updates, SSR, and subscriptions—but they make different trade‑offs.
This comparison focuses on practical differences so you can choose with confidence and architect intentionally.
Design goals and mental models
- Apollo Client: Developer‑ergonomics first. Minimal setup, works with almost any GraphQL schema, optional compile steps, and doubles as a lightweight state manager. Flexible by default.
- Relay: Performance and correctness at scale. Ahead‑of‑time compilation, fragment‑driven data modeling, strict conventions (connections, global IDs), and React‑first patterns (Suspense, streaming). Opinionated by design.
Architecture at a glance
-
Runtime vs. compile time
- Apollo evaluates queries at runtime. Codegen is optional; the runtime parses documents and normalizes results.
- Relay requires a compiler that generates query artifacts and TypeScript/Flow types. Much work moves from runtime to build time.
-
Cache model
- Both normalize entities by ID. Apollo’s cache is policy‑driven and schema‑agnostic. Relay’s store is tightly aligned with Relay conventions (connections, node IDs) to support efficient pagination and refetching.
-
React integration
- Apollo: Hook‑based API (useQuery/useMutation/useSubscription), optional Suspense.
- Relay: Fragment‑centric hooks (useLazyLoadQuery/useFragment) with Suspense as a core mental model.
Typical data‑fetching ergonomics
Apollo Client
import { ApolloClient, InMemoryCache, HttpLink, gql, useQuery } from '@apollo/client';
const client = new ApolloClient({
link: new HttpLink({ uri: '/graphql' }),
cache: new InMemoryCache(),
});
const GET_TODOS = gql`
query GetTodos($first: Int!) {
todos(first: $first) { id title completed }
}
`;
function Todos() {
const { data, loading, error } = useQuery(GET_TODOS, { variables: { first: 10 } });
if (loading) return <>Loading…</>;
if (error) return <>Error: {error.message}</>;
return (
<ul>
{data.todos.map((t: any) => <li key={t.id}>{t.title}</li>)}
</ul>
);
}
- Strengths: minimal setup, works against almost any schema, easy to co‑locate queries near components.
- Considerations: you fine‑tune cache behavior via type/field policies when you need strong consistency or advanced pagination.
Relay
import { RelayEnvironmentProvider, useLazyLoadQuery, graphql } from 'react-relay';
const TodosQuery = graphql`
query TodosQuery($first: Int!) {
todos(first: $first) @connection(key: "Todos_todos") {
edges { node { id title completed } }
}
}
`;
function Todos() {
const data = useLazyLoadQuery(TodosQuery, { first: 10 }); // Suspense boundary required above
return (
<ul>
{data.todos.edges.map(({ node }: any) => <li key={node.id}>{node.title}</li>)}
</ul>
);
}
- Strengths: fragments keep data requirements local and composable; compiler enforces correctness and produces tight artifacts.
- Considerations: requires setting up a Relay Environment and buying into Suspense and the compiler.
Caching and normalization
-
Apollo
- InMemoryCache with typePolicies/fieldPolicies to define key fields, pagination strategies, and merge logic.
- Flexible cache keys (e.g., composite keys) and policies let you adapt to non‑Relay schemas or legacy APIs.
- Local‑only fields and reactive variables enable client state in the same cache.
-
Relay
- Strongly favors globally unique IDs (Node interface) and the Connection spec for lists.
- Compiler knows about fragments and connections, enabling efficient normalization, refetch, and pagination without hand‑written merge logic.
- Intent is server‑data only; defer broader app state to other stores.
Pagination
-
Apollo
- Field policies control concatenation and de‑duplication. Helpers like relayStylePagination exist for cursor‑based lists.
- Works with cursor or offset pagination; you compose the UX yourself.
-
Relay
- Built around the Connection spec (edges/nodes, pageInfo). Pagination directives and helpers keep updates consistent and performant out of the box.
- Encourages a uniform shape for lists, reducing bespoke client code.
Mutations and optimistic UI
-
Apollo
- useMutation with options like optimisticResponse, refetchQueries, and update/cache.modify for precise store updates.
- Easy to get started; advanced scenarios rely on carefully written field policies or cache.modify.
-
Relay
- Mutations integrate with the store via updaters and connection helpers; the compiler understands fragments, so optimistic updates can be concise and safer.
- The connection model reduces custom merge logic for list updates.
Subscriptions and real‑time
- Both support GraphQL subscriptions.
- Apollo uses links (e.g., WebSocketLink) and split routing between HTTP and WS.
- Relay plugs a subscribe function into the Network layer. Once configured, updates flow through the same normalized store.
Type safety
-
Apollo
- TypeScript works well. GraphQL Code Generator or Apollo’s codegen can produce typed hooks and result types.
-
Relay
- The compiler emits Flow or TypeScript types directly from fragments and operations. Because fragments are first‑class, types stay tightly aligned with component needs.
Streaming, deference, and Suspense
-
Apollo
- Supports Suspense and can work with @defer/@stream on supported servers, but Suspense is optional and patterns vary by app.
-
Relay
- Designed around Suspense. @defer and @stream integrate cleanly with fragment composition to progressively render UIs.
SSR and Next.js
-
Apollo
- Mature SSR story with hydration helpers and integration patterns for Next.js/Remix. Straightforward to fetch during server render, embed cache, and hydrate on the client.
-
Relay
- SSR via preloading queries, collecting executed operations, and hydrating the Relay store on the client. Works well with Suspense‑first routing.
Local state and app architecture
-
Apollo
- Can co‑locate local UI state with server data in the Apollo cache or with reactive variables. Useful for small/medium apps that want a single source for UI and server state.
-
Relay
- Focuses on server data. Use Zustand/Redux/Context, etc., for broader client state. This separation can yield clearer mental models in large apps.
Tooling and ecosystem
-
Apollo
- Rich ecosystem: links for transport/middleware, DevTools, schema analytics/monitoring, and helpful error messages. Plays nicely with a wide range of GraphQL servers and federated schemas.
-
Relay
- Relay Compiler, Relay DevTools, and a lean runtime. Strong conventions produce predictable behavior and fewer runtime surprises. Best fit when you can influence server schema to follow Relay patterns.
Performance and bundle size
-
Apollo
- Slightly larger runtime; flexibility can mean more work at runtime. With good cache policies and selective queries, performance is excellent for most apps.
-
Relay
- Ahead‑of‑time compilation yields small artifacts, fewer runtime costs, and aggressive dead‑code elimination. Particularly compelling at Meta‑scale patterns (large lists, heavy fragment composition, streaming).
Learning curve and team adoption
-
Apollo
- Lower barrier to entry; ideal for teams ramping onto GraphQL or migrating from REST.
-
Relay
- Steeper curve: requires understanding fragments, Suspense, connections, and a compiler step. Pays off in consistency and long‑term maintainability at scale.
Decision guide: when to choose which
Choose Apollo Client if:
- You want rapid productivity with minimal setup and a forgiving learning curve.
- Your schema is not Relay‑style (e.g., offset pagination, no global IDs) and you can’t change it soon.
- You prefer to centralize some client state alongside server data.
- You value a broad plugin ecosystem and easy server compatibility.
Choose Relay if:
- You can enforce or evolve toward Relay conventions (Node IDs, Connection spec) on the server.
- You’re building a large, data‑dense React app where strict correctness, Suspense, streaming, and compile‑time guarantees matter.
- You want strong, generated types tied to fragments and minimal runtime overhead.
Migration and coexistence strategies
- Page‑by‑page adoption: You can run Apollo and Relay side‑by‑side while migrating. Each keeps its own cache and provider; scope them to different subtrees.
- Start new surfaces in Relay: New routes or features start with Relay, while legacy areas remain on Apollo until retired.
- Schema alignment: If moving toward Relay, introduce global IDs and Connection‑spec pagination incrementally to unlock Relay’s strengths.
- Shared GraphQL documents: Keep operations colocated with components and codify naming/keys for lists early (e.g., connection keys).
Common pitfalls and how to avoid them
-
Inconsistent IDs
- Symptom: duplicate rows, stale data.
- Fix: Ensure stable cache keys. In Apollo, configure keyFields/typePolicies. In Relay, prefer globally unique IDs across types when possible.
-
Ad‑hoc pagination merges
- Symptom: items missing or duplicated on “Load more.”
- Fix: Use Apollo field policies (e.g., relayStylePagination) or Relay connections with stable connection keys.
-
Mixing UI and server state haphazardly
- Symptom: cache thrash and hard‑to‑debug rerenders.
- Fix: In Apollo, isolate ephemeral UI state with reactive variables or separate stores. In Relay, keep Relay for server data; use dedicated state for UI.
-
Skipping a Suspense boundary in Relay
- Symptom: runtime errors or unresolved queries.
- Fix: Wrap routes/components using useLazyLoadQuery with a Suspense boundary and a suitable fallback.
Example: cursor pagination configuration
Apollo field policy
import { InMemoryCache } from '@apollo/client';
import { relayStylePagination } from '@apollo/client/utilities';
export const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
todos: relayStylePagination(),
},
},
},
});
Relay connection in a fragment
import { graphql } from 'react-relay';
export const fragment = graphql`
fragment Todos_list on Query
@argumentDefinitions(first: { type: "Int" }, after: { type: "String" }) {
todos(first: $first, after: $after)
@connection(key: "Todos_list_todos") {
edges { node { id title } }
pageInfo { hasNextPage endCursor }
}
}
`;
Cost of ownership summary
-
Setup
- Apollo: 1–2 files (client + cache). Start shipping immediately.
- Relay: Add compiler, environment, and Suspense boundaries. More initial work.
-
Maintenance
- Apollo: Freedom means more choices per feature (policies, updates).
- Relay: Conventions reduce variance; compiler catches drift.
-
Performance
- Apollo: Excellent with tuning.
- Relay: Excellent by default when following conventions.
TL;DR
- If you need to move fast with a flexible schema and minimal ceremony, pick Apollo Client.
- If you own your schema and want scale‑proof correctness, streaming/Suspense, and strong compile‑time guarantees, pick Relay.
Either way, model lists carefully, generate types, and standardize patterns early—your future self will thank you.
Related Posts
Build a GraphQL API and React Client: An End‑to‑End Tutorial
Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.
React Dynamic Imports and Lazy Routes: A Practical Guide
A practical guide to React dynamic imports and lazy routes with React Router, preloading strategies, SSR notes, and performance pitfalls.
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.