REST powers 83% of all web services and 76% of public APIs. GraphQL has seen a 340% surge in Fortune 500 adoption since 2023. These two facts coexist without contradiction: REST remains the default, GraphQL is gaining serious ground in enterprise contexts. I've built production APIs in both, and my take is pragmatic — GraphQL is genuinely better for certain problems, and REST is genuinely better for others. The 'GraphQL is always better' and 'REST is simpler, always use it' camps are both wrong.
REST wins on simplicity, caching, and ecosystem maturity. A REST API is a set of URLs with HTTP verbs — any HTTP client in any language can consume it without a library. GET responses are cacheable by default at the HTTP layer (CDN, browser cache, proxy cache) with standard Cache-Control headers. The tooling ecosystem is enormous: Swagger/OpenAPI for documentation, Postman for testing, OAuth/JWT for auth, rate limiting middleware for every framework. For simple CRUD APIs, public APIs, or APIs where caching is critical, REST is the right choice.
REST achieves ~250ms median response time for standard resource requests. The performance bottleneck for REST is typically overfetching (getting more fields than needed) and multiple round trips (N+1 requests to get related resources). For a mobile client that needs a user profile, recent orders, and notification count in one screen, REST requires three separate API calls. These round trips add latency, especially on mobile networks. The traditional REST solution is a BFF (Backend for Frontend) layer that aggregates multiple API calls into one — which adds its own complexity.
GraphQL wins on data fetching precision and complex data relationships. A single GraphQL query can fetch a user, their last 5 orders, each order's items, and the product details — in one round trip, with exactly the fields the client needs. For mobile apps, dashboards, and complex data models with many relationships, this is significant: GraphQL reduces API calls by up to 60% in complex data aggregation scenarios and achieves ~180ms median latency for queries that would require multiple REST round trips. The client declares exactly what it needs; the server resolves it.
REST: 3 round trips to build a user dashboard
─────────────────────────────────────────────
Client → GET /api/users/42 → { id, name, email }
Client → GET /api/users/42/orders → [{ id, total, status }]
Client → GET /api/notifications/42 → { unread: 3 }
Total: 3 requests, ~750ms on mobile 4G
GraphQL: 1 round trip, exact fields
─────────────────────────────────────────────
Client → POST /graphql
query {
user(id: "42") {
name
email
recentOrders(limit: 5) { id total status }
notificationCount
}
}
→ { user: { name, email, recentOrders: [...], notificationCount: 3 } }
Total: 1 request, ~180ms on mobile 4G ✓
Adoption (2025):
REST: 83% of all web services, 76% of public APIs
GraphQL: 340% growth in Fortune 500 adoption since 2023
67% of adopters report improved dev productivity
89% would choose GraphQL again for similar projectsFrom my experience building NestJS APIs: use GraphQL for client-facing APIs where multiple clients (web, mobile, partners) fetch the same data in different shapes. Use REST for server-to-server integrations, public APIs, and simple CRUD operations. The hybrid approach — GraphQL for the frontend API, REST for microservice-to-microservice calls — works well because it optimizes each layer for its actual use case. In NestJS, you can run both simultaneously with @nestjs/graphql and standard @nestjs/common controllers.
GraphQL's flexibility creates production challenges that don't exist in REST. The N+1 problem: a GraphQL query can trigger N database queries for N items in a list (one query per item to resolve a nested field). The solution is DataLoader, which batches and deduplicates database calls. Query complexity: a client can write a deeply nested query that causes exponential database load. Mitigate with query depth limits, complexity scoring, and query cost analysis. Caching: GraphQL responses aren't cacheable at the HTTP layer by default because all requests go to POST /graphql. Use persisted queries or GET for simple queries to enable CDN caching.
REST APIs have a defined surface area — each endpoint is explicit. GraphQL has an open-ended query language — any field combination the schema allows is a valid request. This makes authorization more complex: each field resolver must check whether the requesting user is allowed to see that field. In REST, you check authorization per endpoint. In GraphQL, you check per field — missing a field check is a potential data leak. Tools like graphql-shield (a permission middleware) help enforce field-level authorization consistently.
// app.module.ts — NestJS with both REST and GraphQL
import { GraphQLModule } from '@nestjs/graphql'
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: true, // generate schema from decorators
sortSchema: true,
introspection: process.env.NODE_ENV !== 'production', // security!
playground: process.env.NODE_ENV !== 'production',
}),
],
})
// invoices.resolver.ts — GraphQL resolver
@Resolver(() => Invoice)
export class InvoicesResolver {
constructor(
private invoicesService: InvoicesService,
private readonly invoiceItemsLoader: InvoiceItemsLoader, // DataLoader for N+1
) {}
@Query(() => [Invoice])
async invoices(@Args('cursor') cursor?: string): Promise<Invoice[]> {
return this.invoicesService.findAll(cursor)
}
@ResolveField(() => [InvoiceItem])
async items(@Parent() invoice: Invoice): Promise<InvoiceItem[]> {
// DataLoader batches all invoice item requests into one DB query
return this.invoiceItemsLoader.load(invoice.id)
}
@Subscription(() => Invoice)
invoiceUpdated() {
return pubSub.asyncIterator('invoiceUpdated') // real-time via WebSocket
}
}
// invoices.controller.ts — REST controller (same service layer)
@Controller({ path: 'invoices', version: '2' })
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) {}
@Get()
findAll(@Query('cursor') cursor?: string) {
return this.invoicesService.findAll(cursor) // same service method!
}
}GraphQL subscriptions over WebSockets provide real-time data push — clients subscribe to events and receive updates without polling. GraphQL subscriptions reduce polling overhead by up to 80% compared to REST polling for real-time data. For an ERP system where users need to see order status updates in real time, a GraphQL subscription is cleaner than WebSocket management in REST. NestJS's @nestjs/graphql module has built-in subscription support using the graphql-subscriptions package.
GraphQL's introspection feature lets clients query the full schema — every type, field, and relationship. In development, this powers GraphQL Playground and type generation. In production, it exposes your entire data model to anyone who can reach your endpoint. Disable introspection in production unless you have a specific reason to enable it: `introspection: process.env.NODE_ENV !== 'production'` in your NestJS GraphQL module config. Also disable the GraphQL Playground in production. An attacker with your full schema can craft targeted queries to probe for data.
I use this decision tree: Multiple clients with different data needs → GraphQL. Simple CRUD with standard data shapes → REST. Public API with third-party developers → REST (GraphQL adds client-side complexity). Real-time data → GraphQL subscriptions. Caching is critical → REST or GraphQL with persisted queries. Team is new to the codebase → REST (lower learning curve). Complex entity relationships, dashboard aggregations → GraphQL. Microservice internal API → REST or gRPC. The answer is usually REST for new simple APIs, GraphQL for client-facing APIs with complex data models.
NestJS makes it easy to run REST and GraphQL simultaneously in the same application. REST controllers and GraphQL resolvers can share the same service layer — a single InvoiceService handles both REST endpoint calls and GraphQL resolver calls. This architecture lets you start with REST and add GraphQL later without rewriting your business logic. The GraphQL module registers its own router at /graphql; REST controllers register at their decorated paths. Both read from the same database via the same service methods.