urql as a Lightweight GraphQL Client: When It's Enough
AI generated
{ }
type
GraphQL · Client · React · Tooling
urql as a Lightweight GraphQL Client
An exchange pipeline instead of a monolith, and when the leaner solution is enough

urql replaces the monolithic client core of other GraphQL clients with an exchange pipeline made of swappable, individually testable middleware functions. Normalized caching is optional through Graphcache instead of a default. This article shows when that leaner architecture is genuinely enough for a project.

15 min read Exchange pipeline · Graphcache React · Vue · Svelte

1. Why a third GraphQL client exists alongside Apollo and Relay

Apollo Client and Relay dominate the discussion around GraphQL clients, but both ship a considerable feature set that not every project needs: elaborate cache policies, optimistic UI mechanisms, compiler-generated artifacts or a deep plugin system. urql, built by the team behind Formidable and later maintained by The Guild, grew out of the observation that many projects prefer a noticeably leaner client with clearly traceable behavior.

Instead of a fixed core with optional extensions, urql is built from the ground up as a pipeline of individual functions, the exchanges, through which every operation flows in sequence. That makes the client's core behavior easy to follow and lets teams swap out individual behaviors without having to understand or fork the entire client.

2. The exchange pipeline architecture: urql's core principle

An exchange in urql is a pure function that accepts a stream of incoming operations and returns a stream of results, similar to middleware in Express or Redux. Every request runs through the configured chain of exchanges in order, and each exchange can decide to pass the operation along unchanged, transform it, or answer it directly itself, for example from a cache, without the request ever reaching the network.

This architecture makes urql extensible from the ground up without The Guild or the community having to bake every conceivable feature into the core: a custom exchange for authentication, retry logic or request deduplication can be written as an isolated, independently testable function and simply plugged into the chain, at exactly the position where it should act.


import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql'

const client = createClient({
  url: 'https://api.mironsoft.de/graphql',
  exchanges: [dedupExchange, cacheExchange, fetchExchange],
})

3. The default exchanges: dedupExchange, cacheExchange, fetchExchange

A standard urql client typically consists of three exchanges in a fixed order: dedupExchange detects and merges identical, concurrently running requests into a single network operation, cacheExchange answers requests from a simple, document-based cache when possible, and fetchExchange performs the actual network request at the end of the chain if no previous exchange has already answered the request.

This default cache works on a document basis rather than a normalized one: every query is cached as a whole, without linking individual entities by type and ID the way Apollo Client's InMemoryCache or Relay's store do. That is entirely sufficient for many use cases and avoids the complexity of cache invalidation, but it requires a team to consciously decide when this simpler cache strategy hits its limits.


import { Client, dedupExchange, cacheExchange, fetchExchange } from 'urql'

// The default pipeline: dedupe -> document cache -> network fetch
const client = new Client({
  url: '/graphql',
  exchanges: [
    dedupExchange,
    cacheExchange,
    fetchExchange,
  ],
})

4. Graphcache: optional normalized caching as an exchange

As soon as a project needs real normalized caching, for example because a mutated entity has to be updated consistently across several independent queries, Graphcache, implemented as its own exchange, simply replaces the default cacheExchange in the chain. Graphcache normalizes objects by type and ID, similar to Apollo Client's InMemoryCache, but unlike it ships an explicit update API where developers specify how a mutation changes the cache of other affected queries.

Because Graphcache is implemented as a swappable exchange rather than a fixed built-in part, the rest of the urql configuration stays unchanged when a team switches from document-based to normalized caching. That allows starting with the simple default cache and only moving to Graphcache once a concrete consistency problem actually shows up, instead of front-loading that complexity from the start.


import { createClient, fetchExchange } from 'urql'
import { cacheExchange } from '@urql/exchange-graphcache'

const cache = cacheExchange({
  keys: {
    Product: (product) => product.id,
  },
  updates: {
    Mutation: {
      addReview: (result, args, cache) => {
        cache.invalidate({ __typename: 'Product', id: args.productId })
      },
    },
  },
})

const client = createClient({
  url: '/graphql',
  exchanges: [cache, fetchExchange],
})

5. Writing custom exchanges: an auth exchange example

Because every exchange is a pure, independently testable function, cross-cutting logic like authentication can be cleanly isolated instead of being spread across request interceptors or global configuration. An auth exchange, for example, attaches a current access token as a header to every outgoing operation and can automatically trigger a token refresh on a 401 response, then resend the original operation through the pipeline afterward.

The official @urql/exchange-auth package already implements exactly this pattern, but it also shows how easily a custom, project-specific exchange can be written when the default solution does not fit exactly. This extensibility without forking the core is one of the practical advantages of the exchange architecture over more monolithic clients.


import { authExchange } from '@urql/exchange-auth'

const auth = authExchange(async (utils) => {
  let token = localStorage.getItem('authToken')
  return {
    addAuthToOperation: (operation) => {
      return utils.appendHeaders(operation, {
        Authorization: token ? `Bearer ${token}` : '',
      })
    },
    didAuthError: (error) => error.response?.status === 401,
    refreshAuth: async () => {
      token = await refreshAccessToken()
      localStorage.setItem('authToken', token)
    },
  }
})

6. Bundle size and entry barrier compared to Apollo Client

The urql core without Graphcache brings only a fraction of Apollo Client's bundle size, because features like normalized caching, optimistic updates or local state management are not included by default, they are only added through additional exchanges when needed. For projects where bundle size on mobile devices or in low-bandwidth environments plays a real role, that difference is noticeable.

The entry barrier is correspondingly lower: a basic setup with useQuery and useMutation can be put together in a few lines, without a team first having to work through cache policies, type policies or fragment conventions the way advanced use cases require with Apollo Client.

7. When urql's lower feature depth is not a drawback

For small to medium applications with a manageable data model, where mutations rarely affect several independent queries at once, urql's simple document-based default cache is usually entirely sufficient. Projects that already rely on server-side rendering with frequent full-page reloads also rarely benefit from the elaborate optimistic UI mechanisms that Apollo Client or Relay bring along.

urql also fits teams that deliberately prefer a smaller, more fully understandable codebase, for example in libraries or widgets embedded in third-party applications, where every extra kilobyte of bundle size counts. In such contexts, urql's lower feature depth weighs less than the benefit of a smaller, clearly traceable dependency.

8. When Apollo Client remains the better choice despite the larger feature set

As soon as a project needs complex, fine-grained cache policies per field type, for example different behavior for paginated lists versus single objects, or depends on a large ecosystem of ready-made integrations like Apollo Studio, Apollo Router and official Federation support, Apollo Client offers more built-in solutions without a team having to rebuild them itself through exchanges.

The sheer size of the community also favors Apollo Client in complex projects: for nearly every advanced caching or pagination problem, a documented solution or an official package already exists, while urql teams more often have to build their own exchanges from scratch for very specific requirements.

9. Decision guide: urql, Apollo Client or Relay

Anyone looking for a lean, easy to understand foundation with optional normalized caching, without needing deep integration into a specific ecosystem, is well served by urql. Anyone needing fine-grained cache control, a large plugin ecosystem or official Federation support is better off with Apollo Client, and anyone wanting to enforce maximum consistency in a very large React team should look at Relay.

The table below compares the three clients along the key criteria.

Criterion urql Apollo Client Relay
Architecture Exchange pipeline Monolithic core with link chain Compiler-generated store
Normalized caching Optional via Graphcache Default via InMemoryCache Default, strictly typed
Bundle size Very small in base setup Medium to large Medium plus compiler tooling
Entry barrier Low Medium High

Mironsoft

GraphQL schema design, resolver performance and API security

GraphQL APIs that hold up under real load?

We review existing GraphQL schemas and resolvers, uncover N+1 problems and missing query limits, and turn that into an API that holds performance, security and maintainability together.

Schema Review

Checking types, resolvers and permissions for consistency and security gaps.

Performance Optimization

Deploying DataLoader, caching and query complexity limits against N+1 and overfetching.

Production Hardening

Setting up rate limiting, introspection protection and monitoring for production.

10. Summary

urql: The Essentials at a Glance

Architecture

urql replaces a monolithic client core with a chain of swappable exchanges that process every operation in sequence.

Caching

The default cache works on a document basis, Graphcache delivers normalized caching as a swappable exchange when needed.

Bundle size

Without Graphcache, urql brings only a fraction of Apollo Client's bundle size, relevant for mobile and embedded applications.

Best fit

Small to medium apps with a manageable data model benefit most, complex cache policies favor Apollo Client instead.

11. FAQ: urql: The Essentials at a Glance

1What architecturally distinguishes urql from Apollo Client?
urql processes every operation through a chain of swappable exchange functions, while Apollo Client uses a fixed core with a link chain for network logic and a built-in InMemoryCache.
2Is normalized caching active by default in urql?
No. The default cacheExchange works on a document basis. Normalized caching is only added once Graphcache is plugged into the chain as its own exchange.
3What is Graphcache?
Graphcache is an optional exchange for urql that caches objects normalized by type and ID, similar to Apollo Client's InMemoryCache, including an explicit update API for mutations.
4Is urql suitable for React Native?
Yes, urql works across frameworks and is officially supported for React, Vue, Svelte and React Native, each with its own bindings for the exchange pipeline.
5How do I write a custom exchange?
An exchange is a pure function that accepts a stream of operations and returns a stream of results. For common cases like authentication, official packages such as @urql/exchange-auth already exist as a template.
6When does urql's default cache stop being enough?
As soon as a mutation changes an entity that appears in several independent, already cached queries and needs to be updated consistently there, normalized caching via Graphcache is usually the better choice.
7Does urql support Federation?
urql itself is a pure client and works independently of whether the backend is federated or not, as long as it talks to a single GraphQL schema, the way a Federation router exposes one externally.
8How big is the bundle size difference between urql and Apollo Client really?
The urql core without Graphcache sits in the low single-digit kilobyte range, while Apollo Client with InMemoryCache and a standard setup adds noticeably more kilobytes to the bundle. For most web apps the difference is noticeable but rarely decisive.
9Can I switch from urql to Apollo Client later?
Technically yes, but queries, cache configuration and in part component structure need adjustment, since both clients use different hooks and cache models. A switch is more of a rewrite than a simple migration.
10What kind of project is urql the clear recommendation for?
Small to medium applications with a manageable data model, libraries and widgets where bundle size matters, and teams that prefer a small, fully traceable client codebase.