Migrate from REST to GraphQL without breaking clients
Hitesh Sondhi · June 25, 2026 · 11 min read
Your mobile app has 40 REST endpoints in production. Three of them return slightly different shapes of the same user object. The web team wants GraphQL to stop over-fetching, but the Android team just shipped a release pinned to the current /v2/users response. Nobody wants to rewrite 12,000 lines of Retrofit interfaces next sprint.
This is the actual problem with a REST to GraphQL migration. It's not the schema design. It's not the resolver logic. It's the 47 clients already calling your API who don't know you're about to change everything.
We've done this migration twice now, once for a hospitality platform and once for an internal tooling backend. Both times the strategy that worked was the same: don't migrate. Translate.
- Never rewrite client code and the API surface in the same release. The blast radius is too large and rollback becomes impossible.
- Put a GraphQL layer in front of your REST endpoints, not behind them. Resolvers call existing REST routes directly.
- Migrate clients one field at a time, not one endpoint at a time. Field-level adoption lets you ship partial progress without breaking anything.
- Your REST endpoints stay live until every client has moved. Set a hard deprecation date, then enforce it with traffic metrics, not vibes.
Why Full Rewrites Fail
The temptation is to design your GraphQL schema, build resolvers against the database, point clients at the new endpoint, and delete the old REST code. We've seen teams try this. It works in staging. It works in QA. It fails in production because of the clients you forgot about.
There's always a client you forgot about. A cron job that polls /health. A legacy partner integration using basic auth on /v1/orders. An internal dashboard some analyst built in 2022 that hits your API directly with curl. These clients don't show up in your mobile repo or your web repo. They show up in your incident channel at 2am.
The full rewrite approach also couples two risks that should be independent: the risk of getting the schema wrong, and the risk of breaking existing integrations. If you ship both at once, a schema bug looks like a client breakage and vice versa. You can't triage.
The Translation Layer Pattern
Instead of replacing REST with GraphQL, put GraphQL in front of REST. Your resolvers don't query the database. They call your existing REST endpoints, parse the JSON, and return the fields the client asked for.

Here's what this buys you: your REST endpoints keep working unchanged. Your existing mobile and web clients keep hitting /v2/users and getting the same response they always did. New clients, or clients you've updated, hit /graphql and get exactly the fields they ask for.
The resolver code is dumb on purpose. A User resolver calls GET /v2/users/{id}, maps the JSON response to the GraphQL type, and returns it. No new database queries. No new business logic. The REST layer stays the source of truth for authorization, validation, and side effects.
This sounds inefficient. You're adding a network hop. In practice, the GraphQL gateway and your REST API sit in the same cluster, and the internal call adds 3 to 8ms. For a mobile client over LTE, that's noise. If your REST endpoints are fast, the GraphQL layer is fast. If they're slow, the GraphQL layer won't make them slower in any way your users notice.
Where the N+1 Problem Actually Bites
The one real performance risk in the translation layer pattern is the N+1 problem, and it's not where most teams expect it.
You build a Post type with an author field. The author resolver calls GET /v2/users/{authorId}. A client queries for 20 posts with their authors. Your Post resolver fires 20 calls to GET /v2/posts, then your author resolver fires 20 calls to GET /v2/users/{id}, one per post. That's 40 HTTP calls for a single GraphQL query.
The fix is batching, and it works the same way as DataLoader in the JavaScript ecosystem. Instead of each resolver calling the REST endpoint immediately, you collect the IDs during a single tick of the event loop, deduplicate them, and make one call to a bulk endpoint: GET /v2/users?ids=1,2,3,4,5.
If your REST API doesn't have bulk endpoints, add them. This is the one place where the translation layer requires changes to your REST code, and it's worth it. A single GET /v2/users?ids=... endpoint eliminates the N+1 problem for any field that resolves to a user.
We measure this in production on our on-device AI work. Before batching, a list query with nested resolvers averaged 380ms at the GraphQL layer. After batching, it dropped to 95ms. The REST endpoints themselves were fast. The overhead was purely in the number of round trips.
Schema Design: Match REST First, Optimize Later
The mistake teams make in a REST to GraphQL migration is designing the "ideal" GraphQL schema on day one. They normalize types, split monolithic objects into smaller types, add connections and edges for pagination, and rename fields to follow GraphQL conventions.
Don't do this yet.
Your first schema should mirror your REST responses as closely as possible. If /v2/users returns first_name and last_name, your User type has first_name and last_name. Not firstName and lastName. If the REST response nests address inside user, your GraphQL type nests address inside User.
The reason is simple: you're going to write resolvers that map REST JSON to GraphQL types. The closer the shapes match, the less mapping code you write, and the fewer bugs you introduce. You can rename fields later with GraphQL's deprecation mechanism. You can add connections and edges later. You can split types later. But if your first schema is already a redesign, your resolvers become translation layers between two different mental models, and every field is a potential bug.
Once clients have moved to GraphQL and you've confirmed everything works, then you iterate on the schema. Add the firstName alias. Add cursor-based pagination. Split the monolithic Order type into Order and OrderSummary. Deprecate the old fields with @deprecated(reason: "Use firstName instead") and give clients a migration window.
Migrating Clients One Field at a Time
The biggest advantage of the translation layer is that you don't need to migrate a client all at once. You can migrate field by field.
Say your mobile app has a screen that displays user profiles. Today it calls GET /v2/users/{id} and gets 14 fields, of which it uses 9. You can update that one screen to query GraphQL for just those 9 fields, while every other screen in the app still calls REST. The REST endpoint stays live. The GraphQL endpoint handles one query from one screen.
This is how we approach migrations in our AI consulting work. We don't ask teams to rewrite their entire networking layer. We pick one screen, one query, and move it. Then we measure. If the GraphQL query is faster and the response is correct, we move the next screen. If something breaks, the blast radius is one screen, not the entire app.
The mobile team doesn't need to learn GraphQL all at once. They learn it one query at a time, on a screen they already understand, with the REST endpoint as a fallback they can revert to in minutes.
Handling Authentication and Headers
Your REST API probably uses bearer tokens, cookies, or API keys. Your GraphQL endpoint needs to accept the same auth mechanisms, not new ones.
If your REST API reads an Authorization header, your GraphQL gateway reads the same header and passes it through to the REST calls it makes internally. The client doesn't change how it authenticates. The gateway acts as a transparent proxy for auth.
This matters more than people realize. We've seen migrations stall because the GraphQL layer introduced a new auth scheme, and suddenly the mobile team had to implement token refresh logic for two different auth flows. Keep auth identical. The gateway forwards headers. Done.
When REST Endpoints Return Different Shapes
One of the messy realities of REST APIs is that /v1/users and /v2/users return different shapes for the same logical entity. The v1 response has name as a single string. The v2 response splits it into first_name and last_name. The v3 response adds middle_name.
Your GraphQL schema needs one User type. Pick the most complete shape, which is usually the latest version, and map the others to it. The name field in v1 maps to firstName + " " + lastName in your GraphQL type. The v2 shape maps directly. The v3 shape maps directly.
This is where the translation layer earns its keep. Clients on v1 can move to GraphQL and get the v3 shape without changing their business logic, because the gateway handles the mapping. You've effectively upgraded all clients to the latest API version by moving them to GraphQL.
Monitoring and the Deprecation Timeline
Once the translation layer is live, you need two metrics: GraphQL query volume per client, and REST endpoint traffic per route. The goal is to watch REST traffic decline as clients move to GraphQL.
Set up logging on your REST endpoints that records the client identifier. Mobile clients should send a User-Agent or custom header that identifies the app version. Web clients should send a similar identifier. When you see REST traffic from a specific client version drop to zero for 30 days, that client version has migrated.
Don't deprecate REST endpoints based on vibes. Don't deprecate them based on a calendar date you set before the migration started. Deprecate them based on traffic data. When a REST route has zero traffic from all client versions for 30 consecutive days, it's safe to remove.
We track this in a simple dashboard: one line per REST endpoint, showing daily request count over the last 90 days. When a line hits zero and stays there, the endpoint gets a deprecation tag. If traffic reappears, someone found a client we missed, and we investigate before removing anything.
What About Subscriptions and Mutations?
Mutations are straightforward in the translation layer. A GraphQL mutation maps to a POST, PUT, or DELETE on your REST API. The resolver constructs the request, sends it, and returns the result. The same auth headers pass through.
Subscriptions are harder. If your REST API has no real-time mechanism, you can't magically add one via GraphQL. If you have webhooks, you can build a subscription resolver that registers a webhook URL with your REST backend and pushes updates to the GraphQL client over WebSocket. But this is new infrastructure, not a translation of existing REST behavior.
Our recommendation: defer subscriptions until after the query migration is complete. Subscriptions are a feature addition, not a migration step. If you try to ship them at the same time as the query migration, you're coupling a new capability with a platform change, and the debugging surface area explodes.
Ship queries first. Ship mutations next. Ship subscriptions when the REST endpoints they'd replace are already deprecated.
The One Thing That Makes This Work
The translation layer works because it decouples the server migration from the client migration. The server team builds the GraphQL schema and resolvers. The client teams migrate at their own pace. Nobody is blocked on anybody else.
This is the same principle we use when building AI agents for clients. You don't replace the entire system at once. You put the new system alongside the old one, route traffic incrementally, and remove the old system only when the data proves it's safe.
The REST to GraphQL migration is not a technical problem. It's a coordination problem. The translation layer solves the coordination problem by making the migration optional per client, per screen, per query.
If you're planning a migration and want a second pair of eyes on your schema design or resolver architecture, talk to us. We've done this enough times to know which corners are safe to cut and which ones will bite you in production.
This Week: Pick One Query
Find the single most over-fetched REST endpoint in your API. You know the one: it returns 30 fields and the client uses 5. Write a GraphQL resolver that calls that endpoint, define a type with only those 5 fields, and point one screen at /graphql. Measure the response size difference. That's your proof of concept and your migration kickoff in one PR.





