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.
Table of Contents
- 1. Why a third GraphQL client exists alongside Apollo and Relay
- 2. The exchange pipeline architecture: urql's core principle
- 3. The default exchanges: dedupExchange, cacheExchange, fetchExchange
- 4. Graphcache: optional normalized caching as an exchange
- 5. Writing custom exchanges: an auth exchange example
- 6. Bundle size and entry barrier compared to Apollo Client
- 7. When urql's lower feature depth is not a drawback
- 8. When Apollo Client remains the better choice despite the larger feature set
- 9. Decision guide: urql, Apollo Client or Relay
- 10. Summary
- 11. FAQ
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.