GraphQL Clients: Choosing an Approach
|
This section documents the current GraphQL specification (October 2021), plus the working draft for
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly. |
Once a server exposes a GraphQL schema, something on the consuming side still has to send query documents over the wire, turn the JSON response back into typed data, and decide what — if anything — gets cached between requests. This page surveys what a dedicated GraphQL client adds on top of a plain HTTP call, maps the current client landscape across JavaScript/TypeScript, the JVM, and Python, and introduces typed operation generation with GraphQL Code Generator. The two pages that follow it, Apollo Client configuration and Other clients, go deep on the individual clients introduced here.
What a GraphQL client adds over a bare fetch call
A GraphQL request is, at the wire level, nothing more than an HTTP POST carrying a JSON body — Serving over HTTP covers that wire format in depth. Nothing stops
an application from calling fetch directly for every query and mutation it needs, and for a handful of
one-off requests that is often the simplest option. As an application grows, though, a few recurring problems
show up that a dedicated client is built to solve:
-
Typed operations. A bare
fetchcall returnsunknownJSON — the shape ofdataexists only in the query string and in whatever the caller assumes about it. A client paired with a code generator ties each query or mutation document to a generated type (or, in typed languages, a generated class), so a field rename or removal on the server surfaces as a compile-time error in the client rather than a runtimeundefined. -
A normalized cache. Repeated queries for overlapping data (the same
Bookshown in a list and again on a detail page) can share one cached copy of that object instead of each query owning its own disconnected response tree. A normalized cache keys every object by its__typenameplus itsid(or a custom key), so writing to one query’s result is visible to every other query that also selected that object. -
Request de-duplication and batching. Two components rendering at the same time that ask for the exact same query and variables should produce one network request, not two — and a client can further batch several distinct queries fired in the same tick into a single HTTP round trip. Neither behavior is available from a bare
fetchcall without hand-rolled bookkeeping. -
Subscription transport. Queries and mutations are a single request/response pair, but a long-lived subscription needs a persistent connection — typically a WebSocket running the
graphql-ws(or, more recently, an SSE-based) protocol. A client that supports subscriptions manages that connection’s lifecycle, reconnection, and per-subscription multiplexing so application code only sees an async stream of results.Subscriptionscovers the server side of that same protocol. -
Optimistic updates. A mutation’s real result only arrives after a round trip, but a client with a normalized cache can apply a predicted result to the cache immediately — rendering the change before the server confirms it — and roll it back automatically if the mutation actually fails.
None of these are things a GraphQL server requires of its clients — they are conveniences a client library provides on top of the same request/response contract every client, typed or not, ultimately uses.
A bare fetch call vs. a typed client call
The difference is easiest to see side by side. Both snippets send the exact same query over the wire; only the second one gives the caller a typed, cache-aware result:
// Bare fetch: no types, no cache, no de-duplication.
async function getBook(id: string) {
const response = await fetch("https://api.example.com/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query GetBook($id: ID!) {
book(id: $id) { title publishedYear author { name } }
}`,
variables: { id },
}),
});
const { data, errors } = await response.json();
if (errors) throw new Error(errors[0].message);
return data.book; // typed as `any` unless annotated by hand
}
// Apollo Client: typed, cached, de-duplicated, and re-run automatically on cache updates.
import { gql } from "@apollo/client";
import { useQuery } from "@apollo/client/react";
const GET_BOOK = gql`
query GetBook($id: ID!) {
book(id: $id) { title publishedYear author { name } }
}
`;
function BookDetail({ id }: { id: string }) {
const { data, loading, error } = useQuery<GetBookQuery, GetBookQueryVariables>(GET_BOOK, {
variables: { id },
});
if (loading) return <Spinner />;
if (error) return <ErrorBanner message={error.message} />;
return <Book title={data!.book.title} author={data!.book.author.name} />;
}
GetBookQuery and GetBookQueryVariables in the second snippet are not hand-written — they come from running
GraphQL Code Generator against the schema and this operation, covered later on this page. The client also
means a second component rendering <BookDetail id="42" /> at the same time reuses the in-flight request and
the cached result instead of firing a duplicate query.
The client landscape
No single client is the right choice for every stack. The table groups the options this section covers by ecosystem and by how much machinery they bring beyond sending a request:
| Client | Ecosystem | Cache model | Best fit |
|---|---|---|---|
Bare |
Any JS/TS runtime |
None ( |
Scripts, server-to-server calls, or a handful of one-off queries where a full client is overkill. |
Apollo Client |
React, Vue, Angular, plain JS |
Normalized, |
Apps that want a mature, batteries-included client with a large ecosystem of links, dev tools, and community patterns. |
urql |
React, Vue, Svelte, plain JS |
Pluggable — document cache by default, normalized via |
Apps that want a smaller runtime and an explicit, composable request pipeline (the exchange chain). |
Relay |
React only |
Normalized, compiler-driven |
Large React apps that want colocated, statically-verified data requirements per component and aggressive build-time optimization. |
TanStack Query + |
Any JS/TS framework TanStack Query supports |
Query-key based (not normalized by object identity) |
Teams already using TanStack Query for REST that want the same caching/retry/refetch model for GraphQL, without adopting a GraphQL-specific cache. |
|
JVM (Java/Kotlin) |
None built in — caching is left to the caller |
A JVM backend or service calling another GraphQL API, or JVM integration tests against a GraphQL endpoint. |
|
Python |
None built in |
A Python backend or script consuming a GraphQL API, with pluggable sync/async transports (HTTP, WebSocket). |
A few of these are covered only briefly in this section:
Apollo Client configuration is a dedicated deep dive because of how much configuration surface Apollo exposes
(links, cache policies, type policies), while urql, Relay, the JVM GraphQlClient family, and gql are covered
together in Other clients.
Typed operations with GraphQL Code Generator
GraphQL Code Generator reads a schema (from a live endpoint, a local SDL
file, or an introspection JSON dump) plus a project’s .graphql operation documents, and generates source code
from both — TypeScript types for every query/mutation/subscription and its variables, typed React/Vue/Svelte
hooks bound to a specific client (Apollo, urql, TanStack Query), or a fully typed SDK, depending on which
plugins a project configures. The two snippets above rely on exactly this: GetBookQuery and
GetBookQueryVariables are generated, not hand-written, so a schema change that removes or renames a field
breaks the generated types — and therefore the build — rather than failing silently at runtime.
A minimal configuration points at the schema and the operation files, and lists the plugins to run:
# codegen.yml
schema: https://api.example.com/graphql
documents: "src/**/*.graphql"
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
Running the generator is typically wired into a project’s build or a watch script:
npx graphql-codegen --config codegen.yml
|
This page is generated with the assistance of AI. Verify the current plugin names, configuration keys, and supported target clients against the GraphQL Code Generator documentation before adopting a specific plugin, since the plugin ecosystem changes independently of the core tool. |
The same idea — generate types from the schema instead of hand-maintaining them — exists outside the
JavaScript ecosystem too: JVM projects using Spring for GraphQL typically rely on the schema plus DTOs mapped by
the framework’s own binding rather than a separate code generator, and Python projects using gql can generate
typed clients with ariadne-codegen against a Strawberry or Ariadne schema.
Subscriptions transport across clients
Subscriptions describes the server side of the two transports in
common use today — the graphql-ws WebSocket sub-protocol, and an SSE-based alternative for servers that
prefer not to hold a WebSocket connection open. Client support for each varies: Apollo Client and urql both
support graphql-ws via a GraphQLWsLink/subscriptionExchange wired alongside their normal HTTP transport,
Relay’s network layer accepts any observable-returning subscription implementation (commonly graphql-ws
underneath), and the JVM WebSocketGraphQlClient and Python gql client each ship their own graphql-ws
transport implementation. A client’s subscription support only matters once a schema actually exposes a
Subscription type — most of the client configuration shown in the next two pages concerns queries and
mutations, with subscription wiring called out separately where it applies.
Choosing a client
There is no universally correct choice — the table above groups clients by ecosystem for a reason — but a few starting points cover most projects:
-
A React app that wants a mature, widely documented client with strong dev tools and no strong opinion beyond GraphQL itself: reach for Apollo Client.
-
A team that wants a smaller runtime and prefers an explicit, inspectable request pipeline over a larger built-in feature set: urql covers the same ground with a different design philosophy.
-
A large React app with strict data-requirement colocation and build-time verification as a priority (and the willingness to adopt its compiler): Relay.
-
A team already standardized on TanStack Query for REST that wants one caching/retry model across both: TanStack Query +
graphql-request, accepting a query-key cache instead of a normalized one. -
A JVM service calling another GraphQL API, or a test harness exercising a GraphQL endpoint: the
GraphQlClientfamily (HttpGraphQlClient,WebSocketGraphQlClient,RSocketGraphQlClient). -
A Python service or script consuming a GraphQL API: the
gqlclient, choosing the transport (HTTP, WebSocket) that matches the target server. -
A handful of queries in a script, a server-to-server call, or a context where a full client’s cache would be wasted overhead: a bare
fetch/graphql-requestcall is often the right amount of tooling.
Where to go next
-
Apollo Client configurationcoversApolloClientconstruction, link chains, cache type policies, and pagination helpers in depth. -
Other clientscovers urql’s exchange pipeline, Relay’s compiler and store, the JVMGraphQlClientfamily, and the Pythongqlclient. -
PaginationandGlobal object identificationcover the Relay-style cursor conventions that several of these clients (Apollo’srelayStylePagination, Relay itself) build pagination helpers around. -
GraphQLlists every page in this section, andthe cheat sheetlinks back to each one from a single page.