<!-- APIScout AI-readable guide source -->
<!-- Canonical: https://apiscout.dev/guides/graphql-client-patterns-production-2026 -->
<!-- Raw Markdown: https://apiscout.dev/guides/graphql-client-patterns-production-2026/raw.md -->
<!-- Source path: content/guides/graphql-client-patterns-production-2026.mdx -->

---
og_image: "/images/guides/graphql-client-patterns-production-2026.webp"
title: "GraphQL Client Patterns for Production 2026"
description: "Production GraphQL client patterns in 2026 — Apollo vs urql, typed codegen, caching, error handling, pagination, optimistic updates, and real-time subs."
date: "2026-03-08"
author: "APIScout Team"
tags: ["graphql", "apollo", "urql", "api-integration", "frontend"]
tier: 1
---

GraphQL gives you exactly the data you need. But a production GraphQL client needs more than queries — it needs caching, error handling, optimistic updates, pagination, and offline support. Here are the patterns that work at scale.

## Choosing a GraphQL Client

| Client | Bundle Size | Cache | Best For |
|--------|-----------|-------|----------|
| **Apollo Client** | ~33KB | Normalized | Full-featured, enterprise |
| **urql** | ~8KB | Document/normalized | Lightweight, flexible |
| **TanStack Query + graphql-request** | ~12KB + 2KB | Query-based | Simple, REST-like DX |
| **Relay** | ~30KB | Normalized, compiler | Meta-scale apps |
| **graphql-request** | ~2KB | None | Simple scripts, SSR |

### Quick Comparison

```typescript
// Apollo Client
const { data, loading, error } = useQuery(GET_USER, {
  variables: { id: '123' },
});

// urql
const [result] = useQuery({ query: GET_USER, variables: { id: '123' } });
const { data, fetching, error } = result;

// TanStack Query + graphql-request
const { data, isLoading, error } = useQuery({
  queryKey: ['user', '123'],
  queryFn: () => graphqlClient.request(GET_USER, { id: '123' }),
});
```

## Pattern 1: Type-Safe Queries with Codegen

Generate TypeScript types from your GraphQL schema:

```bash
# Install
npm install -D @graphql-codegen/cli @graphql-codegen/typescript \
  @graphql-codegen/typescript-operations @graphql-codegen/typed-document-node

# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: 'https://api.example.com/graphql',
  documents: 'src/**/*.graphql',
  generates: {
    './src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typed-document-node',
      ],
    },
  },
};

export default config;
```

```graphql
# src/queries/user.graphql
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    avatar
    posts {
      id
      title
    }
  }
}

mutation UpdateUser($id: ID!, $input: UpdateUserInput!) {
  updateUser(id: $id, input: $input) {
    id
    name
    email
  }
}
```

```typescript
// Usage — fully type-safe, no `any`
import { GetUserDocument, UpdateUserDocument } from '@/generated/graphql';

const { data } = useQuery(GetUserDocument, { variables: { id: '123' } });
// data.user.name is typed as string
// data.user.posts[0].title is typed as string
// data.user.nonExistent → TypeScript error
```

## Pattern 2: Error Handling

```typescript
// Centralized error handling
function useGraphQL<TData, TVariables>(
  document: TypedDocumentNode<TData, TVariables>,
  variables?: TVariables
) {
  const { data, loading, error } = useQuery(document, {
    variables,
    errorPolicy: 'all', // Return partial data with errors
  });

  // Categorize errors
  if (error) {
    const graphQLErrors = error.graphQLErrors || [];
    const networkError = error.networkError;

    // Network error (API unreachable)
    if (networkError) {
      return {
        data: null,
        error: { type: 'network', message: 'Unable to connect. Check your connection.' },
        loading: false,
      };
    }

    // Auth error
    const authError = graphQLErrors.find(e =>
      e.extensions?.code === 'UNAUTHENTICATED'
    );
    if (authError) {
      // Redirect to login
      router.push('/login');
      return { data: null, error: { type: 'auth', message: 'Session expired' }, loading: false };
    }

    // Validation error
    const validationError = graphQLErrors.find(e =>
      e.extensions?.code === 'BAD_USER_INPUT'
    );
    if (validationError) {
      return {
        data: null,
        error: {
          type: 'validation',
          message: validationError.message,
          fields: validationError.extensions?.fields,
        },
        loading: false,
      };
    }

    // Generic error
    return {
      data: null,
      error: { type: 'unknown', message: 'Something went wrong' },
      loading: false,
    };
  }

  return { data, error: null, loading };
}
```

## Pattern 3: Optimistic Updates

Update the UI immediately, then sync with the server:

```typescript
const [updateTodo] = useMutation(UPDATE_TODO, {
  // Optimistic response — show update immediately
  optimisticResponse: {
    updateTodo: {
      __typename: 'Todo',
      id: todoId,
      text: newText,
      completed: true,
    },
  },

  // Update cache with the optimistic (then real) response
  update(cache, { data }) {
    cache.modify({
      id: cache.identify({ __typename: 'Todo', id: todoId }),
      fields: {
        text: () => data.updateTodo.text,
        completed: () => data.updateTodo.completed,
      },
    });
  },

  // If the mutation fails, Apollo automatically reverts the optimistic update
  onError(error) {
    toast.error('Failed to update todo. Please try again.');
  },
});
```

## Pattern 4: Pagination

### Cursor-Based (Relay-style)

```typescript
const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          createdAt
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

function PostList() {
  const { data, loading, fetchMore } = useQuery(GET_POSTS, {
    variables: { first: 20 },
  });

  const loadMore = () => {
    fetchMore({
      variables: {
        first: 20,
        after: data.posts.pageInfo.endCursor,
      },
      updateQuery: (prev, { fetchMoreResult }) => ({
        posts: {
          ...fetchMoreResult.posts,
          edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
        },
      }),
    });
  };

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      {data?.posts.pageInfo.hasNextPage && (
        <button onClick={loadMore} disabled={loading}>
          Load More
        </button>
      )}
    </div>
  );
}
```

### Infinite Scroll

```typescript
function useInfiniteGraphQL(query: DocumentNode, variables: any) {
  const { data, loading, fetchMore } = useQuery(query, { variables });
  const observerRef = useRef<IntersectionObserver>();
  const loadMoreRef = useCallback((node: HTMLElement | null) => {
    if (loading) return;
    if (observerRef.current) observerRef.current.disconnect();

    observerRef.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && data?.posts.pageInfo.hasNextPage) {
        fetchMore({
          variables: { after: data.posts.pageInfo.endCursor },
        });
      }
    });

    if (node) observerRef.current.observe(node);
  }, [loading, data, fetchMore]);

  return { data, loading, loadMoreRef };
}

// Usage
function PostFeed() {
  const { data, loading, loadMoreRef } = useInfiniteGraphQL(GET_POSTS, { first: 20 });

  return (
    <div>
      {data?.posts.edges.map(({ node }) => (
        <PostCard key={node.id} post={node} />
      ))}
      <div ref={loadMoreRef} /> {/* Trigger when visible */}
      {loading && <Spinner />}
    </div>
  );
}
```

## Pattern 5: Subscriptions (Real-Time)

```typescript
// WebSocket-based real-time updates
const MESSAGES_SUBSCRIPTION = gql`
  subscription OnNewMessage($channelId: ID!) {
    messageCreated(channelId: $channelId) {
      id
      text
      sender {
        id
        name
      }
      createdAt
    }
  }
`;

function ChatRoom({ channelId }: { channelId: string }) {
  const { data: messages } = useQuery(GET_MESSAGES, {
    variables: { channelId },
  });

  // Subscribe to new messages
  useSubscription(MESSAGES_SUBSCRIPTION, {
    variables: { channelId },
    onData({ data }) {
      // New message arrives — update cache
      const newMessage = data.data.messageCreated;
      // Apollo automatically updates if using cache policies
    },
  });

  return (
    <div>
      {messages?.channel.messages.map(msg => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}
```

## Pattern 6: Fragment Colocation

Keep data requirements next to the component that uses them:

```typescript
// UserCard.tsx — declares its own data needs
export const USER_CARD_FRAGMENT = gql`
  fragment UserCardFields on User {
    id
    name
    avatar
    role
  }
`;

function UserCard({ user }: { user: UserCardFieldsFragment }) {
  return (
    <div>
      <img src={user.avatar} alt={user.name} />
      <h3>{user.name}</h3>
      <span>{user.role}</span>
    </div>
  );
}

// Parent page composes fragments
const GET_TEAM = gql`
  ${USER_CARD_FRAGMENT}
  query GetTeam($teamId: ID!) {
    team(id: $teamId) {
      id
      name
      members {
        ...UserCardFields
      }
    }
  }
`;
```

## Common Mistakes

| Mistake | Impact | Fix |
|---------|--------|-----|
| No codegen | Manual types, runtime errors | Set up graphql-codegen |
| Over-fetching with `*` | Defeats GraphQL's purpose | Query only needed fields |
| No error categorization | Generic "something went wrong" | Handle auth, validation, network separately |
| Missing optimistic updates | UI feels slow | Add optimistic responses for mutations |
| N+1 queries on client | Many round-trips | Use fragments, batch queries |
| No pagination | Loading all data at once | Use cursor-based pagination |

---

## Apollo vs urql: Choosing in 2026

Both are excellent production-grade clients — the decision comes down to bundle size tolerance and how much normalized caching complexity you're willing to manage.

Apollo Client's normalized cache is its defining feature. When you fetch a `User` object anywhere in your app, Apollo stores it by its cache key (typically `User:123`). When that user's data updates — via a mutation, a refetch, or a direct cache write — every query that included that user automatically reflects the change without you writing any synchronization code. For complex UIs where the same entity (a user, a post, a product) appears in multiple places on screen simultaneously, this is genuinely elegant. The cost is real: Apollo's `InMemoryCache` configuration is not simple. Field policies, `keyFields` for custom cache keys, `merge` functions for paginated lists, and `read` functions for derived fields all require careful thought. Misconfigured cache behavior produces subtle bugs that are hard to reproduce.

urql's normalized cache (the Graphcache plugin, installed separately) offers comparable power at roughly a quarter of the bundle size — ~8KB versus Apollo's ~33KB. The tradeoff is that Graphcache requires more explicit configuration upfront; Apollo's defaults handle more cases automatically, including simple list appends and object updates. For teams already invested in the Apollo ecosystem — using Apollo Server, Apollo Federation, or Apollo on React Native — staying in the ecosystem has real value: shared tooling, consistent mental models, and one fewer vendor to evaluate.

For greenfield projects where bundle size matters (e-commerce storefronts, content-heavy sites, performance-sensitive SPAs), urql's lighter footprint is worth the slightly different DX. The developer experience is intentionally similar to Apollo, so switching costs are low.

TanStack Query paired with `graphql-request` is the right call in a specific scenario: you're already using TanStack Query for REST endpoints and want a single, consistent data-fetching layer across your app. You get TanStack Query's excellent devtools, background refetching, and query invalidation, with `graphql-request` handling the HTTP transport. The limitation is that you don't get normalized caching — each query is cached independently by its query key, not by entity identity. For most CRUD-heavy apps this is fine.

## GraphQL in Next.js App Router

App Router changes the rules for GraphQL clients in meaningful ways. The old mental model — initialize Apollo once, wrap the app in `ApolloProvider`, use hooks everywhere — doesn't translate directly.

Client Components (marked `'use client'`) work exactly as before. Apollo's `useQuery` and `useMutation` hooks, urql's `useQuery`, and TanStack Query's `useQuery` all function normally inside Client Components because they run in the browser where React context exists. No changes needed here.

Server Components are a different story. Hooks don't work in Server Components — they run on the server synchronously, without a React lifecycle. There's no Provider context to inject the Apollo client from. The straightforward approach: use `graphql-request` directly in `async` Server Components. It's a simple HTTP client with no client-side state, which is exactly what you need for server-side fetches. Fetch your data, pass it as props to Client Components, and let the Client Components handle any subsequent interactive queries with Apollo or urql.

If you need the Apollo cache pre-populated on the client for a smoother initial render, the pattern is: fetch data in a Server Component using the Apollo client in a server-only mode (no reactive cache), serialize it, and hydrate the client-side Apollo cache via `ApolloProvider`'s `initialState` prop. This is more complex than it sounds and the Apollo docs have specific guidance for App Router. For most teams, the simpler split — `graphql-request` for server, Apollo/urql for client — is easier to reason about and easier to maintain.

---

## Cache Management in Production

Apollo's normalized cache is a feature until it's a bug. The most common production issue: stale cache entries that don't update when they should, or cache writes that corrupt adjacent query results due to incorrect merge logic.

When to use `refetchQueries`: mutations that affect multiple queries in ways the cache can't automatically reconcile should explicitly refetch those queries. The standard pattern is including `refetchQueries` in your `useMutation` call with the queries that need updating. For example, a mutation that deletes an item from a list should refetch the list query, or use `cache.evict` to remove the deleted item from the cache by its ID. `refetchQueries` re-runs the network request; `cache.evict` + `cache.gc()` removes the entry and triggers a re-render of affected queries without a network call.

Polling versus subscriptions: `useQuery` has a built-in `pollInterval` option for queries that need to stay fresh without real-time subscriptions. For dashboards and status displays where data changes every few minutes, polling (e.g., `pollInterval: 30000` for a 30-second interval) is simpler to implement and doesn't require WebSocket infrastructure. Use subscriptions when you need sub-second updates — chat, collaborative editing, live pricing. The operational cost of subscriptions is higher: you need WebSocket-capable infrastructure, connection management, and reconnection logic.

Optimistic UI pitfalls: optimistic updates that don't match the server response produce a visible flash when the real response overwrites the optimistic value. Make your optimistic response match the server's exact shape, including `__typename` fields and all expected fields — missing fields in the optimistic response cause the component to briefly render with undefined values. Test optimistic updates explicitly with delayed server responses to catch timing-dependent UI glitches.

Cache persistence for offline support: Apollo Client's `apollo3-cache-persist` library serializes the normalized cache to localStorage or AsyncStorage, allowing the app to show cached data on load before the network response arrives. This is most valuable for mobile apps and progressive web apps where offline or low-connectivity scenarios are common. Cache persistence adds startup complexity (you need to await cache hydration before rendering) and risks showing stale data if the app is opened after a long offline period — include a cache TTL check to clear stale persisted caches on launch.

## Testing GraphQL Queries

Apollo Client includes a `MockedProvider` component for unit testing. Wrap your component in `MockedProvider` with an array of mocked responses, render the component, and assert the UI matches expectations after the mock resolves:

```tsx
const mocks = [{
  request: { query: GET_USER, variables: { id: '1' } },
  result: { data: { user: { id: '1', name: 'Alice', email: 'alice@example.com', avatar: null, posts: [] } } },
}];

render(
  <MockedProvider mocks={mocks} addTypename={false}>
    <UserCard userId="1" />
  </MockedProvider>
);

await waitFor(() => {
  expect(screen.getByText('Alice')).toBeInTheDocument();
});
```

For integration tests with a real GraphQL server, use `msw` (Mock Service Worker) to intercept the `fetch` calls made by `graphql-request` or Apollo's HTTP link. This gives you realistic behavior — network timing, error responses, partial data — without requiring a live server in CI. MSW also works in browser tests (Playwright, Cypress) and Node.js tests without changing your test assertions.

## Methodology

GraphQL client versions: Apollo Client 3.11 (stable as of March 2026, includes React 19 Suspense support), urql 4.x with Graphcache plugin, TanStack Query 5.x paired with graphql-request 6.x, Relay 17. graphql-codegen 5.x, @graphql-codegen/cli. Bundle size figures are gzipped production builds without tree-shaking; actual bundle impact depends on which Apollo features your app uses. Next.js App Router GraphQL patterns are as of Next.js 15 stable. Apollo Server 4.x for server-side context. urql's Graphcache plugin must be installed separately (`@urql/exchange-graphcache`) — the normalized cache is not included in the base `urql` package. Apollo DevTools browser extension works with all Apollo Client 3.x versions and provides real-time cache inspection, query tracking, and mutation monitoring — install it for any production app using Apollo before debugging cache issues in the browser.

---

*Compare GraphQL APIs and clients on [APIScout](https://apiscout.dev) — find the best GraphQL endpoints, compare schema designs, and evaluate DX.*

*Related: [API Error Handling Patterns for Production Applications](/blog/api-error-handling-patterns-production-2026), [API Pagination: Cursor vs Offset in 2026](/blog/api-pagination-patterns-cursor-vs-offset-2026), [Best GraphQL Federation Platforms 2026](/blog/best-graphql-federation-platforms-2026)*
