Skip to main content

Apollo Client vs urql in 2026: GraphQL Client Libraries

·PkgPulse Team
0

TL;DR

Apollo Client for complex GraphQL with caching needs; urql for lighter-weight GraphQL in React. Apollo Client (~6M weekly downloads) is the full-featured option — normalized cache, devtools, subscriptions, local state. urql (~2M downloads) is modular and smaller, with a document cache by default instead of normalized cache. For simple GraphQL data fetching, urql is often sufficient and easier to configure.

Key Takeaways

  • Apollo Client: ~6M weekly downloads — urql: ~2M (npm, March 2026)
  • Apollo has normalized cache — entities cached by ID, smarter updates
  • urql uses document cache by default — simpler, faster, less overhead
  • Apollo has better devtools — Apollo DevTools browser extension is excellent
  • Both support subscriptions — via WebSocket or SSE

GraphQL vs REST: When to Use Which

Before choosing a GraphQL client, the first question is whether GraphQL is the right choice at all. GraphQL adds complexity (schema definition, resolver implementation, query language for clients) that isn't always justified.

Use GraphQL when:

  • Multiple clients (web, mobile, third-party) need different data shapes from the same API
  • Frontend teams need to iterate on data requirements without backend deploys
  • You have a complex domain with many related entities and want to avoid N+1 over-fetching

Use REST when:

  • Simple CRUD API with well-defined endpoints
  • Small team where both frontend and backend are maintained together
  • You want the simplest possible API surface

If your team decides on GraphQL, the choice between Apollo Client and urql is your next decision.


Cache Philosophy

Apollo Client — normalized cache (InMemoryCache):
  - Each object is stored by type + ID
  - Same object in multiple queries → one cache entry
  - Updates in one query automatically reflect in others
  - Powerful but complex to configure
  - Higher memory usage

urql — document cache (default):
  - Caches by query + variables
  - Simple: same query → same result
  - No normalization → simpler reasoning
  - Can opt into normalized cache (urql/graphcache)
  - Lower memory usage

Setup

// Apollo Client — setup
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://api.example.com/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      User: {
        fields: {
          // Merge strategies for pagination, etc.
        },
      },
    },
  }),
});

function App() {
  return (
    <ApolloProvider client={client}>
      <MyApp />
    </ApolloProvider>
  );
}
// urql — minimal setup
import { createClient, Provider } from 'urql';

const client = createClient({
  url: 'https://api.example.com/graphql',
  fetchOptions: {
    headers: { Authorization: `Bearer ${getToken()}` },
  },
});

function App() {
  return (
    <Provider value={client}>
      <MyApp />
    </Provider>
  );
}

Querying

// Apollo Client — useQuery
import { useQuery, gql } from '@apollo/client';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts { id title }
    }
  }
`;

function UserProfile({ id }) {
  const { data, loading, error } = useQuery(GET_USER, {
    variables: { id },
    fetchPolicy: 'cache-first', // 'cache-and-network', 'network-only', etc.
  });

  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <div>{data.user.name}</div>;
}
// urql — useQuery
import { useQuery, gql } from 'urql';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id name email
      posts { id title }
    }
  }
`;

function UserProfile({ id }) {
  const [result] = useQuery({ query: GET_USER, variables: { id } });
  const { data, fetching, error } = result;

  if (fetching) return <Spinner />;
  if (error) return <Error />;
  return <div>{data.user.name}</div>;
}

Mutations

// Apollo Client — useMutation with cache update
import { useMutation, gql } from '@apollo/client';

const CREATE_POST = gql`
  mutation CreatePost($title: String!, $content: String!) {
    createPost(title: $title, content: $content) { id title }
  }
`;

function NewPostForm() {
  const [createPost, { loading, error }] = useMutation(CREATE_POST, {
    update(cache, { data: { createPost } }) {
      cache.modify({
        fields: {
          posts(existingPosts = []) {
            const newPostRef = cache.writeFragment({
              data: createPost,
              fragment: gql`fragment NewPost on Post { id title }`,
            });
            return [...existingPosts, newPostRef];
          },
        },
      });
    },
  });

  const handleSubmit = () =>
    createPost({ variables: { title, content } });
}
// urql — useMutation
import { useMutation, gql } from 'urql';

const CREATE_POST = gql`
  mutation CreatePost($title: String!, $content: String!) {
    createPost(title: $title, content: $content) { id title }
  }
`;

function NewPostForm() {
  const [result, createPost] = useMutation(CREATE_POST);
  const { fetching, error } = result;

  const handleSubmit = () =>
    createPost({ title, content });
  // urql's document cache auto-invalidates related queries
  // No manual cache update needed for simple cases
}

The cache update difference is significant in practice. Apollo's normalized cache is powerful but requires you to write explicit cache update code after mutations — you must tell Apollo which cache entries to update or invalidate. urql's document cache automatically invalidates queries that contain the same types as the mutation result. For most CRUD operations, urql's auto-invalidation is sufficient.


Subscriptions

Both support real-time updates via WebSocket:

// Apollo Client — subscriptions
import { useSubscription, gql } from '@apollo/client';

const MESSAGE_ADDED = gql`
  subscription MessageAdded($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id content author { name }
    }
  }
`;

function ChatChannel({ channelId }) {
  const { data } = useSubscription(MESSAGE_ADDED, {
    variables: { channelId },
    onData: ({ client, data }) => {
      // Update Apollo cache with new message
      client.cache.modify({ /* ... */ });
    },
  });
}
// urql — subscriptions with wonka
import { useSubscription, gql } from 'urql';

const MESSAGE_ADDED = gql`
  subscription MessageAdded($channelId: ID!) {
    messageAdded(channelId: $channelId) {
      id content author { name }
    }
  }
`;

function ChatChannel({ channelId }) {
  const [result] = useSubscription({
    query: MESSAGE_ADDED,
    variables: { channelId },
  });
  // result.data contains the latest subscription event
}

Error Handling

// Apollo Client — structured errors
const { data, error } = useQuery(GET_USER, { variables: { id } });

if (error) {
  // error.graphQLErrors — errors from the GraphQL layer
  // error.networkError — HTTP/network failures
  error.graphQLErrors.forEach(e => {
    if (e.extensions?.code === 'UNAUTHENTICATED') {
      redirectToLogin();
    }
  });
}
// urql — CombinedError
const [{ data, error }] = useQuery({ query: GET_USER, variables: { id } });

if (error) {
  // error.graphQLErrors — GraphQL errors
  // error.networkError — network failures
  // error.message — combined message
}

TanStack Query as an Alternative

If your team is already using TanStack Query for REST data fetching, consider using it for GraphQL too rather than adding a dedicated GraphQL client:

// TanStack Query + graphql-request (no dedicated client)
import { useQuery } from '@tanstack/react-query';
import { request, gql } from 'graphql-request';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) { id name email }
  }
`;

function UserProfile({ id }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', id],
    queryFn: () => request('https://api.example.com/graphql', GET_USER, { id }),
  });
}

This approach gives you TanStack Query's excellent caching and background refetching semantics with minimal GraphQL-specific library overhead. You lose normalized caching and subscription support, but gain consistency with the rest of your data fetching layer.


When to Choose

Choose Apollo Client when:

  • Large GraphQL schemas with complex entity relationships
  • Normalized cache is needed (same entity in many queries)
  • Apollo DevTools are valuable for your team
  • Optimistic UI updates and rollback
  • Local state management via Apollo reactive variables

Choose urql when:

  • Simpler GraphQL usage (mostly queries)
  • Bundle size matters (~15KB vs Apollo's ~40KB gzipped)
  • You want easier configuration without typePolicies
  • Document-cache behavior is sufficient (most CRUD apps)
  • Team is building with urql's exchange system for customization

Compare Apollo Client and urql package health on PkgPulse. Also see tRPC v11 for a type-safe REST alternative and how to set up a modern React project for the full data fetching stack.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.