Skip to content
lazy devs
5 min readLazy Devs

When GraphQL is the right call, and when it is not

GraphQL solves real problems, but it is not a default. Here is how to tell when it earns its keep and when REST or RPC will serve you better.

GraphQL gets pitched as the grown-up way to build an API, and REST as the thing you settle for. That framing has cost a lot of teams a lot of weekends. GraphQL is genuinely great at a specific set of problems, and a tax on everything else, so the useful question is not "is GraphQL better" but "does your situation actually look like the problem GraphQL was built to solve."

What GraphQL is actually good at

The original pitch from Facebook was about mobile clients on bad networks pulling deeply nested, client-specific data. That is still where GraphQL shines. When you have many different consumers (a web app, an iOS app, a partner integration) all wanting different shapes of the same underlying graph, GraphQL lets each one ask for exactly what it needs in a single round trip.

Concretely, GraphQL earns its keep when:

  • You have a highly relational domain where a single screen needs data from five or six entities, and those combinations differ a lot per screen.
  • You have multiple distinct frontends with different data needs, and you do not want to ship a new backend endpoint every time the UI changes.
  • Over-fetching and under-fetching are real costs, for example a mobile client on a flaky connection where every extra kilobyte and every extra round trip hurts.
  • You want a strongly typed contract that frontend and backend share, with introspection and tooling like codegen built in.

That last point is underrated. A typed schema plus generated TypeScript types means the compiler catches a whole category of frontend bugs before they ship. That is real value, and it is why some teams adopt GraphQL even when the network arguments do not apply to them.

What it costs you

None of the above is free. Here is the bill.

N+1 queries become your full-time job

The flexibility that lets clients ask for nested data is the same flexibility that detonates your database. A query for 50 posts, each asking for its author, naively fires 1 query for the posts and 50 more for the authors. You solve this with DataLoader, which batches and caches lookups within a request, but you have to remember to wire it up for every relationship.

import DataLoader from "dataloader";
import { db } from "./db";
 
// Batches all author lookups in a single tick into one SQL query.
function createUserLoader() {
  return new DataLoader<string, User>(async (ids) => {
    const users = await db.user.findMany({
      where: { id: { in: [...ids] } },
    });
    // DataLoader requires results in the same order as the input keys.
    const byId = new Map(users.map((u) => [u.id, u]));
    return ids.map((id) => byId.get(id) ?? new Error(`No user ${id}`));
  });
}
 
const resolvers = {
  Post: {
    // Without the loader, this runs once per post: the classic N+1.
    author: (post: Post, _args: unknown, ctx: Context) =>
      ctx.loaders.user.load(post.authorId),
  },
};

You create a fresh loader per request so the cache does not leak between users. Forget that, and you ship a data-privacy bug. This is not exotic, it is table stakes, and it is overhead you simply do not have with a hand-written REST endpoint that issues one deliberate JOIN.

Caching gets harder

With REST, HTTP caching just works. A GET /posts/42 is cacheable by the browser, a CDN, and a reverse proxy, keyed by URL. GraphQL sends everything as a POST to a single /graphql endpoint, so all of that free infrastructure stops helping you. You end up reaching for persisted queries, normalized client caches like Apollo or urql, and response caching at the resolver level. It is solvable, but you are rebuilding caching you used to get for nothing.

Every client can write a query that hurts you

A public or partner-facing GraphQL endpoint is an open invitation to ask for deeply nested, expensive data. You need query depth limiting, complexity scoring, and timeouts as a baseline, not as a later hardening pass.

import depthLimit from "graphql-depth-limit";
 
const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)],
  // Plus per-field cost analysis and a hard request timeout in production.
});

REST has rough edges too, but a REST endpoint can only return what you coded it to return. The blast radius is smaller by default.

When REST or RPC is the better call

Reach for plain REST or a typed RPC layer when:

  • Your API is mostly CRUD with predictable, stable resource shapes. If GET /invoices/:id returns roughly the same fields every time, GraphQL's flexibility is solving a problem you do not have.
  • You have one frontend built by the same team that owns the backend. You can just add the exact endpoint you need. That is faster than maintaining a schema, resolvers, and loaders.
  • HTTP caching matters to you, for example a content site where CDN caching is the whole performance strategy.
  • You are doing file uploads, streaming, or binary payloads, which GraphQL handles awkwardly.

For a TypeScript team with a single Next.js frontend, there is a sweet spot worth naming. Tools like tRPC give you end-to-end type safety (the thing most teams actually wanted from GraphQL) without a schema language, without resolvers, and without the N+1 footguns. You write a backend function, you call it from the client, and the types flow through automatically.

// server: the procedure is just a typed function
export const appRouter = router({
  getInvoice: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(({ input }) => db.invoice.findUnique({ where: { id: input.id } })),
});
 
// client: fully typed, no codegen step, no query language
const invoice = await trpc.getInvoice.query({ id: "inv_123" });

If your real goal was "I want my frontend and backend types to match," tRPC or even REST with a generated OpenAPI client gets you there with a fraction of the moving parts.

A decision you can actually use

Skip the religion. Ask three questions about your project:

  1. How many different clients consume this API, and how different are their data needs? One client with stable needs leans REST or RPC. Several clients with wildly different needs leans GraphQL.
  2. Who owns the frontend and backend? One team that owns both can ship endpoints on demand and rarely needs GraphQL's decoupling. Many independent teams or external consumers benefit from a self-serve typed graph.
  3. Is the data deeply relational, or mostly flat resources? Deep graphs that get sliced differently per screen are GraphQL's home turf. Flat CRUD is not.

If you land on GraphQL, commit to the operational work up front: DataLoader on every relationship, depth and complexity limits before you go live, and a real answer for caching. If you land on REST or tRPC, do not feel like you settled. You probably just avoided a pile of complexity you would have spent the next year managing.

Takeaway

GraphQL is a specialized tool for relational data served to many different clients, not a default upgrade over REST. Pick it when your problem looks like the problem it solves, and reach for REST or tRPC when you mostly need typed, predictable endpoints for one app. The lazy move, in the good sense, is choosing the option that leaves you less to maintain.

If you are mid-migration and not sure which way to jump, our API and backend engineering team is happy to look at your actual schema and traffic before you commit.

Related service

API & Backend Engineering

Secure, well-documented APIs that scale.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com