> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# GraphQL Returns 200 With Your Error Inside It
- URL: https://debugly.dev/graphql-errors-200-debugging/
- Published: 2026-04-07T14:00:00.000Z
- Updated: 2026-10-17T05:58:48.000Z
- Description: Every response is a success as far as HTTP is concerned. That one design decision breaks monitoring, retries, and error handling unless you plan for it.
- Author: Rohit Bhadani
- Tags: Networking, Error Autopsy, Debugging, JavaScript

## The short answer

GraphQL returns **HTTP 200 for almost everything**, including resolver errors, validation failures, and partial results.

I have hit this enough times that I now check for it before I check for anything clever.

```json
{
  "data": { "user": null },
  "errors": [{
    "message": "Database connection failed",
    "path": ["user"],
    "extensions": { "code": "INTERNAL_SERVER_ERROR" }
  }]
}

```

Status 200\. Your monitoring says the endpoint is healthy. Your client's `res.ok` is true. Your retry logic never fires.

Always check `errors` before using `data`:

```javascript
const { data, errors } = await res.json();
if (errors?.length) {
  throw new GraphQLError(errors[0].message, { errors, path: errors[0].path });
}

```

## Why this design exists

A GraphQL query can request several things at once. If one resolver fails and three succeed, what status code represents that?

The specification's answer is to return what succeeded in `data`, what failed in `errors`, and let the client decide. That is coherent, and it means HTTP status stops being a health signal.

The consequences are what this post is about.

## The four consequences

### Monitoring is blind

Your dashboard shows a 100 percent success rate while every request contains an error.

You need to inspect the body:

```javascript
app.use("/graphql", (req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    if (body?.errors?.length) {
      metrics.increment("graphql.errors", {
        operation: req.body?.operationName ?? "anonymous",
        code: body.errors[0]?.extensions?.code ?? "UNKNOWN",
      });
      logger.error({
        event: "graphql.error",
        operation: req.body?.operationName,
        errors: body.errors.map(e => ({ message: e.message, path: e.path })),
      }, "graphql errors in response");
    }
    return originalJson(body);
  };
  next();
});

```

Most GraphQL servers have a plugin hook for this. Apollo Server has `didEncounterErrors`. Use it, because without it your error rate is permanently zero.

### Retries do not fire

Client libraries retry on 5xx. A GraphQL 200 with an internal server error inside is not a 5xx, so nothing retries.

Handle it at the link level:

```javascript
import { onError } from "@apollo/client/link/error";

const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors?.some(e => e.extensions?.code === "INTERNAL_SERVER_ERROR")) {
    return forward(operation);          // retry once
  }
  graphQLErrors?.forEach(e =>
    logger.error({ message: e.message, path: e.path, op: operation.operationName })
  );
});

```

Be careful about retrying mutations. A mutation that timed out may have succeeded, and retrying it duplicates the effect unless it is idempotent.

### Partial data is easy to mishandle

```json
{
  "data": {
    "order": { "id": "123", "total": 4999, "customer": null }
  },
  "errors": [{ "path": ["order", "customer"], "message": "Not authorised" }]
}

```

The order loaded. The customer did not. If your UI assumes `customer` is present, it crashes on a response that HTTP considers successful.

This is where GraphQL's nullability rules matter. A non-null field that errors **propagates the null upward** until it reaches a nullable field, which can null out an entire subtree from one failed leaf.

That produces a genuinely confusing symptom: a whole section of the response is missing because one deeply nested non-null field failed. The `path` in the error tells you the actual origin, and it is worth reading rather than reacting to the null.

Design nullability deliberately. Marking everything non-null feels safer and means a single failure can empty the response.

### Errors leak internals

The default behaviour in many servers is to include the stack trace:

```json
{
  "errors": [{
    "message": "connect ECONNREFUSED 10.0.3.44:5432",
    "extensions": {
      "exception": { "stacktrace": ["Error: connect ECONNREFUSED..."] }
    }
  }]
}

```

That tells an attacker your internal IP, your database port, and your framework. Mask in production:

```javascript
const server = new ApolloServer({
  schema,
  includeStacktraceInErrorResponses: false,
  formatError: (formatted, error) => {
    const id = randomUUID();
    logger.error({ errorId: id, err: error }, "graphql resolver error");

    if (formatted.extensions?.code === "BAD_USER_INPUT") {
      return formatted;                   // safe to expose
    }
    return {
      message: "Internal server error",
      extensions: { code: "INTERNAL_SERVER_ERROR", errorId: id },
    };
  },
});

```

The `errorId` is the important part. The client gets something safe and a reference, you get everything in your logs, and support can join them. Same pattern as [not leaking internals in HTTP error responses](https://debugly.dev/error-messages-are-a-user-interface/).

## Debugging a slow query

The other thing GraphQL makes harder: one HTTP request can be hundreds of database queries.

**Enable tracing** to get per resolver timings. Apollo's inline trace plugin, or an OpenTelemetry instrumentation, gives you a breakdown showing which resolver consumed the time.

**Expect N+1 by default.** Each resolver runs independently, so a list of 50 orders each resolving a customer is 51 queries. This is the architectural default rather than a mistake, and DataLoader is the standard answer:

```javascript
const customerLoader = new DataLoader(async (ids) => {
  const rows = await db.customers.findMany({ where: { id: { in: [...ids] } } });
  const byId = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => byId.get(id) ?? null);
});

```

Create it **per request**, not globally, or you serve one user's cached data to another. And return results in input key order, which the final map does. [The general N+1 detection techniques apply here too](https://debugly.dev/finding-n-plus-one-queries/), and query count assertions are especially valuable because GraphQL makes the count so unpredictable.

**Log the operation name.** An anonymous query in your logs is unattributable. Require names:

```javascript
if (!operationName) throw new Error("operations must be named");

```

That is a small policy with a large payoff when you are trying to work out which client is causing load.

## Query complexity, which you need before you are attacked

A nested query can be exponentially expensive:

```graphql
query {
  users { orders { items { product { reviews { author { orders { ... } } } } } } }
}

```

Without limits, one request can consume your entire database capacity. This is a denial of service vector that is unique to GraphQL and frequently unaddressed.

```javascript
import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(8), createComplexityLimitRule(1000)],
});

```

For a public API, also consider persisted queries, where clients send a hash of a pre-registered query rather than arbitrary GraphQL. That eliminates the whole category and reduces payload size, at the cost of a build step.

## A production checklist

- `errors` inspected on every client response, before `data`
- Error rate metric derived from the body, not the status code
- Stack traces suppressed in production, with an error id for correlation
- Operation names required and logged
- DataLoader for every to-one relationship, created per request
- Depth and complexity limits
- Per resolver tracing enabled
- Introspection disabled in production for non public APIs
- Query count assertions in tests for your heaviest operations

---

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)