log in
consulting hosting industries the daily tools about contact

Why I Went Back to REST After Two Years on GraphQL

I bought into GraphQL hard. After two years and several production apps, I switched back to REST. Here's the honest post-mortem.

I was a GraphQL believer. I gave the conference talk in my head, I migrated a production codebase to it, I evangelized it to clients. Two years later I ripped it out of two projects and went back to REST. This is not a hot take — it's a post-mortem.

What GraphQL Promised Me

The pitch is genuinely compelling. One endpoint, typed schema, clients ask for exactly what they need, no over-fetching, no under-fetching. Frontend teams stop filing tickets asking you to add a field. You ship a schema, they explore it with GraphiQL, everyone goes home happy.

For a certain class of problem — a public API with many different consumers, each with wildly different data needs — that's a real solution to a real problem. I don't want to pretend otherwise.

But most of what I build isn't that. I build custom web apps for specific clients. A healthcare portal. A biotech LIMS front-end. A real estate tool consuming MLS data. In those contexts, "the frontend team" is one person, often me, and the data shapes I need are fairly predictable. GraphQL was solving a problem I didn't have.

Where It Started to Hurt

The N+1 problem doesn't go away — it hides

This one bit me hard on a project for a client with a moderate-complexity data model: companies, contacts, deals, activities, tags. Classic CRM territory.

With REST, I'd write a query that joins what it needs. One round trip, shaped to the endpoint. With GraphQL and a naive resolver setup in Lighthouse (the PHP GraphQL library I was using with Laravel), I got N+1 queries the moment anyone nested a relationship.

Lighthouse has a @with directive and batch loading via DataLoader patterns, but you have to be deliberate about every single relationship. Miss one and you've silently shipped a page that fires 87 queries for a 20-row list. You won't notice until production traffic hits it or you install Telescope and have a small heart attack.

// In your GraphQL schema — looks innocent
type Company {
    id: ID!
    name: String!
    contacts: [Contact!]! @hasMany   # N+1 waiting to happen without @with
    deals: [Deal!]! @hasMany          # same
}

// In your query
type Query {
    companies: [Company!]! @paginate  # need @with(relation: ["contacts", "deals"])
                                       # or a custom resolver with eager loading
}

With REST I write Company::with(['contacts', 'deals'])->paginate(25) and I'm done. The intent is explicit, visible, reviewable. I'm not relying on directive magic I might forget.

Authorization gets complicated fast

In a typical Laravel REST app, authorization lives in policies and middleware. Clean, testable, obvious. With GraphQL, you're authorizing at the field level, the type level, and the query level, and those concerns start to tangle.

I had a situation where a user could see a Contact record's name and email, but not their internal notes field — unless they had a certain role. Simple enough. In REST, that's a resource transformer that conditionally includes the field. In GraphQL with Lighthouse, I was reaching for @can directives, custom guards, and eventually a custom field middleware that felt like a workaround for a framework that wasn't quite designed for that granularity.

It works. It's just not clean, and clean matters when someone else has to maintain it.

Caching is a first-class casualty

HTTP caching is free with REST. GET requests cache at the CDN, at the browser, at Nginx. You set a Cache-Control header and walk away.

GraphQL runs over POST by default. Your CDN doesn't cache POST. You can work around this — persisted queries, GET-based queries for read operations, edge caching solutions — but every workaround is complexity you own. For a client that had a publicly-facing biotech data portal with heavy read traffic, this was not a theoretical concern. I ended up with a hand-rolled application cache layer that I would have gotten for free otherwise.

Error handling is a footgun

GraphQL always returns HTTP 200. Always. Errors come back in an errors array in the response body. This breaks every piece of HTTP-aware tooling you have: Sentry alerting, uptime monitors, log aggregation by status code. You have to specifically teach all of it to understand GraphQL errors.

More importantly, partial success is a real thing in GraphQL. You can get back data for part of a query and errors for the rest, and the HTTP layer has no idea anything went wrong. I've watched junior developers log the response, see data in it, and not notice the errors key at all.

{
  "data": {
    "company": {
      "name": "Acme Corp",
      "contacts": null
    }
  },
  "errors": [
    {
      "message": "Unauthorized",
      "locations": [{"line": 4, "column": 5}],
      "path": ["company", "contacts"]
    }
  ]
}

HTTP 200. Sentry saw nothing. The client called me two weeks later.

What I Actually Missed About REST

When I went back, I was surprised how much I appreciated the simplicity. An endpoint has a URL. The URL is a resource. You know what it does by looking at it. You test it with curl. You mock it with a JSON file. You document it with a markdown table if you want.

Laravel API resources are underrated. A clean resource class gives me explicit control over exactly what gets serialized, conditional fields, relationship loading — everything I was reaching for GraphQL directives to accomplish.

class ContactResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'email'      => $this->email,
            'notes'      => $this->when(
                                $request->user()->can('view-notes', $this->resource),
                                $this->notes
                            ),
            'deals'      => DealResource::collection(
                                $this->whenLoaded('deals')
                            ),
        ];
    }
}

That when() and whenLoaded() pattern handles 90% of what I was using GraphQL for. It's explicit, it's testable, it's been in Laravel for years.

When I'd Still Reach for GraphQL

I'm not done with it entirely. There are situations where it's the right tool:

  • A truly public API with heterogeneous consumers. If I'm building a platform API that mobile, web, and third-party developers will all hit differently, the self-documenting schema and query flexibility are genuine wins.
  • Rapid prototyping with a separate frontend team. If I hand a schema to a frontend developer and they can explore and build without waiting on me for every endpoint, that's real productivity.
  • When the client's existing stack is already GraphQL. I'm not migrating a running system for ideological reasons.

But for the kind of work I do most — a full-stack Laravel app, one team (often just me), predictable data access patterns, real caching requirements — REST is less to think about and less to go wrong.

When I Wouldn't Touch It

  • Internal tools. Nobody needs introspection queries in a CRUD app that three people use.
  • Any project with significant caching requirements and no CDN budget for edge logic.
  • Healthcare or biotech data apps where audit trails and explicit access control are requirements. I want authorization to be boring and obvious, not clever.
  • Small teams without the bandwidth to maintain schema evolution discipline. Deprecating fields in GraphQL is a process. REST versioning is just a new URL.

The Honest Takeaway

GraphQL is a real technology that solves real problems. I was just applying it to the wrong problems because it was new and interesting, which is a mistake I've made before with other things.

Two years in, the novelty had worn off and what I had was more schema maintenance, more N+1 vigilance, a broken caching story, and a more complicated authorization layer — in exchange for flexibility I wasn't actually using.

If the flexible query surface isn't buying you something specific that you can point to and measure, you're just paying the complexity tax with no return. REST has been boring for twenty years. That's a feature.

Need help shipping something like this? Get in touch.