The GraphQL Migration We Quietly Abandoned

A client was six months into migrating from REST to GraphQL. Half their endpoints were converted, developer experience was arguably worse, and the frontend team was maintaining two data layers. Sometimes the right engineering decision is to stop.


The pitch deck was compelling. One of those internal engineering proposals with a clear problem statement, before-and-after diagrams, and a slide titled "Developer Velocity Gains." A mid-size B2B SaaS company — about 60 engineers, a React frontend, a Node.js backend with 140 REST endpoints — was going to migrate to GraphQL. The estimated timeline was four months.

I arrived eight months later. The migration was "80% done," which in my experience means somewhere between 40% and never.

The state of things

Here's what I found when I opened the codebase:

The backend had 140 REST endpoints and 47 GraphQL resolvers. Some resources were available through both. Some only through REST. A handful only through GraphQL, because new features had been built exclusively on the new stack.

The frontend had two data-fetching patterns. Older pages used Axios with a custom hook that handled caching, retries, and auth token refresh. Newer pages used Apollo Client. Both patterns worked, neither worked well with the other. The Apollo cache and the custom cache had no awareness of each other, so the same entity could show stale data on one page and fresh data on another depending on which API layer loaded it.

The team had written a "bridge" utility to keep both caches in sync. It was 600 lines long and had more bugs filed against it than any other module in the project.

Why they started

The reasons were legitimate. I want to be clear about that, because this isn't a story about GraphQL being bad. GraphQL is a fine technology that solves real problems.

Their pain points were real: mobile clients needed to fetch different subsets of the same resources, leading to a proliferation of query-parameter-driven field filtering on REST endpoints. The frontend team was frustrated by over-fetching on list views. And the backend team was tired of building bespoke endpoints for every new UI component.

GraphQL addresses all of that. On paper, the migration made sense.

Where it went wrong

The first mistake was treating it as a gradual migration with no hard cutover date. The plan was to convert endpoints one by one, starting with the most-used ones, and eventually deprecate the REST layer. Reasonable in theory. In practice, it meant the team was maintaining two complete API stacks indefinitely.

Every new feature required a decision: build it in REST (faster, because the patterns are established and the team knows them) or GraphQL (correct, because that's where we're heading). This decision burned time in every sprint planning session. Sometimes different team members made different calls on the same feature.

The second mistake was underestimating the N+1 problem in their resolver layer. Their REST endpoints had been carefully optimized over three years — hand-tuned SQL queries, strategic eager loading, response caching at the HTTP layer. The GraphQL resolvers started as thin wrappers around the existing service layer, which meant the query optimizer had no visibility into what the client actually needed. A single GraphQL query to load a dashboard could generate 30+ database queries.

// What the resolver looked like
const resolvers = {
  Project: {
    members: (project) => userService.getByProjectId(project.id),
    lastDeployment: (project) => deployService.getLatest(project.id),
    metrics: (project) => metricsService.getForProject(project.id),
  }
};
 
// What happened when the frontend queried 25 projects with all fields:
// 1 query for projects
// 25 queries for members
// 25 queries for deployments
// 25 queries for metrics
// = 76 database round trips

They added DataLoader to batch the queries. That fixed the N+1 issue but introduced a new class of caching bugs — stale DataLoader instances across requests, cache key collisions in multi-tenant queries. The kind of problems that only show up in production under real load, not in the integration tests running against a seeded database with 50 records.

The third mistake was social, not technical. The engineer who championed the migration left four months in. The two people who understood the GraphQL schema best were both on the same team. When that team got pulled onto an urgent project for six weeks, the migration stalled — but the GraphQL endpoints still needed maintenance, and now people who hadn't built them were debugging them.

The conversation nobody wanted to have

When I sat down with the engineering leads, I asked a question that made the room uncomfortable: "What if you stopped migrating and stayed on REST?"

The resistance was immediate. They'd invested six months. They had a technical vision. Going back felt like failure. One lead said, "We can't just throw away all that work."

But the work wasn't going to be thrown away. The 47 GraphQL resolvers that existed would keep working. The new features built on GraphQL were fine. The question was whether converting the remaining 93 REST endpoints was worth the cost — and whether maintaining two parallel API stacks for another four-to-six months was acceptable.

I asked them to quantify two things: how much time per sprint the dual-stack architecture was costing them, and what the concrete benefit of completing the migration would be.

The first number came back as roughly 15-20% of backend engineering time — context switching between patterns, maintaining the cache bridge, debugging issues that only existed because of the hybrid state. The second question was harder to answer. The honest truth was that the REST endpoints were fine. They weren't elegant, but they worked, they were well-tested, and the team understood them.

Note

The sunk cost of a migration is real time and real money. But the ongoing cost of a half-finished migration is often higher than either completing it or reverting it. The worst option is staying in the middle.

What they decided

They stopped the migration. Not dramatically — no big announcement, no post-mortem. They just updated the engineering guidelines: new endpoints would be REST, following the existing patterns. The GraphQL layer stayed in place for the features that used it. The cache bridge got a two-week hardening sprint to fix the worst bugs, and then it got left alone.

Over the next three months, the "lost" 15-20% of backend capacity came back. A junior engineer told me it was the first time in months that she felt confident about which pattern to use for a new endpoint. There was only one answer now.

Was this the right call? I think so, for this team, at this point. A smaller team with a greenfield project and a clear cutover plan might have a completely different outcome. The problem was never GraphQL. The problem was a half-finished migration with no end date, maintained by a team that kept losing the people who understood it best.

What I took away

I've seen this pattern repeat across technologies. REST to GraphQL, monolith to microservices, Redux to whatever comes next. The technology being migrated to is usually fine. The migration plan is usually optimistic. And the hardest decision is always recognizing when stopping is more productive than finishing.

The thing nobody tells you about technology migrations is that the middle state is almost always worse than either endpoint. You're paying the complexity cost of both systems while getting the full benefit of neither. If you can't get through the middle fast, you should seriously consider whether you should enter it at all.

How many half-finished migrations are living in your codebase right now?