Hasan's Journal

Stories, lessons, and scars from production.

Mehedi Hasan
Back to blog

Migrating from REST to GraphQL: What I'd Do Differently

GraphQL solved real problems for us. It also introduced new ones we didn't anticipate. Here's the honest retrospective.

#GraphQL#REST#API#Architecture

Why We Migrated

The REST API we'd been using had grown to over 100 endpoints, and the frontend was suffering from two chronic problems. The first was over-fetching: a list view that needed to display ten fields per item was hitting an endpoint that returned thirty fields, meaning two-thirds of the transferred data was discarded. On a fast connection this is invisible; on a slow connection, it's a meaningful performance tax. The second was under-fetching: a detail view that needed data from three related resources required three separate API calls, which meant three round-trips and three loading states to manage. The frontend code to orchestrate those calls, handle partial failures, and merge the results was complex and bug-prone.

GraphQL addressed both problems elegantly. The frontend specifies exactly which fields it needs, eliminating over-fetching. Related resources are nested in a single query, eliminating under-fetching and the orchestration code it required. The type system gives both frontend and backend a shared contract, and tools like code generation eliminate the class of bugs where the frontend's assumptions about the API drift from the backend's actual behavior. On paper, GraphQL was strictly better than our REST API, and the migration case was easy to make. In practice, the migration introduced a set of problems we hadn't anticipated, and the retrospective is more nuanced than the original pitch.

What Worked

The frontend developer experience improved dramatically. Queries that used to require three API calls and complex state management became single GraphQL queries with nested resolvers, and the loading states simplified to a single "loading" boolean per query. Code generation from the schema gave us TypeScript types for every query and mutation, which eliminated an entire category of type-mismatch bugs and made refactoring safer — change the schema, and the frontend compiler tells you every query that needs to be updated. The type safety alone was worth the migration, because it replaced a category of runtime bugs with compile-time errors that are dramatically cheaper to fix.

The performance improvement on slow connections was real and measurable. Pages that used to load in 3-4 seconds on 3G dropped to 1.5-2 seconds, because the transferred data was smaller (no over-fetching) and the number of round-trips was reduced (no under-fetching). For users on good connections, the improvement was invisible; for users on poor connections, it was the difference between a usable and unusable application. That differential impact — helping the users who need it most — was the strongest argument for the migration in hindsight, and it's the argument I'd lead with if I were making the case again.

What Didn't

The problems we didn't anticipate were all on the backend. The first was the N+1 query problem: GraphQL resolvers, if naively implemented, generate one database query per related entity, which means a list of 50 items with a nested relation generates 51 database queries instead of one join. The fix is DataLoader-style batching, which coalesces individual queries into batch queries, but implementing batching correctly requires understanding the query execution model deeply, and the naive implementation "works" in development and falls over in production. We spent weeks optimizing resolver performance that we hadn't budgeted for, because the performance characteristics of GraphQL are different from REST and the expertise didn't transfer.

The second problem was authorization. In REST, each endpoint has a clear authorization boundary — "can this user call this endpoint" — and the check lives at the endpoint level. In GraphQL, any field can be queried by any client, which means authorization has to happen at the field level, not the endpoint level. That's a fundamentally different security model, and getting it wrong means leaking data — a query that asks for a field the user shouldn't be able to see returns the data if the field-level authorization check is missing. We had to audit every field in the schema for authorization, which was a large effort we hadn't anticipated, and the ongoing maintenance burden of keeping field-level authorization correct as the schema evolves is non-trivial.

What I'd Do Differently

If I were making the decision again, I'd ask harder questions about whether the problems GraphQL solves are worth the complexity it introduces. For a frontend-heavy application with complex data needs and a backend team that can invest in resolver optimization and field-level authorization, GraphQL is a strong choice. For a simpler application where the REST API's over-fetching and under-fetching are minor inconveniences rather than critical problems, the migration cost may not be justified. REST with a well-designed schema (OpenAPI, code generation, thoughtful endpoint design) can solve many of the same problems without introducing GraphQL's backend complexity.

The specific thing I'd do differently is to prototype the backend architecture before committing to the migration. We migrated based on the frontend benefits, assuming the backend would "just work," and the backend complexity was where the real cost lived. A prototype — building one or two GraphQL endpoints end-to-end, including resolver optimization, authorization, and monitoring — would have surfaced the backend costs early enough to make an informed decision. That advice — prototype the hard part before committing to the migration — applies to any technology migration, and it's the lesson I've taken from this experience into every subsequent technology decision.