The debate we declared over
We ended the REST versus GraphQL argument in a single meeting. GraphQL would sit in front of the web and mobile apps, where clients wanted to ask for exactly the fields they needed. REST would stay where it already worked: service to service, the partner-facing API, the webhooks. Everyone nodded. The debate was resolved. We were going to use both, like grown-ups.
Six months later the biggest item on the board was a gateway nobody wanted to own, our CDN hit rate had quietly collapsed, and the mobile team was filing bugs the backend team could not reproduce because the two halves disagreed about what an error even was. The debate was not resolved. We had agreed to have it twice, forever.
"Use both" is two contracts, not a truce
"Use both" sounds like maturity. What it means in practice is that every engineer now holds two API models in their head and switches between them depending on which corner of the system they are in. There are two ways to describe a resource, two sets of pagination conventions, two doc sites, two client libraries, two ways a request can be malformed.
New hires learn both before they are productive. A change that touches the seam touches both. The cost is not in any single line of code. It is the constant tax of context-switching between two philosophies that disagree about where the smarts belong.
The caching you quietly gave up
REST gets HTTP caching for free, and most teams forget how much they were leaning on it. A GET has a URL, and a URL is a cache key. Your CDN, the browser, a reverse proxy, and an ETag all cooperate to keep load off the origin without anyone writing caching code.
GraphQL sends a POST with the query in the body. A POST is not cacheable by any of that machinery. The shared field that REST served from the edge a million times a day now hits your resolvers a million times a day. You can claw some of it back with persisted queries and a client cache, but you are rebuilding by hand what HTTP handed you in the protocol. I wrote a whole post on the Cache-Control header most people ignore. GraphQL ignores it for you.
Two error models in one client
REST signals failure with a status code. A 404 is missing, a 409 is a conflict, a 500 is your fault. GraphQL returns 200 OK with an errors array in the body, because at the transport level the query arrived fine. Each one is defensible. Living with both inside a single client is the problem.
The app now checks the HTTP status for the REST calls and parses a body-level errors array for the GraphQL calls, and a partial GraphQL response can be half data and half error at the same time. I have a separate post about the endpoint that always returns 200 and why it hides failures. GraphQL makes that the default and asks you to be fine with it.
Rate limiting stops being per-endpoint
With REST you can put a limiter in front of each route, because each route does roughly one bounded thing. GET /orders costs about the same every time someone calls it. You count requests and you are done.
One GraphQL endpoint accepts a query that asks for a single user, and the next query asks for every user, their orders, and each order's line items three levels deep. Same URL, wildly different cost. Counting requests protects nothing. You end up writing query-cost analysis, depth limits, and complexity budgets, which is a small rules engine living in front of your data, and it is now load-bearing.
The BFF that became a third backend
To stitch the two worlds together, someone stands up a backend-for-frontend. At first it just forwards calls. Then it shapes a payload so the mobile client gets something friendlier. A caching layer shows up. It starts owning a slice of authorization. A product rule lands there because it was the convenient place that afternoon.
A year on, the BFF is a third service with its own deploys, its own on-call, and business logic that lives in no design doc. It was supposed to be glue. It became a backend that happens to speak both protocols, and it is the scariest thing to change in the whole system.
N+1 moved, it did not leave
People adopt GraphQL partly to escape over-fetching. The trap is that the classic N+1 query problem does not vanish. It relocates into your resolvers. A query for 50 orders fans out into 50 separate lookups for each order's customer, one resolver call at a time, against the very REST service or database you were trying to be gentle with.
The fix is DataLoader-style batching, where you collect the keys from a tick of resolution and issue one batched call. It works. It is also a real piece of machinery you have to understand, configure, and debug. That is the price of the abstraction, not a bonus feature.
Where each actually earns its place
GraphQL earns its keep at a genuine aggregation boundary: a client that drives wildly different screens from one round trip, pulling from several services, where letting the client name its fields kills dozens of bespoke endpoints. That is a real problem, and GraphQL is a real answer to it.
REST earns its keep almost everywhere else. Resources with stable shapes. Anything that benefits from HTTP caching. Public APIs outsiders have to learn quickly, and the service-to-service calls inside your own walls where a typed contract beats a flexible one. Picking by fit instead of fashion is the whole game.
What I actually reach for
My default is REST, because the protocol does caching, status codes, and conditional requests for me and I do not have to rebuild any of it. I add GraphQL in exactly one place, when there is a real client-aggregation boundary that hurts without it, and I put one team in charge of that boundary so the seam has an owner.
What I do not do anymore is declare the debate over and walk both paths at once without counting the cost. Both is a valid choice. It is just not a free one, and the bill arrives at the seam, six months later, with no name on it.
Comments (0)