
We adopted GraphQL in 2022 because we were told it was the future. Facebook used it. Shopify used it. Every tech blog praised its elegance. Our frontend team was excited about declarative data fetching. Our backend team was cautious but outvoted.
Two years later, we ripped it out and went back to REST. The migration took three months. It was three of the most productive months our team ever had, because every week we removed complexity instead of adding it.
GraphQL is a brilliant solution to a problem most companies don't have. If you're Facebook serving a billion different client configurations, you need a flexible query language. If you're a 50-person company with two clients (web and mobile), you need simple, predictable API endpoints.
The N+1 Query Catastrophe
GraphQL's nested query structure creates N+1 problems by design. When a client queries users and their posts and the comments on those posts, the naive resolver implementation makes one query for users, one query per user for posts, and one query per post for comments.
For 50 users with 10 posts each, that's 1 + 50 + 500 = 551 database queries for a single API request.
The "solution" is DataLoader, a batching library that accumulates queries within a single tick of the event loop and executes them in bulk. DataLoader works, but it transforms a simple "fetch data from database" operation into a complex asynchronous batching system with its own caching semantics, error handling, and debugging challenges.
We spent weeks optimizing DataLoader configurations. We wrote custom batching functions for complex relationships. We built monitoring to detect unbatched queries that slipped through. This was infrastructure we didn't need with REST, where each endpoint executed a known, optimized SQL query.
With REST: One endpoint, one SQL query (maybe two with a JOIN), predictable performance. With GraphQL: arbitrary query shapes, dynamic resolver execution, and performance that varies wildly based on what the client asks for.
The Security Nightmare
GraphQL exposes your entire data graph to the client. Every type, every field, every relationship. The client can construct any query they want, including queries you never anticipated.
Query depth attacks: A malicious client can construct deeply nested queries that consume exponential server resources. User -> posts -> comments -> author -> posts -> comments -> author, recursively until the server runs out of memory or time.
We implemented query depth limiting. Then query complexity analysis. Then query cost estimation. Then persisted queries to whitelist only known query shapes. Each layer of protection added complexity to our server and still left edge cases.
Authorization becomes field-level: With REST, you authorize at the endpoint level. Can this user access /api/users/123? Yes or no. With GraphQL, you need to authorize every field of every type, because any field can be requested in any context. User.email might be accessible to the user themselves but not to other users. User.salary should only be visible to HR.
We built a field-level authorization system. It was 3,000 lines of code that had to stay in sync with our data model. Every time we added a field, we had to update authorization rules. Every time we forgot, we had a potential data leak.
With REST, adding a new field meant adding it to the serializer of the appropriate endpoint, which already had endpoint-level authorization. The security model was implicit and hard to get wrong.
Client-Side Complexity Explosion
GraphQL's promise is that the client declares what data it needs and the server delivers exactly that. This sounds elegant until you implement it.
Code generation: To get type safety on the client, you need to generate TypeScript types from your GraphQL schema. This requires a build step, a code generator (like GraphQL Code Generator), and a pipeline to keep generated types in sync with the schema.
Cache management: Apollo Client's normalized cache is powerful but complex. It stores entities by ID and reconstructs query results from cached fragments. When cache invalidation fails (and it does), the UI shows stale data. Debugging cache issues requires understanding Apollo's internal data structures.
We had bugs where updating a user's name would be reflected on one page but not another, because the second page's query was cached independently. The fix was manual cache invalidation or refetching, which eliminated the supposed benefit of the normalized cache.
Query colocation vs. reuse: The best practice is to colocate queries with components. But when two components need similar but not identical data, you either duplicate queries (violating DRY) or create shared fragments (adding abstraction). Neither is simple.
Our frontend codebase had 200+ GraphQL query files, each with its own fragment composition, variable types, and error handling. This was more complex than the REST alternative of calling fetch() with a URL.
Schema Duplication
Our data lived in three places: the database schema (Postgres), the GraphQL schema (SDL files), and the TypeScript types (generated from GraphQL). Every change to the data model required updating all three, keeping them in sync, and deploying in the right order.
With REST, we had database models and serializers. Two layers, not three. Changes were faster and less error-prone.
The GraphQL schema was supposed to be a "contract" between frontend and backend. In practice, it was a bottleneck. Frontend needed a new field? File a PR to update the schema, update the resolver, update the authorization rules, regenerate client types, and then start using the field. With REST, the backend just added the field to the serializer response.
What REST Actually Gives You
REST is boring. That's its strength.
- Predictable performance: Each endpoint has known query patterns. You can optimize, cache, and monitor each one independently.
- HTTP caching: REST responses can be cached at every layer: CDN, reverse proxy, browser. GraphQL uses POST requests for everything, bypassing HTTP caching entirely.
- Simple tooling: curl, Postman, browser DevTools. No special clients, no schema introspection, no query builders needed.
- Endpoint-level monitoring: Each endpoint has its own latency, error rate, and usage metrics. With GraphQL, all queries hit a single /graphql endpoint, making monitoring harder.
- Versioning: REST API versioning is well-understood. GraphQL's schema evolution model (deprecation and eventual removal) is more elegant in theory but harder to enforce in practice.
Over-fetching is usually solved with sparse fieldsets or view-specific endpoints. If your mobile app needs fewer fields than your web app, create a lightweight endpoint or use JSON:API sparse fieldsets. This is simpler than running a GraphQL server.
Conclusion
GraphQL is a powerful technology solving real problems at a specific scale. If you have dozens of client applications consuming the same API in radically different ways, GraphQL's flexibility is genuinely valuable.
If you have a web app and maybe a mobile app built by the same team, GraphQL is overhead. The flexibility isn't worth the complexity. REST gives you simplicity, predictability, and the ability to leverage decades of HTTP infrastructure.
Don't adopt technology because it's popular. Adopt it because it solves a problem you actually have. We had a REST problem — inconsistent endpoint design — that we tried to solve with GraphQL. The real fix was designing better REST endpoints.
Written by XQA Team
Our team of experts delivers insights on technology, business, and design. We are dedicated to helping you build better products and scale your business.