Other GraphQL Clients: urql, Relay, JVM, and Python

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.

Clients overview and Apollo Client configuration cover the most common browser client. This page rounds out the picture with three more client families — urql, Relay, the JVM GraphQlClient hierarchy, and Python’s gql — and closes with a comparison to help you choose among all of them.

urql

urql is a lightweight GraphQL client for JavaScript/ TypeScript, built around a single idea: every request flows through an ordered pipeline of exchanges, each one a function that can inspect, transform, short-circuit, or forward an operation to the next exchange in the chain. Where Apollo Client bundles networking, caching, and retries into one configurable client, urql exposes each of those concerns as a separate, swappable exchange — you assemble the pipeline yourself, and the order you list exchanges in is the order they run.

A minimal client needs only a cache exchange and a fetch exchange, but most real applications add authentication and retry behavior to the pipeline too:

import { createClient, cacheExchange, fetchExchange, subscriptionExchange } from 'urql';
import { authExchange } from '@urql/exchange-auth';
import { retryExchange } from '@urql/exchange-retry';
import { createClient as createWSClient } from 'graphql-ws';

const wsClient = createWSClient({ url: 'wss://api.example.com/graphql' });

export const client = createClient({
  url: 'https://api.example.com/graphql',
  exchanges: [
    cacheExchange,
    retryExchange({ initialDelayMs: 200, maxNumberAttempts: 3 }),
    authExchange(async (utils) => {
      let token = await getToken();
      return {
        addAuthToOperation(operation) {
          return token ? utils.appendHeaders(operation, { Authorization: `Bearer ${token}` }) : operation;
        },
        didAuthError(error) {
          return error.response?.status === 401;
        },
        async refreshAuth() {
          token = await refreshToken();
        },
      };
    }),
    fetchExchange,
    subscriptionExchange({
      forwardSubscription: (request) => ({
        subscribe: (sink) => ({ unsubscribe: wsClient.subscribe(request, sink) }),
      }),
    }),
  ],
});

Every operation enters at the first exchange and works its way down the array: cacheExchange can answer a query from cache and stop the pipeline right there; retryExchange re-issues an operation that fails transiently; authExchange attaches (and, on a 401, refreshes and re-attaches) credentials; fetchExchange is the terminal exchange that actually performs the HTTP request; and subscriptionExchange intercepts subscription operations and routes them over a separate transport (here, graphql-ws) instead of fetch. Because exchanges are ordinary functions composed in an array, adding logging, batching, or a custom retry policy means inserting one more exchange rather than reconfiguring the whole client.

urql ships two different caching strategies, and choosing between them is the biggest architectural decision an urql application makes:

Exchange Caching strategy

cacheExchange (bundled in urql core, formerly cacheExchange from @urql/core)

Document caching: caches each query’s entire response keyed by its query + variables. Cheap and predictable, but updating one object (say, after a mutation) does not automatically update every other cached query that also embeds that object —  you either refetch or manually update affected queries.

@urql/exchange-graphcache

Normalized caching, comparable to Apollo Client’s InMemoryCache: caches individual entities keyed by __typename + id, so a mutation that changes one field updates every query currently displaying that entity, without a manual refetch.

Most applications start with the bundled document cacheExchange and reach for @urql/exchange-graphcache only once they feel the lack of normalization — shared entities going stale across independently-fetched queries.

Relay

Relay is Meta’s GraphQL client for React, and it takes the opposite philosophy from urql and Apollo Client: rather than configuring caching behavior, Relay’s compiler statically analyzes every component’s data requirements at build time and generates optimized query artifacts, so the runtime store can stay comparatively simple and the framework can enforce conventions (like Relay-style pagination) at compile time instead of by convention alone.

Three pieces work together in every Relay application:

  • The Relay compiler scans your source for graphql\…​\`` tagged template literals, validates every fragment and query against the schema, and emits generated artifacts (one file per operation/fragment) that the runtime executes — a build step with no client-side equivalent in urql or Apollo Client.

  • The store is Relay’s normalized, in-memory cache of every object the app has ever fetched, keyed by a global ID; components read from it declaratively via generated fragment references rather than by querying the cache directly.

  • usePreloadedQuery (together with loadQuery) lets a route or a router transition start fetching data before the component that needs it has even rendered, eliminating the fetch-on-render waterfall that a naive useQuery-style hook produces.

A typical preloaded route component looks like this:

import { usePreloadedQuery, graphql, PreloadedQuery } from 'react-relay';

const BookPageQuery = graphql`
  query BookPageQuery($id: ID!) {
    book(id: $id) {
      title
      ...BookDetails_book
    }
  }
`;

function BookPage({ queryRef }: { queryRef: PreloadedQuery<BookPageQueryType> }) {
  const data = usePreloadedQuery(BookPageQuery, queryRef);
  return <BookDetails book={data.book} />;
}

The router (or a top-level route loader) calls loadQuery(environment, BookPageQuery, \{ id }) as soon as navigation starts — not when BookPage renders — and hands the resulting queryRef down as a prop; by the time React reaches usePreloadedQuery, the request is often already in flight or complete. BookDetails_book is a fragment colocated with the BookDetails component itself: each component declares exactly the fields it needs, and the compiler merges every colocated fragment into the single query the route actually issues, so a component’s data dependencies travel with its code instead of being assembled by hand at the top of the tree.

Relay’s @connection directive marks a paginated field for its store to treat as an ever-growing, mergeable list rather than a value that gets replaced wholesale on every fetch — the mechanism behind Relay’s usePaginationFragment hook and the cursor-based pagination convention that Pagination documents at the schema level:

const BookList_query = graphql`
  fragment BookList_query on Query
  @refetchable(queryName: "BookListPaginationQuery") {
    books(first: $count, after: $cursor)
      @connection(key: "BookList_books") {
      edges {
        node { id title }
      }
    }
  }
`;

Because @connection is a client-only directive understood by the compiler (it never reaches the server), the store knows to append newly fetched edges to the existing list identified by the key, rather than discarding what was already loaded — the piece of "infinite scroll" bookkeeping that a document-caching client would otherwise leave to application code.

Relay’s compiler step, fragment colocation conventions, and @connection behavior evolve across releases — verify the exact API surface shown here against the current Relay installation guide and pagination guide before adopting it.

JVM: the GraphQlClient family

Spring for GraphQL ships a GraphQlClient abstraction that is deliberately transport-agnostic: the same request-building API (document(…​), variable(…​), retrieve(…​)/retrieveSync(…​), execute()) works whether the underlying transport is HTTP, a WebSocket, or RSocket, and only the client’s construction differs per transport. This makes it a natural fit for JVM backends and batch/integration code that needs to call another GraphQL service without pulling in a browser-oriented JavaScript client.

// HTTP: request/response queries and mutations.
WebClient webClient = WebClient.create("https://api.example.com/graphql");
HttpGraphQlClient httpClient = HttpGraphQlClient.builder(webClient).build();

Mono<Book> book = httpClient.document("""
        query GetBook($id: ID!) {
          book(id: $id) { title publishedYear author { name } }
        }
        """)
    .variable("id", "42")
    .retrieve("book")
    .toEntity(Book.class);

// WebSocket: subscriptions, and optionally queries/mutations over the same connection.
WebSocketGraphQlClient wsClient = WebSocketGraphQlClient.builder(
        "wss://api.example.com/graphql", new ReactorNettyWebSocketClient())
    .build();

Flux<BookEvent> events = wsClient.document("subscription { bookAdded { title } }")
    .retrieveSubscription("bookAdded")
    .toEntity(BookEvent.class);

// RSocket: an alternative binary transport, useful inside a JVM-to-JVM service mesh.
RSocketGraphQlClient rsocketClient = RSocketGraphQlClient.builder()
    .tcp("localhost", 9000)
    .route("graphql")
    .build();

HttpGraphQlClient is the one most applications reach for — a thin wrapper over Spring’s reactive WebClient that adds GraphQL-specific request building and response decoding on top of an ordinary HTTP POST. WebSocketGraphQlClient is the counterpart for subscriptions, since a long-lived subscription needs a persistent connection rather than one request per operation; it can also carry regular queries and mutations over that same connection when a server supports it. RSocketGraphQlClient targets RSocket transport, most often seen inside a service mesh of JVM applications rather than facing a browser. Every one of the three implements the same GraphQlClient interface, so application code that builds and sends a request rarely needs to know which transport it is actually running over.

retrieve(path) returns just the data at that JSON path and raises an exception if the response carries errors, while execute() returns the full ClientGraphQlResponse — both data and errors — for callers that need to inspect partial-success responses themselves, mirroring the envelope Response \& error handling documents at the protocol level.

Python: gql

gql is the most widely used general-purpose GraphQL client for Python — not tied to any one web framework, and built around a pluggable transport the same way Spring for GraphQL’s GraphQlClient is: you construct a Client with a transport object, and the transport decides whether requests go out over synchronous HTTP, async HTTP, or a persistent WebSocket for subscriptions.

from gql import Client, gql
from gql.transport.aiohttp import AIOHTTPTransport
from gql.transport.websockets import WebsocketsTransport

transport = AIOHTTPTransport(
    url="https://api.example.com/graphql",
    headers={"Authorization": "Bearer <token>"},
)
client = Client(transport=transport, fetch_schema_from_transport=True)

query = gql("""
    query GetBook($id: ID!) {
      book(id: $id) {
        title
        publishedYear
        author { name }
      }
    }
""")

async with client as session:
    result = await session.execute(query, variable_values={"id": "42"})
    print(result["book"]["title"])

fetch_schema_from_transport=True has gql run an introspection query against the server on connect and use the result to validate every subsequent query document locally before sending it — catching a typo’d field name at the call site instead of as a server-side validation error. Swapping AIOHTTPTransport for WebsocketsTransport (and calling session.subscribe(…​) instead of session.execute(…​)) is all that changes to consume a subscription; gql also ships a plain RequestsHTTPTransport for synchronous, non-asyncio codebases that don’t need the async/await surface shown above.

This page is generated with the assistance of AI. Verify exact class names, constructor arguments, and transport behavior for both the JVM client and gql against Spring for GraphQL — Client and the gql repository’s own documentation before relying on them.

How to choose

Five clients now stand alongside each other across this section: Apollo Client, urql, Relay, the JVM GraphQlClient family, and gql. None is strictly better than the others — each optimizes for a different combination of language, framework, and how much caching/pagination structure you want the client to enforce for you.

Client Best fit Trade-off

Apollo Client

React/Vue/Angular apps that want a batteries-included client with a mature ecosystem (devtools, code generation, extensive docs). See Apollo Client configuration.

Larger dependency footprint and more configuration surface than urql for teams that don’t need every feature.

urql

Teams that want normalized caching as an option rather than a default, or that want to compose custom pipeline behavior (auth, retry, logging) as small, testable exchanges.

Smaller ecosystem than Apollo Client; the normalized cache (@urql/exchange-graphcache) is an opt-in add-on, not the bundled default.

Relay

Large React codebases that want compile-time-enforced data-fetching discipline — colocated fragments, no fetch-on-render waterfalls, and built-in conventions for cursor pagination.

The compiler build step and its conventions (@connection, fragment colocation, generated artifacts) are a steeper learning curve and a firmer opinion than urql or Apollo Client impose.

JVM GraphQlClient

A JVM backend or batch job calling another GraphQL service — server-to-server calls, integration tests, or a Spring application consuming a schema it doesn’t own.

Not a browser client — no framework bindings, normalized cache, or UI integration; it is a request/response (and subscription) building block, not an application data layer.

gql

A Python script, batch job, or backend service that needs to call a GraphQL API, with the option to validate queries locally via fetch_schema_from_transport.

Also not a UI client — no framework bindings or cache layer; pick a transport per use case (aiohttp for async, requests for sync, websockets for subscriptions).

As a rule of thumb: reach for Apollo Client or Relay when the client is a React UI and you want the framework to manage a cache for you (Relay if you want that management compiler-enforced; Apollo Client otherwise); reach for urql when you want the same UI-cache role with a smaller, more composable pipeline; and reach for the JVM GraphQlClient family or gql whenever the caller is not a browser UI at all, but another service, a script, or a test.