Fix: GraphQL Error Handling Not Working — Errors Not Returned or Always 200 OK
Quick Answer
How to fix GraphQL error handling — error extensions, partial data with errors, Apollo formatError, custom error classes, client-side error detection, and network vs GraphQL errors.
The Problem
GraphQL always returns HTTP 200 even when an error occurs:
HTTP/1.1 200 OK
{
"data": null,
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"]
}
]
}Or errors include stack traces in production:
{
"errors": [
{
"message": "Cannot read properties of undefined (reading 'id')",
"extensions": {
"stacktrace": [
"TypeError: Cannot read properties of undefined",
" at UserResolver.getUser (/app/resolvers/user.ts:42:18)",
" at ..."
]
}
}
]
}Or the client receives a 200 response but data is null with no errors array — the error is silently lost.
Or field-level errors (partial failures) aren’t distinguishable from full query failures.
Why This Happens
GraphQL has a unique error model that differs from REST APIs:
- HTTP 200 is by design — GraphQL specs allow partial success. If some fields resolve successfully and others fail, the response is HTTP 200 with both
data(partial) anderrors. HTTP 4xx/5xx are reserved for transport errors. - Unhandled resolver exceptions become GraphQL errors — any uncaught error in a resolver is caught by the GraphQL engine and added to the
errorsarray. The field value becomesnull. - Stack traces exposed in development — most GraphQL servers (Apollo, Yoga) include
stacktraceinextensionsduring development. Without properNODE_ENVconfiguration, this leaks to production. - Silent null fields — if a resolver returns
null(not throws), there’s no error in the response. The client sees null data without knowing why. - Error masking — Apollo Server 4 masks unexpected errors by default. Non-
ApolloError/ non-GraphQLErrorsubclasses become “Internal server error” — intentional, but confusing if you expect the original message.
Fix 1: Create Typed GraphQL Errors
Use specific error classes that communicate the error type and control what’s exposed to clients:
// errors.ts — custom error classes
import { GraphQLError } from 'graphql';
// Base class for expected errors (user-facing messages safe to show)
export class AppError extends GraphQLError {
constructor(message: string, extensions?: Record<string, unknown>) {
super(message, {
extensions: {
...extensions,
// code helps clients distinguish error types
},
});
}
}
export class NotFoundError extends AppError {
constructor(resource: string, id?: string) {
super(`${resource}${id ? ` (${id})` : ''} not found`, {
code: 'NOT_FOUND',
http: { status: 404 },
});
}
}
export class ValidationError extends AppError {
constructor(message: string, fields?: Record<string, string>) {
super(message, {
code: 'VALIDATION_ERROR',
fields,
http: { status: 400 },
});
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Not authenticated') {
super(message, {
code: 'UNAUTHENTICATED',
http: { status: 401 },
});
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Permission denied') {
super(message, {
code: 'FORBIDDEN',
http: { status: 403 },
});
}
}Use in resolvers:
// resolvers/user.ts
import { NotFoundError, ValidationError, AuthenticationError } from '../errors';
const resolvers = {
Query: {
user: async (_, { id }, { currentUser }) => {
if (!currentUser) throw new AuthenticationError();
const user = await UserService.findById(id);
if (!user) throw new NotFoundError('User', id);
return user;
},
},
Mutation: {
createUser: async (_, { input }) => {
if (!input.email.includes('@')) {
throw new ValidationError('Invalid email format', {
email: 'Must be a valid email address',
});
}
const existing = await UserService.findByEmail(input.email);
if (existing) {
throw new ValidationError('Email already registered', {
email: 'This email is already in use',
});
}
return UserService.create(input);
},
},
};Fix 2: Configure formatError for Production
Strip internal details from production error responses:
// Apollo Server 4
import { ApolloServer } from '@apollo/server';
import { GraphQLError } from 'graphql';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// In production — hide unexpected errors
if (process.env.NODE_ENV === 'production') {
// Check if it's an expected, safe error
const isSafeError =
error instanceof GraphQLError &&
error.extensions?.code !== undefined;
if (!isSafeError) {
// Log the real error server-side
console.error('Unexpected GraphQL error:', error);
// Return a generic message to the client
return {
message: 'An unexpected error occurred',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
}
// Remove stack trace from extensions in all environments
const { stacktrace, ...safeExtensions } = formattedError.extensions ?? {};
return {
...formattedError,
extensions: safeExtensions,
};
},
});Apollo Server 4 error masking:
// Apollo Server 4 masks unexpected errors by default
// Error masking behavior:
// - GraphQLError subclasses → message shown as-is
// - Unexpected errors → replaced with "Internal server error"
// To see original messages in development:
const server = new ApolloServer({
typeDefs,
resolvers,
includeStacktraceInErrorResponses: process.env.NODE_ENV !== 'production',
});Fix 3: Handle Partial Errors on the Client
GraphQL responses can have BOTH data and errors. Check both:
// Apollo Client — check for errors even with 200 response
const { data, errors, loading } = useQuery(GET_USER, {
variables: { id: userId },
errorPolicy: 'all', // Return partial data + errors (default is 'none')
});
// errorPolicy options:
// 'none' (default) — if any error, data is undefined
// 'ignore' — ignore errors, return whatever data resolved
// 'all' — return partial data + errors array
if (errors?.length) {
errors.forEach(err => {
console.error('GraphQL error:', err.message, err.extensions?.code);
});
}
if (data?.user) {
renderUser(data.user);
}Distinguish network errors from GraphQL errors:
// Apollo Client
import { useQuery } from '@apollo/client';
function UserProfile({ userId }) {
const { data, loading, error } = useQuery(GET_USER, {
variables: { id: userId },
});
if (loading) return <Spinner />;
// error.networkError — HTTP/transport error (500, CORS, network down)
// error.graphQLErrors — GraphQL execution errors in the response
if (error) {
if (error.networkError) {
return <ErrorPage message="Network error — check your connection" />;
}
const errorCode = error.graphQLErrors[0]?.extensions?.code;
switch (errorCode) {
case 'UNAUTHENTICATED':
return <Redirect to="/login" />;
case 'NOT_FOUND':
return <NotFound message="User not found" />;
case 'FORBIDDEN':
return <Forbidden />;
default:
return <ErrorPage message={error.message} />;
}
}
return <User user={data.user} />;
}With fetch directly (no Apollo):
async function graphqlRequest(query, variables) {
const response = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
// GraphQL always returns 200 for execution errors
// Non-200 means transport/server error
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const result = await response.json();
// Check for GraphQL-level errors even on 200
if (result.errors?.length) {
const error = result.errors[0];
throw new GraphQLError(error.message, error.extensions?.code);
}
return result.data;
}Fix 4: Handle Errors in Subscriptions
GraphQL subscription error handling is different from queries and mutations:
// Server — error in subscription resolver
const resolvers = {
Subscription: {
messageAdded: {
subscribe: async function* (_, __, { currentUser }) {
if (!currentUser) throw new AuthenticationError();
try {
for await (const message of messageStream()) {
yield { messageAdded: message };
}
} catch (err) {
// Subscription errors end the subscription stream
throw new GraphQLError('Subscription stream failed', {
extensions: { code: 'SUBSCRIPTION_ERROR' },
});
}
},
},
},
};
// Client — Apollo Client subscription error handling
const { data, error } = useSubscription(MESSAGE_ADDED_SUBSCRIPTION, {
onError: (error) => {
console.error('Subscription error:', error);
// error.networkError or error.graphQLErrors
},
});Fix 5: Log Errors Server-Side
Ensure server-side errors are captured for debugging while hiding details from clients:
// Apollo Server with error logging
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, originalError) => {
// Log unexpected errors
if (
!(originalError instanceof GraphQLError) ||
originalError.extensions?.code === 'INTERNAL_SERVER_ERROR'
) {
// Log to your error tracking service
logger.error({
message: 'Unexpected GraphQL error',
error: originalError,
graphqlError: formattedError,
});
// Report to Sentry, Datadog, etc.
Sentry.captureException(originalError);
}
// Return sanitized error to client
return sanitizeError(formattedError, originalError);
},
plugins: [
{
async requestDidStart() {
return {
async didEncounterErrors({ errors, operation, variables }) {
// Log all errors with operation context
errors.forEach(error => {
logger.warn({
operation: operation?.name?.value,
error: error.message,
code: error.extensions?.code,
path: error.path,
});
});
},
};
},
},
],
});Fix 6: Return Errors as Data (Result Pattern)
For mutations, returning errors as part of the response type gives clients type-safe error handling:
# Schema — result union pattern
type CreateUserSuccess {
user: User!
}
type CreateUserError {
code: String!
message: String!
fields: [FieldError!]
}
type FieldError {
field: String!
message: String!
}
union CreateUserResult = CreateUserSuccess | CreateUserError
type Mutation {
createUser(input: CreateUserInput!): CreateUserResult!
}// Resolver
const resolvers = {
Mutation: {
createUser: async (_, { input }) => {
const validation = validateUser(input);
if (!validation.valid) {
return {
__typename: 'CreateUserError',
code: 'VALIDATION_ERROR',
message: 'Validation failed',
fields: validation.errors,
};
}
try {
const user = await UserService.create(input);
return { __typename: 'CreateUserSuccess', user };
} catch (err) {
return {
__typename: 'CreateUserError',
code: 'SERVER_ERROR',
message: 'Failed to create user',
fields: [],
};
}
},
},
CreateUserResult: {
__resolveType: (obj) => obj.__typename,
},
};// Apollo Client — type-safe handling
const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
... on CreateUserSuccess {
user { id name email }
}
... on CreateUserError {
code
message
fields { field message }
}
}
}
`;
const [createUser] = useMutation(CREATE_USER);
const result = await createUser({ variables: { input } });
const { createUser: response } = result.data;
if (response.__typename === 'CreateUserSuccess') {
router.push(`/users/${response.user.id}`);
} else {
setErrors(response.fields);
}Still Not Working?
errorPolicy: 'all' not returning partial data — partial data is only returned if the schema allows nullable fields. If a required field (field: Type!) fails, the null propagates upward until it reaches a nullable parent. Make strategic fields nullable if partial failure is acceptable.
Error boundaries in React with Apollo — Apollo’s default errorPolicy: 'none' throws errors as exceptions. Use an ErrorBoundary component to catch them:
<ApolloProvider client={client}>
<ErrorBoundary fallback={<ErrorPage />}>
<UserProfile />
</ErrorBoundary>
</ApolloProvider>CORS errors look like GraphQL errors — a CORS preflight failure returns a network error, not a GraphQL error. The client gets no response body. Check error.networkError.statusCode and error.networkError.result.
For related API issues, see Fix: GraphQL N+1 Problem and Fix: GraphQL Subscription Not Updating.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GraphQL N+1 Query Problem — DataLoader and Batching
How to fix the GraphQL N+1 query problem — understanding why it happens, implementing DataLoader for batching, using query complexity limits, and selecting efficient resolver patterns.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: GraphQL Yoga Not Working — Schema Errors, Resolvers Not Executing, or Subscriptions Failing
How to fix GraphQL Yoga issues — schema definition, resolver patterns, context and authentication, file uploads, subscriptions with SSE, error handling, and Next.js integration.
Fix: Pothos Not Working — Types Not Resolving, Plugin Errors, or Prisma Integration Failing
How to fix Pothos GraphQL schema builder issues — type-safe schema definition, object and input types, Prisma plugin, relay connections, auth scope plugin, and schema printing.