Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Oct 6, 2026·13 min read

Error Handling on the Client: The 200 Trap, Partial Data & Error Boundaries

HTTP 200 with an errors array is not a successful response — it is a partial failure. Every error boundary that keys on HTTP status is silently corrupting your UI's data in production.

Error Handling on the Client: The 200 Trap, Partial Data & Error Boundaries

The bug is in production and it looks like this: a user's order history section is blank. No error state, no loading spinner, no toast. Just an empty list where orders should appear. The network tab shows HTTP 200. The developer sees a 200, concludes the request succeeded, and opens a backend ticket.
The backend responds in four hours: "The orders resolver threw an UNAUTHENTICATED error. It's in the response body — check errors[0].extensions.code."
The response was never successful. HTTP 200 with an errors array is a partial failure — the GraphQL layer processed the request, some resolvers succeeded, some failed, and the server returned both the partial data and the error details in the same response. Every error handler that keys on response.ok or HTTP status is architecturally wrong for GraphQL.
A GraphQL client is not a data fetcher — it is a local replica of your server's data graph. Partial failures corrupt the replica: some fields resolve correctly and update their cache entries, while failed fields remain null. Error handling is a replica consistency concern, not just a UI concern.

1. The GraphQL Error Model

GraphQL has two distinct error categories, and confusing them is the source of every error handling bug.

1.1 Network Errors — Transport-Level Failures

Network errors occur at the HTTP level, before GraphQL executes anything:
typescript
// Network errors: fetch throws, or HTTP returns 4xx/5xx
// Apollo surfaces these as error.networkError
const { data, error } = useQuery(GET_USER)
if (error?.networkError) {
  // Server is unreachable, request timed out, or server returned 500
  return <ServiceUnavailablePage />
}
These are the errors most engineers are familiar with. They behave like REST errors: non-200 HTTP status means something went wrong at the transport level.

1.2 Execution Errors — The 200 Trap

Execution errors occur inside GraphQL resolvers. The HTTP request succeeded (200), but one or more fields failed to resolve:
json
{
  "data": {
    "user": {
      "id": "1",
      "name": "Alice",
      "orders": null
    }
  },
  "errors": [
    {
      "message": "Not authenticated",
      "locations": [{ "line": 5, "column": 5 }],
      "path": ["user", "orders"],
      "extensions": {
        "code": "UNAUTHENTICATED"
      }
    }
  ]
}
data and errors coexist. data.user.name is correct. data.user.orders is null because the resolver failed. HTTP 200 doesn't tell you any of this — only inspecting the errors array does.
Mental Model Check
The application/graphql-response+json Content-Type (IETF-standardized) formalizes this: a response with both data and errors is a partial success. The HTTP status code reports whether the request was received and processed — not whether all resolvers succeeded.

2. The extensions Field: Machine-Readable Error Codes

The message field in a GraphQL error is for humans. It can change, it can be localized, it can include dynamic content. Never build error handling logic that pattern-matches on message strings.
The extensions field is for machines:
json
{
  "errors": [
    {
      "message": "You must be authenticated to access this resource",
      "extensions": {
        "code": "UNAUTHENTICATED",
        "field": "orders"
      }
    }
  ]
}
Handle errors by extensions.code:
typescript
import { onError } from '@apollo/client/link/error'

// In the ErrorLink — handle globally
const errorLink = onError(({ graphQLErrors }) => {
  if (!graphQLErrors) return

  for (const error of graphQLErrors) {
    switch (error.extensions?.code) {
      case 'UNAUTHENTICATED':
        // Token expired globally — refresh and retry (covered in Part 3)
        break

      case 'FORBIDDEN':
        // User lacks permission — redirect, don't retry
        router.push('/access-denied')
        break

      case 'NOT_FOUND':
        // Entity doesn't exist — let the component render a 404 state
        break
    }
  }
})
Pro Tip & Optimization
The GraphQL Error Handling RFC (2025) establishes extensions.code as the de facto standard across Apollo Server, GraphQL Yoga, and Mercurius. All three emit it by default. Standardize your error handling on code, never on message.

3. Apollo Error Policies

Apollo Client's errorPolicy option controls what happens when the response contains both data and errors:
typescript
// errorPolicy: 'none' (DEFAULT)
// Throws on the first execution error. Partial data is DISCARDED.
const { data, error } = useQuery(GET_USER, { errorPolicy: 'none' })
// If orders resolver fails: data is null, error is set. Orders UI can't render at all.

// errorPolicy: 'all'
// Returns both data and errors. Partial data is AVAILABLE.
const { data, error } = useQuery(GET_USER, { errorPolicy: 'all' })
// If orders resolver fails: data.user.name is present, data.user.orders is null, error has details.

// errorPolicy: 'ignore'
// Returns partial data. Errors are SILENTLY DISCARDED.
// Almost never correct — you lose the ability to show error states.
For production applications — especially those consuming a federated supergraph where individual subgraphs can fail independently — errorPolicy: 'all' is almost always the correct policy:
typescript
// ✅ Set globally in ApolloClient defaultOptions
const client = new ApolloClient({
  link: from([authLink, retryLink, errorLink, httpLink]),
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      errorPolicy: 'all',
    },
    query: {
      errorPolicy: 'all',
    },
  },
})
Performance / Safety Warning
The default errorPolicy: 'none' is safe for simple apps but wrong for federated graphs. A single subgraph outage with 'none' renders the entire page into an error state because all partial data is discarded. With 'all', the healthy subgraph data renders and only the failing zone shows an error.

4. Handling Partial Data in Components

With errorPolicy: 'all', components receive both data and errors and must handle the null fields explicitly:
typescript
function UserDashboard({ userId }: { userId: string }) {
  const { data, error } = useQuery(GET_USER_DASHBOARD, {
    variables: { id: userId },
    errorPolicy: 'all',
  })

  // ✅ Find the specific error for the orders field
  const ordersError = error?.graphQLErrors.find(
    e => e.path?.includes('orders')
  )

  return (
    <div>
      {/* Critical data — always present if user resolver succeeded */}
      <h1>{data?.user?.name ?? 'Loading...'}</h1>

      {/* Orders zone — independently fallible */}
      {ordersError ? (
        <OrdersErrorState code={ordersError.extensions?.code as string} />
      ) : (
        <OrderList orders={data?.user?.orders} />
      )}
    </div>
  )
}
HTTP-status error handler (broken: 200 passes through, null data silently renders as empty) vs GraphQL-aware handler (correct: inspects errors array, degrades per data zone)
HTTP-status error handler (broken: 200 passes through, null data silently renders as empty) vs GraphQL-aware handler (correct: inspects errors array, degrade…

5. Fragment-Level React Error Boundaries

A single Error Boundary at the page level is the wrong architecture for a GraphQL UI. When any field fails, the entire page enters an error state — even the fields that resolved correctly.
Fragment-level Error Boundaries isolate each data zone:
typescript
import { ErrorBoundary } from 'react-error-boundary'
import { useSuspenseQuery } from '@apollo/client'

function UserDashboard({ userId }: { userId: string }) {
  return (
    <div>
      {/* Profile header — isolated zone */}
      <ErrorBoundary fallback={<ProfileError />}>
        <Suspense fallback={<ProfileSkeleton />}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>

      {/* Orders section — independently isolated */}
      <ErrorBoundary fallback={<OrdersUnavailable />}>
        <Suspense fallback={<OrdersSkeleton />}>
          <OrderList userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  )
}
useSuspenseQuery integrates execution errors with Error Boundaries via the throwOnError option (Apollo Client 3.11+):
typescript
function OrderList({ userId }: { userId: string }) {
  // ✅ throwOnError: true causes execution errors to propagate to the nearest ErrorBoundary
  const { data } = useSuspenseQuery(GET_ORDERS, {
    variables: { userId },
    errorPolicy: 'all',
    throwOnError: (errors) =>
      errors.some(e => e.extensions?.code !== 'NOT_FOUND'),
      // NOT_FOUND: render empty state in component
      // Everything else: propagate to ErrorBoundary
  })

  if (!data?.user?.orders?.length) return <EmptyOrdersState />
  return <OrderListView orders={data.user.orders} />
}

6. Mutation Error Rollback

Mutation errors with optimisticResponse require special handling. When a mutation with an optimistic cache write fails, Apollo's behavior depends on errorPolicy:
  • With errorPolicy: 'none' (default): Apollo rolls back the optimistic write automatically.
  • With errorPolicy: 'all': Apollo does not roll back automatically. The optimistic value remains in the cache.
typescript
const [updateUserName] = useMutation(UPDATE_USER_NAME, {
  optimisticResponse: {
    updateUser: { __typename: 'User', id: userId, name: newName },
  },
  errorPolicy: 'all',

  onError: (error, clientOptions) => {
    // ✅ Manual rollback required with errorPolicy: 'all'
    client.cache.modify({
      id: client.cache.identify({ __typename: 'User', id: userId }),
      fields: {
        name: () => previousName, // Restore pre-mutation value
      },
    })

    // Show error to user
    const code = error.graphQLErrors[0]?.extensions?.code
    if (code === 'FORBIDDEN') toast.error('You do not have permission to rename this user.')
    if (code === 'VALIDATION_ERROR') toast.error('Name must be between 2 and 50 characters.')
  },
})
Failed mutation with errorPolicy 'all' — optimistic write is NOT automatically rolled back, onError callback fires, manual cache.modify required to restore previous state
Failed mutation with errorPolicy 'all' — optimistic write is NOT automatically rolled back, onError callback fires, manual cache.modify required to restore p…
Crucial Requirement
The onError callback receives the previous variable values in clientOptions. Store previousName in a useRef or closure before the mutation call — the onError callback needs the pre-mutation value to perform the rollback correctly.

Summary

ConceptRule
The 200 trapHTTP 200 with an errors array is a partial failure, not a success — every component that renders GraphQL data must null-check every field, not just check for HTTP error status.
Error policyApollo's default errorPolicy: 'none' throws on any execution error and discards partial data — for production apps with federation or complex resolvers, 'all' is almost always the correct policy.
Machine-readable codesThe extensions field in each error object is the machine-readable error code surface — handle UNAUTHENTICATED, FORBIDDEN, and NOT_FOUND by code, not by string-matching the message.
Error BoundariesFragment-level Error Boundaries are the correct architecture for federated UIs — each independently-owned data slice should have an independent failure render state.
Optimistic rollbackWhen optimisticResponse is used with errorPolicy: 'all', Apollo does not automatically roll back the optimistic update on partial failure — the rollback must be triggered manually from the onError callback.

What's Next

In Part 7, we look at performance — starting with the decision most engineers make after the fact: whether 47KB for @apollo/client was justified in the first place. Then we cover Automatic Persisted Queries, @cacheControl TTLs, and Persisted Operations — the three-layer optimization stack that takes GraphQL from "POST queries to a single endpoint" to "CDN-cached GET requests with zero server compute on repeat visits."
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Error Handling#Apollo Client#React#Error Boundaries#Partial Data#TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.