Migrating from REST to GraphQL at Scale

Our agency's experience migrating a massive enterprise legacy API to a modern GraphQL federation — including the pitfalls to avoid.

By Arsalan Khalid, Lead Engineer. Published 2024-08-30. Engineering.

Last year, we migrated a fintech platform from a sprawling REST API (210 endpoints, 8 years of accumulated technical debt) to a GraphQL federation architecture. The migration took 14 weeks and involved 5 engineers. This is what we'd do differently, what worked, and the problems we didn't anticipate.

Why GraphQL at Scale?

The case for GraphQL isn't just about developer ergonomics (though those are real). At scale, the compounding benefits become significant:

  • Over-fetching elimination: mobile clients fetching only the fields they need, reducing bandwidth 35–60%
  • Single query waterfalls: replacing 4–6 sequential REST calls with one composed query
  • Type safety across the stack: schema-first development with generated TypeScript types for client and server
  • Schema federation: each team owns their subgraph, with the gateway composing a unified schema
  • Real-time subscriptions: built-in WebSocket support without separate infrastructure

The Federation Pattern

Don't build a monolithic GraphQL schema. Use federation (Apollo Federation v2 or GraphQL Mesh). Each bounded domain — users, payments, orders, inventory — owns its own subgraph with its own team and deployment cadence. The federation gateway composes them into a unified schema that clients query.

This pattern is critical for large teams. Without it, your GraphQL schema becomes the same monolith problem you had with REST, just with a different wire format.

Our Migration Strategy: The Strangler Fig

Don't rewrite everything at once. We used the Strangler Fig pattern: the GraphQL gateway sits in front of the existing REST API, with new resolvers calling existing endpoints. Over time, resolvers are re-implemented to hit the database directly, and the REST endpoints are decommissioned domain by domain.

  1. Week 1–2: Stand up the gateway, define the schema for the first domain (Users), implement resolvers as REST call wrappers
  2. Week 3–4: Migrate the frontend to consume the new GraphQL API for the User domain, remove REST calls
  3. Week 5–6: Re-implement User resolvers to hit the database directly (Prisma/Drizzle), decommission REST user endpoints
  4. Repeat for each domain — Payments (weeks 7–8), Orders (weeks 9–10), Inventory (weeks 11–12)
  5. Week 13–14: Cross-cutting concerns — N+1 query optimization with DataLoader, query depth limiting, rate limiting

The N+1 Problem: Your Biggest Enemy

Every GraphQL migration hits this wall. When resolving a list query like orders with nested user objects, a naive implementation will make one DB query per user — a catastrophic performance regression.

The solution is DataLoader (or equivalent batching libraries). DataLoader batches multiple resolution calls within a single tick of the Node.js event loop into a single database query. This is not optional — implement it from day one on every resolver that loads related entities.

Security: Don't Skip This

GraphQL's flexibility is also a security surface. Before going to production, implement:

  • Query depth limiting: reject queries nested more than 6–8 levels deep
  • Query complexity analysis: assign a cost score to each field, reject queries over a threshold
  • Persisted queries: in production, only allow queries that are pre-registered (eliminates arbitrary query injection)
  • Field-level authorization: use a library like graphql-shield for declarative permission rules per resolver

What We Got Wrong

In hindsight, three things caused the most pain:

  1. We underestimated schema design time. Good GraphQL schema design requires upfront thinking about the entity graph — rushing this leads to breaking changes later. Spend 2 weeks just on schema design before writing a single resolver
  2. We didn't add query tracing early enough. When performance regressions appeared, we spent days debugging. Apollo Studio or Jaeger from day one would have saved us many hours
  3. Authorization was bolted on later. Building field-level auth into resolvers after the fact is painful. Design your permission model before writing resolvers

The Results

After 14 weeks: mobile app data load times dropped 48%, backend team deployment frequency increased 3x (teams ship their subgraphs independently), and the frontend team's feature velocity increased measurably — no longer waiting for new REST endpoints to be spec'd and built.

The biggest surprise wasn't the technical complexity — it was the team culture shift. GraphQL federation forces clear domain ownership. Teams that had been vague about boundaries had to get explicit. That alone was worth the migration.

If you're considering a REST-to-GraphQL migration, start small, use the Strangler Fig, and invest seriously in your schema design phase. The payoff compounds with every new feature you ship after.