Three API Styles, Three Sets of Tradeoffs
REST vs GraphQL vs gRPC
The real tradeoffs between REST, GraphQL, and gRPC — over/under-fetching, typing, streaming, caching, browser support — and how each looks from Node.
What you'll learn
- Contrast REST, GraphQL, and gRPC across the dimensions that matter
- Pick the right paradigm for a given client and traffic shape
- Recognize what each looks like from a Node client and server
Once you’ve picked a transport, you pick an API paradigm — the contract between client and server. The three you’ll defend in an interview are REST, GraphQL, and gRPC. None is universally “best”; each optimizes for a different problem. Your job is to match the paradigm to the client and the traffic.
REST: resources and verbs
REST models your system as resources addressed by URLs, manipulated with HTTP
verbs (GET, POST, PUT, DELETE). It’s the default for public web APIs
because it’s simple, debuggable with curl, and — crucially — plays perfectly
with HTTP caching. A GET /users/42 is a cacheable, idempotent request that
CDNs and browsers already know how to store.
Its two classic pains are over-fetching and under-fetching. A GET /users/42 returns the whole user when the client only wanted the name
(over-fetch). Rendering a profile screen might need the user, their posts, and
their followers — three round trips (under-fetch). REST’s coarse-grained
endpoints don’t bend to each screen’s exact needs.
GraphQL: the client shapes the response
GraphQL exposes one endpoint and a typed schema; the client sends a query describing exactly the fields it wants, nested as deeply as needed, and gets back that exact shape. One round trip, no over- or under-fetching.
const query = `
query Profile($id: ID!) {
user(id: $id) {
name
posts(last: 3) { title } # nested — no extra round trip
followers { count }
}
}
`;
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query, variables: { id: '42' } }),
});
const { data } = await res.json(); // shaped exactly like the query The costs: HTTP caching gets harder (everything is a POST to one URL, so you
lean on client-side caches like Apollo and persisted queries instead). The server
must guard against expensive queries — deep nesting can trigger the N+1
problem, which you fix with DataLoader batching. And the schema/resolver
layer is real machinery to operate.
gRPC: typed, binary RPC
gRPC is remote procedure calls over HTTP/2 with Protocol Buffers. You
define services and messages in a .proto file, and codegen produces strongly
typed client and server stubs. The payload is compact binary, calls are fast, and
HTTP/2 gives you bidirectional streaming for free.
The catch is browser support: browsers can’t speak raw gRPC, so client-facing use needs a gRPC-Web proxy that translates. That, plus the codegen step, makes gRPC shine for internal service-to-service traffic where you control both ends and care about throughput.
The comparison
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Transport | HTTP/1.1+ | HTTP (usually 1.1) | HTTP/2 |
| Payload | JSON (text) | JSON (text) | Protobuf (binary) |
| Schema/typing | Optional (OpenAPI) | Built-in, typed | Built-in, typed |
| Over/under-fetch | Common | Solved | Per-method |
| HTTP caching | Excellent | Hard | N/A (POST-like) |
| Streaming | SSE/WebSocket bolt-on | Subscriptions | First-class |
| Browser-friendly | Yes | Yes | No (needs gRPC-Web) |
| Best for | Public web APIs | Aggregating many sources | Internal microservices |
How to choose
A useful default: REST at the edge, gRPC in the back. Public clients and partners get a cacheable REST surface; your internal services talk to each other over gRPC for speed and strong contracts. Reach for GraphQL when one frontend aggregates many backend sources and you’re tired of shipping a bespoke “backend-for-frontend” endpoint per screen.
The JavaScript angle
All three are first-class in Node. REST is an Express route. GraphQL servers run
on Apollo Server or GraphQL Yoga. gRPC uses @grpc/grpc-js (a pure-JS
implementation, no native addon) driven by your .proto.
import grpc from '@grpc/grpc-js';
import protoLoader from '@grpc/proto-loader';
const def = protoLoader.loadSync('users.proto'); // from the .proto contract
const { users } = grpc.loadPackageDefinition(def);
// --- server: implement the RPC method ---
const server = new grpc.Server();
server.addService(users.UserService.service, {
GetUser: (call, callback) => {
callback(null, { id: call.request.id, name: 'Ada' });
},
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () =>
server.start(),
);
// --- client: call it like a local function ---
const client = new users.UserService('localhost:50051', grpc.credentials.createInsecure());
client.GetUser({ id: '42' }, (err, user) => console.log(user.name)); // "Ada" Notice the client calls GetUser like a local function — that’s the whole point
of RPC. Contrast it with the REST fetch and the GraphQL query above: same data,
three different contracts, three different cost profiles.
These three paradigms all assume the client asks first. Next we flip that: how do you let the server push data to the client?