Web Development

Why we chose GraphQL over REST for a new mobile backend

DEV

A client's mobile app screens each needed a slightly different combination of data — a user profile screen needing partial order history, a dashboard needing summaries from four different resources, a search results screen needing just enough of each product to render a card but nothing more. Building this as REST would have meant either a lot of small endpoint-specific requests fired off in parallel from the mobile client, or a handful of bloated, screen-specific endpoints that duplicate logic across the backend every time a new screen needed a slightly different shape of the same underlying data.

Why the mobile context made this decision sharper than usual

On a web project, an extra round trip or two is rarely the end of the world, since browsers are generally on faster, more forgiving connections and users are more tolerant of a brief loading spinner. Mobile changes that calculus meaningfully: cellular connections are slower and less reliable, battery life is a real constraint that extra network chatter genuinely affects, and users on a mobile app have less patience for a screen that visibly assembles itself from several sequential requests. This context is what tipped the decision toward GraphQL for us here specifically, rather than something we would default to on every project regardless of platform.

Why GraphQL fit

  • The mobile client asks for exactly the fields each screen needs, in a single request, regardless of how many underlying resources that touches, which matters enormously for a screen aggregating data from several otherwise-unrelated backend services
  • Adding a new field to an existing type does not require versioning a new endpoint, which meant the mobile team could ship a new screen needing slightly more data without waiting on a coordinated backend release cutting a new REST endpoint just for them
  • The schema itself acts as living documentation, which made onboarding the client's own mobile developers noticeably faster than the equivalent process would have been against a REST API whose actual behavior lived partly in outdated wiki documentation and partly in the memory of whoever built each endpoint originally

What we had to build around it

N+1 query problems showed up quickly once resolvers started calling into the database independently for nested fields — a query asking for a list of orders, each with its associated customer, would naively issue one database query per order to fetch that order's customer, rather than a single batched query for all of them. We added DataLoader-style batching to fix this, collecting all pending customer lookups within a single tick of the event loop and resolving them in one database round trip instead of many, which brought response times on our worst-offending nested queries down by an order of magnitude.

Caching is genuinely harder than with REST's predictable URLs, since there is no single cacheable URL to point a CDN or reverse proxy at the way there would be for a REST resource. We leaned on persisted queries — where the mobile client sends a short hash identifying a pre-registered query rather than the full query text — combined with field-level caching inside our resolvers, rather than a simple HTTP cache sitting in front of the whole API the way we would for REST.

Rate limiting needed to account for query complexity rather than just request count, since a single expensive, deeply nested query can do as much backend work as dozens of simple REST calls combined. We implemented a basic query cost analysis step that estimates a query's complexity before executing it, rejecting anything above a configured threshold, which has already caught at least one poorly-written internal tooling query that would otherwise have put meaningful unplanned load on the database.

Where we would still choose REST instead

For a single-purpose public API, we would likely still reach for REST — it is simpler to build, cache, and reason about, and the tooling and conventions around it are more broadly understood by any third-party developer who might eventually need to integrate against it. GraphQL's flexibility is a genuine advantage specifically when the consuming client's data needs are varied and evolving, which was exactly this mobile client's situation, but it is not a universal upgrade over REST for every kind of API.

What surprised us most in hindsight

The single biggest surprise, a few months into running this in production, was how much the schema itself became a genuine communication tool between the mobile team and the backend team, functioning almost like a shared contract that both sides could reference and reason about together, rather than the backend team publishing an API and the mobile team simply consuming whatever it happened to expose. A schema change proposal now gets reviewed by both teams before implementation, in a way that a REST endpoint change rarely got the same collaborative attention previously.

Results

For this mobile client's varied and evolving data needs, GraphQL has been the right call, and it has held up well through several months of continued feature development on both the mobile and backend sides, without the kind of endpoint sprawl or duplicated logic the earlier REST-based approach was starting to accumulate before this rebuild.

How authorization works differently in a graph

REST's per-endpoint model makes authorization relatively straightforward: a given route either allows a given user to hit it or it does not, and that decision is usually made once, near the top of the request handling chain. GraphQL's single endpoint accepting arbitrary query shapes means authorization has to happen at the field level instead, since a single query might legitimately return a user's own order history while a nested field on the same query — say, another customer's contact details reachable through a shared address — needs to be blocked independently of whether the outer query itself is allowed to run at all. We built field-level authorization checks into our resolvers rather than relying on a single top-level gate, which took real design work up front but has meant we have never had a case of a legitimate top-level query accidentally leaking a nested field it should not have access to.

Versioning strategy without REST's URL-based versioning

REST APIs commonly version by URL path — `/v1/`, `/v2/` — giving old and new clients a clean, separate way to keep functioning during a transition. GraphQL's single endpoint does not offer that same mechanism, so our versioning strategy leans entirely on additive schema changes and the deprecation pattern described elsewhere on this blog: new fields get added rather than existing ones changed incompatibly, and anything genuinely needing to change gets a new field alongside the deprecated old one, with client teams given a real migration window before the old field is ever actually removed.

Tooling that made the transition easier than expected

GraphQL Playground and its schema introspection made onboarding new team members meaningfully faster than documenting a REST API by hand ever did, since a new mobile developer could explore the entire available schema interactively, trying real queries against a live development server, rather than reading static documentation that risked drifting out of sync with the actual API's current behavior. We treat the schema itself as the single source of truth for what the API can do, rather than maintaining separate documentation that could silently fall out of date the way our old REST API documentation occasionally did.

What we would tell a team building their first GraphQL API

Start with a narrower schema than feels necessary, resisting the urge to model every conceivable future need up front, since a schema is genuinely easier to extend than to walk back once real clients depend on its current shape. Invest in DataLoader-style batching from the very first resolver that touches a relationship, rather than waiting for an N+1 performance problem to force the issue later, since retrofitting batching onto resolvers already written without it is more disruptive than building it in from the start.

Backward compatibility given how slowly mobile app versions update

A web client redeploys the moment we ship a backend change, but a mobile app update depends on app store review time and, more importantly, on individual users actually choosing to install it, which for a meaningful slice of any user base can take weeks or never happens at all. This changes how conservative a schema change needs to be in practice: a field we might happily restructure outright on a web-only API instead has to stay functioning, unchanged, for as long as any meaningfully sized cohort of installed app versions still queries it. We now have the client report its own app version alongside every request, which lets us see in our own request logs roughly what fraction of traffic still comes from an app version old enough to depend on a field we would like to retire, before we ever schedule that field's actual removal.

Testing resolvers in isolation rather than only end to end

Our first testing approach for this API leaned almost entirely on end-to-end tests firing full queries against a test database, which caught real bugs but ran slowly enough that the suite discouraged frequent runs during active development. We have since added a layer of resolver-level unit tests that mock the data layer directly, checking a resolver's own logic — field-level authorization, data shaping, error handling — without needing a real database round trip for every single test case. The end-to-end suite still runs before every deploy to catch integration issues the mocked unit tests cannot see, but the faster unit layer is what most of the team actually runs continuously while writing a new resolver, which has shortened the feedback loop noticeably compared to waiting on the full suite for every small change.

Offline behavior and the mobile client's own cache

Mobile users expect an app to at least partially function without a live connection, which pushed us to think harder about client-side caching than a typical web project would need to. The mobile team adopted Apollo Client's normalized cache, which keeps a local copy of previously fetched data keyed by object identity, letting the app show a last-known state immediately while a fresh request runs in the background rather than showing a blank loading state every time a screen the user has already visited comes back into view. Getting cache invalidation right took real trial and error — a mutation that updates a nested field can silently leave a stale copy of that same data sitting in a different part of the cache if the two are not linked by a consistent identity, which is exactly the kind of subtle bug that a REST client's simpler, endpoint-scoped caching model never has to reason about at all.

← Back to the journal

Have a project in mind?
Let’s talk.

Tell us where you are and where you want to go. We'll map the fastest route between the two.

Currently accepting new clients