Backend in Depth
REST vs GraphQL (vs Hasura): How to Choose
Fetching too much or too little, how caching is different, changing the schema over time, limits on query cost, and where an auto-generated API fits.
On this page
- Words used in this lesson
- The core difference
- Over- and under-fetching
- Caching: the biggest practical difference
- Schema evolution
- The server-side risks of GraphQL
- Where Hasura fits
- When GraphQL is the wrong choice
- When GraphQL shines
- Scenario: mobile and web need the same data in different shapes. What do you pick?
- Common mistakes
- Interview questions
- Key takeaways
- What's next
In one line: REST gives you simple, cacheable endpoints shaped like resources. GraphQL lets clients ask for exactly the data they need from one endpoint, but it makes caching, security and N+1 harder. Hasura gives you GraphQL on top of Postgres almost for free, but it moves logic into configuration. Choose by the problem, not by the trend.
You have used GraphQL and Hasura from the client side. Interviewers want to hear the trade-offs from the server side: where each style hurts, and how you reduce the pain.
Words used in this lesson
- API (application programming interface) is a set of rules that lets one program ask another program for data or actions.
- REST is a style for building web APIs. Each thing (a user, an order) is a resource with its own URL, and you act on it with HTTP methods such as
GETandPOST. - GraphQL is a query language and runtime for APIs. The client sends one query that lists the exact fields it wants, and the server returns just those fields. Example:
{ user(id: 1) { name } }. - Query reads data. Mutation changes data. These are the two main kinds of GraphQL operation (a third kind, subscription, is described in the table below).
- Schema is the written description of all the types and fields an API offers.
- Resolver is a function on the server that finds the data for one field of the schema.
- Hasura is a server that connects to a database (mostly PostgreSQL) and builds a GraphQL API for you.
- Pagination means returning a long list in small pages instead of all at once.
- N+1 problem means one query for a list, then one more query for each item in it. It is explained in the resolver section below.
- Endpoint is one URL where an API accepts requests.
- CDN (content delivery network) is a group of servers around the world that keep copies of responses close to users.
- Authentication vs authorization: authentication checks who you are (login). Authorization checks what you may do or see.
The core difference
| REST | GraphQL | |
|---|---|---|
| Shape | Many endpoints. Each one returns a fixed shape | One endpoint. The client describes the shape of the response |
| Over/under-fetching | Common: too many fields, or several round trips for related data | Mostly solved: ask for exactly what you need in one request |
| Versioning | Versions in the URL or in a header | Change the schema over time and mark old fields as deprecated |
| Caching | Easy: HTTP caching, CDNs and ETags (a version tag the server sends so a client can ask "has this changed?") work out of the box | Harder: usually a POST to one URL. Needs client-side normalised caches or persisted queries |
| Typing | Optional (OpenAPI, a standard file format that describes a REST API) | Built in: the schema is the contract, with great tools |
| Errors | HTTP status codes | Usually 200 with an errors array and partial data |
| File uploads | Natural (multipart) | Awkward (needs a spec extension or presigned URLs) |
| Learning curve | Low | Higher (schema design, resolvers, dataloaders) |
| Real-time | Polling (asking again and again), SSE (server-sent events: the server pushes text to the browser) and WebSockets (a two-way connection), added separately | Subscriptions (a query that keeps sending new results, usually over WebSockets) are part of the spec |
Over- and under-fetching
- Over-fetching means you get too much.
GET /users/1returns 30 fields, but the screen needs only a name and an avatar. - Under-fetching means you get too little. To show a profile page you call
/users/1, then/users/1/posts, then/posts/9/comments. This is a waterfall of requests (each one must wait for the one before it), and each one adds delay.
GraphQL lets the screen ask for exactly what it needs:
query ProfilePage($id: ID!) { user(id: $id) { name avatarUrl posts(first: 5) { id title commentCount } }}REST has answers too:
- sparse fieldsets: the client lists the fields it wants (
?fields=name,avatar) - expansion: the client asks to include related data (
?expand=posts) - purpose-built composite endpoints (
/profile-page/1) - a backend-for-frontend (BFF) layer, a small server made for one kind of client that calls other services and returns exactly what that client needs (Browser Contract, Lesson 03)
If your app has many different clients with different needs, GraphQL's flexibility pays off. If each screen maps neatly to an endpoint, REST plus a BFF is simpler.
Caching: the biggest practical difference
REST
GET /products/5 is a URL. Any browser, proxy or CDN can cache it (keep a copy). The Cache-Control header says how long a copy is valid, and ETag lets a client check if its copy is still fresh (Browser Contract, Lesson 05). Public APIs that are mostly read get a very big benefit.
GraphQL
All queries usually go to POST /graphql. By default, POST responses are not cached. Here are your options:
- Client-side normalised caches (Apollo Client, urql and Relay are GraphQL client libraries). Normalised means each item is stored once, under a key made of
__typename(its type name) plus itsid. So when one query updates an item, every screen that shows that item updates too. - Persisted queries / automatic persisted queries (APQ). A persisted query is a query saved on the server ahead of time. The client sends a short hash (a fingerprint of the text) instead of the full query. It can then use
GET, which CDNs can cache. With APQ, the server learns the query the first time a client sends both the hash and the text. - Response caching on the server for each field or query (
@cacheControlis an Apollo Server directive that says how long a field may be cached). The cache key is the query plus its variables. Use it only for public data that is the same for everyone. - Caching at the data layer. Use DataLoader (a small library that batches many lookups into one, one instance per request) and Redis (a fast in-memory data store) for expensive lookups.
GraphQL makes HTTP-level caching hard, not impossible. Take a public API that is mostly read and depends on caching, such as a product catalogue behind a CDN. That case favours REST.
Schema evolution
REST usually uses versions (/v1, /v2). GraphQL's habit is to never remove fields suddenly. You follow these steps:
- Add new fields freely.
- Mark old fields
@deprecated(reason: "..."). - Watch usage numbers from the schema registry (a service that stores schema versions and records which fields clients use).
- Remove a field when no client asks for it any more.
Clients ask only for the fields they use. So adding things does not break them. Changing a field's type, its nullability (whether it may be null) or its required arguments does break clients.
The server-side risks of GraphQL
The client chooses the query. So a client can ask for something very expensive.
1. Deeply nested and expensive queries
{ user(id: 1) { friends { friends { friends { friends { name } } } } } }Each level multiplies the work. Defences:
- Depth limiting. Reject queries deeper than N levels.
- Complexity or cost analysis. Give each field a number (its cost) and multiply by list sizes. Reject queries whose total goes over a budget, for each request and each client.
- Required pagination on every list. Put a maximum on
firstorlimit. - Timeouts on resolvers and on the whole request.
- A persisted-query allow-list for public clients. Only queries you approved in advance can run. This is the strongest defence for your own apps.
- Rate limit (cap how much one client may do in a time window) by query cost, not only by the number of requests.
2. Batching and alias abuse
An alias is a custom name for a field in a query, so you can ask for the same field many times. One request can hold thousands of aliases or batched operations (many operations in one HTTP request). They can get around limits that count requests. For example, 1,000 login mutations with aliases can go in one query. Limit the number of aliases, the batch sizes and the rate for each field. Protect sensitive mutations separately.
3. Introspection and field suggestions
Introspection is a built-in GraphQL feature that lets anyone ask the server for its full schema. Many teams turn it off in production for private APIs. They also turn off field-suggestion hints. This is not a replacement for authorization.
4. Authorization
Authorization means checking what each user may see or do. You must check it for every field and every object, in resolvers or in a policy layer (Lesson 06). A query can reach the same object by many paths. If you guard only the top-level resolver, data leaks. This is a classic GraphQL BOLA (broken object-level authorization: a user can read or change an object that is not theirs).
5. N+1 in resolvers
The User.posts resolver runs once for each user. This is the N+1 problem (Lesson 08): one query for the list, then one more for each row. Use DataLoader per request (Lesson 08).
6. Errors
A GraphQL response can be 200 with partial data and an errors array. (A request that is malformed or fails validation before it runs may get a 4xx instead.) Make sure your monitoring reads the body and counts errors, not only the HTTP status. Map your domain errors to stable extensions.code values (extensions is a field in a GraphQL error where you can put your own machine-readable code).
Where Hasura fits
Hasura connects to PostgreSQL (and other sources) and builds a GraphQL API for you. A role is a named kind of user (like user or admin) that has its own permissions. You get queries, mutations, filters, pagination, relationships and subscriptions, and you write no resolvers. It turns a nested GraphQL query into one SQL query, so N+1 is not a concern.
| Strengths | Trade-offs |
|---|---|
| An instant, typed API over your schema. Very fast to build | Business logic moves into metadata and configuration (permissions, actions, event triggers). Metadata is the configuration that tells Hasura what to expose. This needs its own care: version control, migrations, review |
Row-level permissions (rules that say which rows a role may see) set for each role ({ user_id: { _eq: "X-Hasura-User-Id" } }) | Permission rules become part of your security model. Mistakes are easy to miss without tests |
| Subscriptions and live queries are built in | Complex rules and workflows need Actions (your own HTTP handler that Hasura calls for a custom query or mutation), event triggers (a webhook that Hasura calls when a row changes) or remote schemas (another GraphQL server joined into the API). So you still write backend code |
| Fast to prototype and to build admin tools | You depend on Hasura's model and rules. Your data schema shapes the API directly |
Hasura fits well for products with a lot of CRUD (create, read, update, delete) and a model built around Postgres. It fits less well when most of your value is custom domain logic, or when you want the API shape to be separate from the table design.
When GraphQL is the wrong choice
- A simple CRUD API with only a few clients. REST needs less machinery.
- A public API with heavy CDN caching and many third-party users.
- File-heavy or streaming work.
- A small team that cannot run schema governance, cost limits and dataloaders.
- Strict, simple contracts between services. gRPC (a fast binary call protocol with typed contracts) or REST are usually better from one service to another.
- When you cannot spend time on GraphQL security (cost analysis, persisted queries, field-level authorization).
When GraphQL shines
- Many clients (web, iOS, Android, partners) that need different shapes of the same data.
- Deeply related data, where REST would need waterfalls.
- Fast-moving frontends that change what data they need without waiting for new backend endpoints.
- Teams that like a strongly typed schema and generated types from end to end.
- Putting several backends behind one graph (federation or schema stitching: ways to join several GraphQL services into one schema).
Scenario: mobile and web need the same data in different shapes. What do you pick?
Here are the options and how I would decide:
- GraphQL. Each client asks for what it needs. You have one backend and strong types. It is worth it if you have several clients or the UI changes often, and if you will invest in cost limits and dataloaders.
- REST with a BFF for each client. A thin server for mobile and another for web. Each one calls the same domain services and returns a shape made for its client. Operations and caching are simpler, but you write more code for each client.
- REST with sparse fields or expansion. This is enough when the differences are small.
For one web app and one mobile app on a small team, option 3 or 2 is often enough. As the number and variety of clients grow, the benefits of GraphQL become bigger than its cost.
Common mistakes
- Choosing GraphQL by default. Then you fight caching and N+1 without the right tools.
- No limits on depth, complexity or pagination on a public GraphQL endpoint.
- Authorization only on the root field, not on nested objects.
- Global DataLoaders, or resolvers that skip them.
- Monitoring only the HTTP status. You miss
200responses full of errors. - Using Hasura permissions carelessly, without tests for every role.
- Treating REST as "outdated" when HTTP caching and simplicity are what the problem needs.
- Breaking schema changes (changes of type or nullability) without a deprecation path.
Interview questions
When is GraphQL the wrong choice?
It is the wrong choice for these cases:
- simple CRUD APIs with few clients
- public APIs that depend on CDN and HTTP caching
- file-heavy or streaming work
- contracts between services
- teams that cannot do the extra work of cost limits, persisted queries and schema governance
In those cases REST (or gRPC inside the system) is simpler and cheaper.
How do you cache GraphQL responses?
I use normalised client caches (Apollo, urql, Relay) for the UI. I use persisted queries, so requests become GET requests that CDNs can cache. I use server-side response caching with cache-control hints for public data. I also cache at the data layer (Redis, and a DataLoader for each request). Personal data is cached for each user, or not at all.
How do you block a malicious, deeply nested query?
I limit the query depth. I run cost analysis with a budget for each request. I require pagination and put a maximum on it. I set timeouts on resolvers and on requests. I limit aliases and batch sizes. For my own clients I use a persisted-query allow-list. I combine all of this with authentication and rate limits based on cost.
Scenario: mobile and web need the same data in different shapes. What do you pick?
If I have several clients and their needs keep changing, I choose GraphQL. Each client asks for its own shape. I accept the cost of dataloaders, cost limits and schema governance. For one or two clients on a small team, a REST API with sparse fieldsets, or a BFF for each client, is usually cheaper and easier to cache. My decision depends on how different the clients are, how big the team is, and how much I need caching.
What problem does Hasura solve, and what are its trade-offs?
It builds a typed GraphQL API over Postgres for you. It has role-based row-level permissions, relationships and subscriptions. It turns nested queries into single SQL statements. This removes a lot of boilerplate (repeated setup code) and removes N+1. The trade-offs are these:
- Logic and permissions live in metadata. I must keep that in version control and test it.
- Custom workflows need actions or event triggers.
- The API depends on the database model.
How do you evolve a GraphQL schema without breaking clients?
I add fields and types freely. I deprecate fields instead of removing them. I track which fields are used, and I remove a field only when nobody uses it. I avoid changing the types, nullability or required arguments of existing fields. For breaking changes, I use a migration path with new fields, or I check schema versions with the registry and CI checks.
Why does GraphQL often return 200 for errors, and how do you monitor it?
One request can partly succeed. So the protocol sends data and errors together under a 200. To monitor it, I read the responses (or use server-side instrumentation). I count errors by extensions.code, operation name and field. I set alerts on the error rate, not only on the HTTP status.
Where does authorization belong in GraphQL?
It belongs at the object and field level, in the resolver or policy layer (or in database row-level security). The same item can be reached by many query paths. If I guard only the top-level queries, nested fields leak data.
Scenario: your public GraphQL API is hit by very deep, expensive queries. How do you protect it?
I add limits on depth and complexity, with a cost budget for each request. I require pagination and set a maximum. I set timeouts on resolvers and requests. I limit aliases and batching. I use persisted queries or an allow-list for my own clients. I rate-limit by cost. I use DataLoader to avoid N+1. I log the cost of each query to find abusers. I also check authorization on nested fields.
Key takeaways
- REST is simple, cacheable and built around resources. GraphQL is flexible, typed and shaped by the client, but you pay with extra work on caching and security.
- GraphQL needs limits on depth and cost, pagination caps, persisted queries, field-level authorization and a dataloader for each request.
- Hasura gives you instant GraphQL over Postgres with declared permissions. The price is that logic lives in metadata.
- Match the style to how different your clients are, your caching needs and your team size, not to fashion.
What's next
Lesson 10: Node.js Performance Basics. Whatever API style you choose, one blocked event loop stops everyone. Next you will learn what blocks it, how to see it, and how to stop large workloads from freezing your server.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Hasura & GraphQL Interview Questions · Full-Stack Questions
- What Makes a Good API: Resources, URLs and the Contract · API Design
- GraphQL and gRPC: When Not to Use REST · API Design
Related series