GraphQL Clients: Choosing an Approach

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

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 fetch call returns unknown JSON — the shape of data exists 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 runtime undefined.

  • A normalized cache. Repeated queries for overlapping data (the same Book shown 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 __typename plus its id (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 fetch call 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. Subscriptions covers 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 fetch / graphql-request

Any JS/TS runtime

None (graphql-request adds no cache of its own)

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, InMemoryCache

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 @urql/exchange-graphcache

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 + graphql-request

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.

GraphQlClient (Spring for GraphQL)

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.

gql

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 GraphQlClient family (HttpGraphQlClient, WebSocketGraphQlClient, RSocketGraphQlClient).

  • A Python service or script consuming a GraphQL API: the gql client, 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-request call is often the right amount of tooling.

Where to go next

  • Apollo Client configuration covers ApolloClient construction, link chains, cache type policies, and pagination helpers in depth.

  • Other clients covers urql’s exchange pipeline, Relay’s compiler and store, the JVM GraphQlClient family, and the Python gql client.

  • Pagination and Global object identification cover the Relay-style cursor conventions that several of these clients (Apollo’s relayStylePagination, Relay itself) build pagination helpers around.

  • GraphQL lists every page in this section, and the cheat sheet links back to each one from a single page.