Back to Blog
Technology
January 19, 2026
10 min read
1,827 words

We Stopped Using GraphQL—REST Was Fine All Along

Our GraphQL adoption promised flexibility but delivered complexity. Schema management, N+1 queries, and client-side caching nightmares made us return to REST.

We Stopped Using GraphQL—REST Was Fine All Along

In 2020, we migrated our entire API infrastructure from REST to GraphQL. The promise was irresistible: clients get exactly the data they need, no over-fetching, no under-fetching, one endpoint to rule them all. Facebook built it, major companies adopted it, and we wanted to be on the cutting edge.

Three years later, we completed the migration back to REST. GraphQL hadn't solved our problems—it had replaced them with different, harder problems. This is the story of our GraphQL adoption, the pain points we didn't anticipate, and why REST's simplicity ultimately won.

The Promise That Seduced Us

Our REST API had grown organically over five years. Multiple mobile clients (iOS, Android) and web clients consumed hundreds of endpoints. Each client needed slightly different data shapes. The mobile team constantly complained about over-fetching—downloading entire user objects when they only needed names. The web team created custom endpoints for specific pages.

GraphQL seemed like the perfect solution. One schema, infinite query flexibility. Mobile clients could request minimal fields. Web clients could request everything in one query. No more endpoint proliferation. No more version negotiation between teams.

The initial migration went smoothly. We wrapped our existing services behind GraphQL resolvers. The schema was elegant—types, queries, mutations, all neatly defined. Frontend developers loved it. They could explore the schema, write queries, and get exactly what they needed. Developer experience was genuinely better.

Then we scaled. And everything became complicated.

The N+1 Query Apocalypse

The most famous GraphQL problem hit us hard. A query like "get users with their orders with their products" executed beautifully in the GraphQL playground. Behind the scenes, it generated hundreds of database queries.

For each user, fetch orders. For each order, fetch products. The database was drowning in round trips. What looked like one GraphQL query became 1 + N + N*M database queries. Our database connection pool was exhausted. Response times ballooned.

DataLoader was the prescribed solution. We implemented it, batching requests within a single GraphQL operation. It helped, but introduced its own complexity. Every resolver needed loader awareness. Caching within requests worked, but caching across requests required careful consideration. The mental model shifted from "simple resolver functions" to "loader-aware asynchronous batching."

The DataLoader pattern also created subtle bugs. Loaders needed to be instantiated per-request to avoid data leaking between users. We had an incident where user A saw user B's private data because of a shared loader cache. Debugging took days.

REST didn't have this problem. We designed endpoints that fetched what they needed efficiently. A /users-with-orders endpoint knew exactly what it was doing and could be optimized specifically. GraphQL's flexibility meant we couldn't optimize without knowing what clients would query.

Schema Evolution Nightmares

GraphQL evangelists tout the schema as a contract between frontend and backend. In theory, this enables independent evolution. In practice, schema changes became coordination nightmares.

Adding fields was easy—backwards compatible. Removing or renaming fields was nearly impossible. Once a field was in the schema, some client somewhere was using it. We couldn't remove deprecated fields without breaking unknown consumers.

We implemented @deprecated directives, hoping clients would migrate. They didn't. Deprecated fields accumulated. Our schema became a graveyard of old decisions we couldn't undo. The "clean" schema that started the migration was now cluttered with legacy.

Refactoring types was even worse. Changing the shape of a common type like "User" rippled across dozens of queries. Frontend teams needed to update their queries. Coordinating these changes across mobile and web teams added weeks to what should have been simple refactors.

REST had versioning. /v1/users and /v2/users could coexist. Old clients used old endpoints. New clients used new endpoints. Eventually, we could retire old versions. GraphQL had one schema, one truth, and no clean way to evolve it.

Client-Side Caching Complexity

REST caching is simple. Each URL is a cache key. Cache-Control headers tell clients what to cache and for how long. CDNs understand REST. Browsers understand REST. The infrastructure just works.

GraphQL caching is hard. One endpoint means one URL. POST requests aren't cached by CDNs. We needed specialized GraphQL caching infrastructure—persisted queries, cache normalization, client-side caching libraries.

We adopted Apollo Client on the frontend for its caching capabilities. It worked, but required understanding normalized caching, cache policies, and manual cache updates after mutations. Simple CRUD operations became exercises in cache manipulation.

The caching bugs were subtle and maddening. A user would update their profile, but the old data would appear elsewhere in the app because the cache wasn't properly invalidated. We spent countless hours debugging cache states and writing cache update functions for mutations.

Server-side caching was equally complex. We couldn't cache whole responses because queries were dynamic. We implemented field-level caching with Redis, but configuration was elaborate. Different fields had different TTLs, different cache keys, different invalidation strategies. What was automatic with REST required constant attention with GraphQL.

Security Surface Area Explosion

REST endpoints are easy to secure. Each endpoint has defined inputs and outputs. You can implement rate limiting, authentication, and authorization at the endpoint level. The attack surface is enumerable.

GraphQL's flexibility is a security nightmare. Clients can construct arbitrary queries. Deeply nested queries can overwhelm servers. Introspection exposes your entire schema to attackers. The attack surface is infinite.

We implemented query complexity analysis, rejecting queries that exceeded cost thresholds. But calculating complexity was guesswork—we didn't know the actual cost until execution. Some queries that passed limits still overloaded the database.

Depth limiting helped but broke legitimate use cases. Mobile clients needed nested data for offline functionality. Setting the right depth limit was a constant negotiation between security and usability.

Field-level authorization was complex. In REST, you could check permissions once at the endpoint. In GraphQL, each resolver needed permission checks. We built an authorization layer, but it added latency and complexity. Missing an authorization check in one resolver meant data leakage.

We eventually disabled introspection in production, making the API harder for attackers to explore but also harder for legitimate developers. The security/developer-experience tradeoff was constant.

Tooling and Debugging Friction

REST debugging is straightforward. You can curl an endpoint. You can see the request in browser dev tools. You can replay requests. The HTTP semantics are well-understood.

GraphQL debugging required specialized tools. Standard HTTP debugging showed POST requests with query strings that weren't human-readable without parsing. We needed GraphQL-specific tools for development, monitoring, and debugging.

Tracing queries through our distributed system was harder. A single GraphQL query might touch a dozen services. Correlating logs across those services required elaborate tracing infrastructure. With REST, the endpoint name in logs told you what was happening. With GraphQL, you needed to parse query strings to understand operations.

Error handling was confusing. GraphQL always returns 200 OK with errors in the response body. Standard HTTP error handling didn't work. Monitoring tools needed GraphQL-aware parsing. Our alerting couldn't distinguish between "everything is fine" and "everything is broken" based on status codes alone.

Performance monitoring required understanding which fields were slow. GraphQL layer showed one request; the database layer showed hundreds of queries. Connecting these layers to understand "this GraphQL query is slow because this field is slow" required custom instrumentation.

Team Cognitive Load

Our engineers knew REST. HTTP verbs, status codes, URL structures—these were familiar concepts. Onboarding new developers to REST APIs was trivial.

GraphQL required a learning curve. Schema definition language, resolvers, DataLoaders, fragments, directives, subscriptions—the conceptual overhead was substantial. New engineers needed weeks to become productive with our GraphQL infrastructure.

The schema-first development workflow changed how teams worked. Frontend developers explored the schema before writing code. This was supposedly better, but it meant frontend couldn't start until the schema was defined. Schema-first planning added time before any code was written.

Resolvers confused engineers who thought in terms of endpoints. A resolver for User.orders might be called in contexts the resolver author never anticipated. The difference between root resolvers and field resolvers wasn't obvious. Engineers wrote resolvers that worked in isolation but failed when composed.

The mental model shift was real. Some engineers thrived with GraphQL's approach. Many found it unnecessarily complex for our use cases. The team split was frustrating.

The Migration Back

After three years with GraphQL, we conducted an honest assessment. The promises hadn't materialized. Over-fetching was replaced by N+1 complexity. Endpoint proliferation was replaced by schema evolution nightmares. Developer experience gains were offset by debugging frustration.

We decided to migrate back to REST, but smarter REST. We brought the lessons learned:

Sparse fieldsets: REST endpoints accepting ?fields= parameters to request specific fields. Clients get flexibility without schema complexity.

Compound documents: Following JSON:API patterns, including related resources in responses. One request for users-with-orders, optimized specifically.

Versioned endpoints: Clear URL versioning (/v1/, /v2/) enabling clean evolution without breaking clients.

Standard caching: HTTP caching headers, CDN compatibility, browser caching—all working out of the box.

The migration took six months. We built REST endpoints alongside GraphQL, migrated clients one by one, and eventually decommissioned GraphQL. The relief was palpable.

When GraphQL Makes Sense

Our GraphQL experience was negative, but GraphQL isn't universally wrong. It makes sense in specific contexts:

Truly diverse clients: If you have dozens of clients with genuinely different data needs, GraphQL's flexibility can reduce backend proliferation. We had three clients—not diverse enough to justify the complexity.

Graph-shaped data: If your domain is genuinely graph-like (social networks, recommendation systems), GraphQL's query model matches the data model. Our domain was relational tables, not graphs.

Dedicated GraphQL teams: Large organizations with teams that own GraphQL infrastructure—schema registry, federation, performance monitoring—can make it work. We didn't have GraphQL expertise depth.

Public APIs: If external developers consume your API and you can't coordinate with them, GraphQL's flexibility lets them query what they need. Our API was internal. We could coordinate.

For most applications—especially internal APIs with a few clients—REST is simpler, more debuggable, and better supported by infrastructure.

The Broader Lesson

We adopted GraphQL because it was new, Facebook built it, and conference talks made it seem inevitable. We didn't honestly assess whether our problems matched GraphQL's solutions.

Our real problems were poor API design, inconsistent conventions, and lack of documentation. GraphQL didn't solve those—it papered over them with a query language. When we migrated back to REST, we addressed the real problems: consistent naming, comprehensive documentation, thoughtful endpoint design.

Technology choices should be driven by specific problems, not by novelty or industry trends. GraphQL solves real problems for certain contexts. It wasn't our context. Recognizing that and reversing course was humbling but correct.

Conclusion

Our GraphQL experiment cost us three years of complexity for marginal benefits. The N+1 problems, schema evolution nightmares, caching complexity, and security challenges outweighed the developer experience improvements.

REST with modern patterns—sparse fieldsets, compound documents, standard caching—gives us the flexibility we actually need with the simplicity we'd forgotten to value.

If you're considering GraphQL, honestly assess whether your problems match its solutions. Do you have genuinely diverse clients? Graph-shaped data? Team expertise? If not, REST is probably fine. We learned the hard way that simpler technologies often win.

Tags:TechnologyTutorialGuide
X

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.