REST gives you predictable endpoints, HTTP caching, and universal tooling. GraphQL gives you flexible queries, a typed schema, and fewer round trips. The tradeoff is where the complexity lives: REST puts it on the client (multiple requests, over-fetching), GraphQL puts it on the server (resolvers, data loaders, depth limiting).
For most backend services, REST is the simpler and better-supported path. GraphQL earns its place when multiple clients need different views of deeply nested data.
REST APIs are built around resources. Each resource has a URL. You interact with it using HTTP methods:
/users/42 - retrieve a user/users - create a new user/users/42 - replace a user/users/42 - remove a userResponses are JSON. Status codes tell you what happened. A typical endpoint:
GET /api/orders/123
{
"id": 123,
"status": "shipped",
"customer_id": 42,
"items": [
{ "product_id": 7, "name": "Keyboard", "quantity": 1, "price": 89.99 },
{ "product_id": 12, "name": "Mouse", "quantity": 1, "price": 49.99 }
],
"total": 139.98
}
Why it works: HTTP methods are well-understood. Caching works at every layer - browser, CDN, reverse proxy. Every monitoring tool, HTTP client, and debugging proxy supports REST natively. Each request is self-contained, so horizontal scaling is straightforward.
GraphQL exposes a single endpoint where clients specify exactly what data they need. A typed schema defines all available data and operations:
type User { id: ID! name: String! email: String! orders: [Order!]! } type Query { user(id: ID!): User }
Clients query for specific fields:
query { user(id: "42") { name orders { id status total } } }
Only the requested fields come back. One request can traverse relationships that would take three REST calls.
Why it works: No over-fetching or under-fetching. The schema doubles as documentation. Adding fields is non-breaking since clients only request what they need. Especially valuable when mobile apps, web apps, and third-party consumers all need different views of the same data.
| Dimension | REST | GraphQL |
|---|---|---|
| Data fetching | Fixed response per endpoint | Client specifies exact fields |
| Over/under-fetching | Common (mitigate with sparse fieldsets) | Solved by design |
| Caching | HTTP caching works natively | Requires application-level caching |
| Type safety | Opt-in (OpenAPI + codegen) | Built into the schema |
| Tooling | Universal | Specialized (GraphiQL, Apollo Studio) |
| Server complexity | Low (route → handler) | High (schema, resolvers, data loaders, depth limits) |
| Performance | Predictable per endpoint | Variable (clients can construct expensive queries) |
| Auth | Per-endpoint | Per-field (more granular, more work) |
Caching is REST's biggest structural advantage. Every URL is a cache key. CDNs, browsers, and reverse proxies all work without configuration. GraphQL sends POST requests to a single URL, which breaks URL-based caching entirely. You need Apollo Client's normalized cache, persisted queries, or a CDN layer like Stellate.
Flexibility is GraphQL's biggest structural advantage. A mobile app that only needs a user's name and avatar makes one request and gets exactly that. The same API serves a web dashboard that needs the user's full profile, orders, and payment history - also one request. REST would need separate endpoints or sparse fieldsets for each.
Complexity is the tiebreaker. REST is straightforward to build, monitor, and debug. Each endpoint does one thing, and performance is predictable. GraphQL requires resolvers, data loaders (to avoid N+1 queries), query depth limiting, and field-level authorization. That complexity is worth it when client flexibility is genuinely needed. It's overhead when it's not.
REST is the right default for most backends. Encore.ts is built around that assumption.
Endpoints are TypeScript functions with typed request and response objects. Encore handles routing, validation, serialization, and documentation:
import { api } from "encore.dev/api";
interface Order {
id: number;
status: string;
items: OrderItem[];
total: number;
}
export const getOrder = api(
{ method: "GET", path: "/orders/:id", expose: true },
async ({ id }: { id: number }): Promise<Order> => {
return await orderService.findById(id);
}
);
You get type-safe request validation, automatic API documentation that stays in sync with your code, type-safe service-to-service calls, and built-in distributed tracing - without configuring any of it. The api() function pattern is also straightforward for AI coding agents to follow. Agents generate endpoints matching your existing structure, and TypeScript types ensure the generated code is valid. No separate schema definitions to maintain.
For a complete walkthrough, see How to Build a REST API with TypeScript in 2026. Encore.ts also includes primitives for databases, Pub/Sub, cron jobs, caching, and secrets. When you deploy with Encore Cloud, everything provisions automatically in your own AWS or GCP account.