How Google Fixed the Way Services Talk to Each Other
REST works well for small systems, but as applications grow into dozens of interconnected services, inconsistent API conventions, documentation drift, JSON overhead, and performance costs become increasingly difficult to manage. gRPC was developed by Google to address these challenges, drawing on years of experience operating large-scale distributed systems. By providing strict API contracts through Protocol Buffers, a high-performance binary communication format, and built-in support for streaming, gRPC enables faster, more consistent, and more scalable service-to-service communication than traditional REST-based approaches.
Contents
If you have spent time building services that talk to each other, you know the friction. One service needs to call another, and suddenly there are decisions to make that have nothing to do with the actual problem. What does the URL look like. What goes in the body. How do errors get surfaced. How do you version this without breaking clients that already depend on it. REST made all of this approachable, and for a long time that was the right tradeoff. The ecosystem matured around it, and it became the default.
The default has limits, though. At a certain scale, approachable and rigorous stop being the same thing.
Where gRPC Came From
By the mid-2000s, Google was running a distributed system at a scale nobody else had reached. Search alone was an orchestration of dozens of subsystems working in parallel, each service calling several others, each of those calling several more. To manage all of it, the infrastructure team had built an internal RPC framework called Stubby. It was opinionated, fast, and completely tied to Google's own infrastructure stack.
Stubby was never going to be shareable as-is. But as the industry started wrestling with microservices at scale, Google took the ideas behind it, rebuilt them on open standards like HTTP/2 and Protocol Buffers, and open sourced the result in 2015. The timing landed well. Companies were splitting monoliths into dozens of services and discovering that the communication layer between those services was a harder problem than it first appeared.
What REST Gets Right, and Where It Runs Out of Road
REST is genuinely good at what it was designed for. Public APIs, browser-facing endpoints, anything where human readability and broad compatibility matter. JSON is easy to inspect, curl works everywhere, and every language has an HTTP client. For that use case, REST is hard to beat.
The gaps show up at the seams.
There is no formal contract. You might write OpenAPI docs, or you might not. The docs might be current, or they might have drifted. A client consuming your API is trusting that the field names and types in the documentation match what the server actually sends, with no mechanism to enforce that trust. When it breaks, it breaks at runtime in ways that take time to track down.
There is also more overhead than necessary for internal traffic. JSON is text, and parsing text is slower than reading a binary format. HTTP/1.1 either opens a new connection per request or requires a connection pool, and both options carry complexity. For a public API serving diverse clients, that overhead is a reasonable cost. For two internal services in the same data center making hundreds of thousands of calls per day, it accumulates into something worth fixing.
Streaming is a separate problem. WebSockets and server-sent events both work, but they are distinct protocols with their own tooling and their own failure modes. REST was not designed with streaming in mind, and trying to add it later always feels like it.
The Foundation gRPC Is Built On
Three decisions at the foundation level explain most of what makes gRPC behave differently from REST.
Protocol Buffers replace JSON as the serialization format. You define messages and services in a .proto file, and a code generator produces client and server code in your target language. The generated code handles serialization and deserialization automatically, and the schema acts as a compile-time contract. If the server changes a field name without updating the client, the build breaks. That is a dramatically better place to catch the problem than a production incident at 2am.
HTTP/2 is the transport. Unlike HTTP/1.1, which processes requests sequentially on a connection, HTTP/2 multiplexes them, sending multiple requests in flight over a single connection in parallel, without head-of-line blocking. Header compression also helps when you are making large numbers of small requests, which is exactly the pattern of internal service calls.
Streaming becomes a first-class concern rather than an afterthought. gRPC exposes four patterns: unary (the standard request-response), server streaming (one request, a stream of responses), client streaming (a stream of requests, one response), and bidirectional streaming (both sides streaming concurrently). All four use the same code generation pipeline and the same tooling, so you are not learning a new protocol each time you need a different communication shape.
Setting Up a gRPC Service From Scratch
Everything starts with the proto file. This is where you define the messages your service accepts and returns, and the methods it exposes.
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc CreateUser (CreateUserRequest) returns (UserResponse);
}
message GetUserRequest {
string user_id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
message UserResponse {
string user_id = 1;
string name = 2;
string email = 3;
string created_at = 4;
} The field numbers are how Protocol Buffers identifies fields in the binary format. They stay stable even if you rename the field later, which is how backward compatibility works across versions.
Run the protoc compiler with the gRPC plugin and you get the boilerplate. Here is the Node.js server implementing that service:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { v4: uuidv4 } = require('uuid');
const packageDefinition = protoLoader.loadSync('user.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
// In-memory store for the example
const users = new Map();
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
getUser: (call, callback) => {
const user = users.get(call.request.user_id);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: `User ${call.request.user_id} not found`,
});
}
callback(null, user);
},
createUser: (call, callback) => {
const user = {
user_id: uuidv4(),
name: call.request.name,
email: call.request.email,
created_at: new Date().toISOString(),
};
users.set(user.user_id, user);
callback(null, user);
},
});
server.bindAsync(
'0.0.0.0:50051',
grpc.ServerCredentials.createInsecure(),
(err, port) => {
console.log(`Server running on port ${port}`);
server.start();
}
); And the client:
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const packageDefinition = protoLoader.loadSync('user.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
const client = new userProto.UserService(
'localhost:50051',
grpc.credentials.createInsecure()
);
// Create a user
client.createUser({ name: 'Minh Tran', email: 'minh@example.com' }, (err, response) => {
if (err) {
console.error('Error creating user:', err.message);
return;
}
console.log('Created user:', response);
// Now fetch the same user
client.getUser({ user_id: response.user_id }, (err, user) => {
if (err) {
console.error('Error fetching user:', err.message);
return;
}
console.log('Fetched user:', user);
});
}); The client reads like a local function call. The network, serialization, and HTTP/2 connection are entirely hidden behind the generated stub. That is the point of RPC. The remote part should be invisible.
Streaming
The unary pattern above looks close enough to REST that the difference is mostly syntactic. Streaming is where the gap becomes structural.
Say you have a service that processes large datasets and needs to report progress continuously, or a notification system that pushes events to clients as they happen. With REST you are polling, sending repeated requests and hoping the server state has changed between them. With gRPC server streaming, the server writes to the stream as events arrive and the client receives them in order, over the same connection, without any polling logic.
Extend the proto file to add all three streaming patterns:
service UserService {
rpc GetUser (GetUserRequest) returns (UserResponse);
rpc CreateUser (CreateUserRequest) returns (UserResponse);
rpc WatchUsers (WatchUsersRequest) returns (stream UserResponse);
rpc CreateUsersBatch (stream CreateUserRequest) returns (BatchSummary);
rpc SyncUsers (stream CreateUserRequest) returns (stream UserResponse);
}
message WatchUsersRequest {}
message BatchSummary {
int32 total_created = 1;
repeated string user_ids = 2;
} Server streaming on the server side:
watchUsers: (call) => {
// Simulate pushing new users as they are created
const interval = setInterval(() => {
users.forEach((user) => {
call.write(user);
});
}, 2000);
call.on('cancelled', () => {
clearInterval(interval);
});
call.on('error', () => {
clearInterval(interval);
});
}, Client streaming, where the client sends a batch and the server responds once when the stream ends:
createUsersBatch: (call, callback) => {
const createdIds = [];
call.on('data', (request) => {
const user = {
user_id: uuidv4(),
name: request.name,
email: request.email,
created_at: new Date().toISOString(),
};
users.set(user.user_id, user);
createdIds.push(user.user_id);
});
call.on('end', () => {
callback(null, {
total_created: createdIds.length,
user_ids: createdIds,
});
});
}, Bidirectional, where both sides are streaming at the same time:
syncUsers: (call) => {
call.on('data', (request) => {
const user = {
user_id: uuidv4(),
name: request.name,
email: request.email,
created_at: new Date().toISOString(),
};
users.set(user.user_id, user);
call.write(user); // Immediately write the created user back
});
call.on('end', () => {
call.end();
});
}, The client side for all three:
// Server streaming
const watchCall = client.watchUsers({});
watchCall.on('data', (user) => {
console.log('New user event:', user);
});
watchCall.on('end', () => {
console.log('Watch stream ended');
});
// Client streaming
const batchCall = client.createUsersBatch((err, summary) => {
if (err) return console.error(err);
console.log(`Batch created ${summary.total_created} users:`, summary.user_ids);
});
batchCall.write({ name: 'Alice', email: 'alice@example.com' });
batchCall.write({ name: 'Bob', email: 'bob@example.com' });
batchCall.write({ name: 'Charlie', email: 'charlie@example.com' });
batchCall.end();
// Bidirectional streaming
const syncCall = client.syncUsers();
syncCall.on('data', (user) => {
console.log('Synced user confirmed:', user);
});
syncCall.write({ name: 'Diana', email: 'diana@example.com' });
syncCall.write({ name: 'Eve', email: 'eve@example.com' });
syncCall.end(); Replicating any of this in REST means reaching for WebSockets or SSE, each with its own setup, its own error model, and its own debugging surface. In gRPC it is four method signatures in the proto file and the same event-based API throughout.
Error Handling and Interceptors
REST error handling is whatever the team agreed on when they built the API, which varies more than it should. Sometimes errors come back as HTTP status codes. Sometimes they are a status field in the JSON body. Sometimes both, in ways that do not quite line up. gRPC ships with a standardized set of status codes baked into the framework: NOT_FOUND, INVALID_ARGUMENT, UNAUTHENTICATED, UNAVAILABLE, and about a dozen others that cover the common cases.
// On the server, return structured errors
getUser: (call, callback) => {
if (!call.request.user_id) {
return callback({
code: grpc.status.INVALID_ARGUMENT,
message: 'user_id is required',
});
}
const user = users.get(call.request.user_id);
if (!user) {
return callback({
code: grpc.status.NOT_FOUND,
message: `User ${call.request.user_id} not found`,
});
}
callback(null, user);
}, // On the client, handle them consistently
client.getUser({ user_id: 'nonexistent' }, (err, response) => {
if (err) {
switch (err.code) {
case grpc.status.NOT_FOUND:
console.log('User does not exist');
break;
case grpc.status.INVALID_ARGUMENT:
console.log('Bad request:', err.message);
break;
case grpc.status.UNAVAILABLE:
console.log('Service is down, retry later');
break;
default:
console.error('Unexpected error:', err);
}
return;
}
console.log('User:', response);
}); Interceptors handle the cross-cutting concerns that would otherwise bleed into every service handler. Authentication, request logging, retry logic, distributed tracing. All of it can live in one place and apply uniformly across every call.
// Server-side interceptor for logging and auth
const authInterceptor = (methodDefinition, call, callback, next) => {
const metadata = call.metadata.getMap();
const token = metadata['authorization'];
console.log(`[${new Date().toISOString()}] ${methodDefinition.path}`);
if (!token || token !== 'Bearer valid-token') {
return callback({
code: grpc.status.UNAUTHENTICATED,
message: 'Missing or invalid token',
});
}
// Attach the verified user to the call for downstream handlers
call.user = { id: 'user-from-token' };
next(call, callback);
};
// Client-side interceptor to attach the token automatically
const clientAuthInterceptor = (options, nextCall) => {
return new grpc.InterceptingCall(nextCall(options), {
start: (metadata, listener, next) => {
metadata.add('authorization', 'Bearer valid-token');
next(metadata, listener);
},
});
};
const client = new userProto.UserService('localhost:50051', grpc.credentials.createInsecure(), {
interceptors: [clientAuthInterceptor],
}); Once that interceptor is wired in, every call from that client carries the token automatically. No manual threading of auth headers through every call site.
gRPC vs REST, Side by Side
The two cover overlapping ground but make different tradeoffs, and the right call depends on context.
Contracts are optional in REST. You might write OpenAPI documentation, you might not, and the framework will not stop you from shipping something inconsistent. In gRPC the proto file is the API. Nothing gets generated without it, and clients and servers stay in sync by construction.
Serialization is JSON in REST, text that is human-readable and inspectable with any tool, but slow to parse at volume. gRPC uses Protocol Buffers, a binary format that is faster to serialize, smaller on the wire, and completely opaque without the schema. The performance gain is real. The debugging experience is worse.
Transport is where the HTTP/2 requirement shows up. REST runs on HTTP/1.1 without configuration, which is a meaningful compatibility advantage. gRPC requires HTTP/2, which buys you multiplexed requests and compressed headers but also means you need an environment that supports it.
Streaming has no native REST answer. WebSockets and SSE work, but they are separate protocols with separate mental models. gRPC's four patterns all share the same tooling as ordinary unary calls.
Browser support is native for REST. gRPC needs grpc-web, a proxy layer that translates browser requests into something a gRPC server understands. It works, but it is extra infrastructure that REST does not require.
Error handling in REST follows whatever convention the team settled on. gRPC's status codes are standardized across the framework, meaning the same thing regardless of which service you are talking to.
The learning curve for gRPC is real. Proto files, code generation, a somewhat different mental model for calls. None of it is steep, but it is not zero either.
For a public API with varied external clients, REST is still the stronger choice. The compatibility, the readability, the ease of exploration all matter when you do not control who is consuming you. For internal service-to-service traffic, gRPC earns its keep. The contract enforcement alone removes an entire class of integration bugs. The performance and streaming support are genuine advantages at scale, not theoretical ones. Google, Netflix, Square, Lyft, and Dropbox all moved internal traffic to gRPC not for ideological reasons but because the friction of REST at scale added up to something measurable.
Where gRPC Has Rough Edges
Browser support is the most concrete limitation. Browsers cannot make raw HTTP/2 calls with the control gRPC needs, so grpc-web is required, a proxy that translates between browser requests and gRPC. It works, but it adds a layer that REST does not need.
Debugging takes more tooling. A REST response is JSON you can read in a browser network tab or with curl. A gRPC response is binary. You need grpcurl or grpcui to inspect calls interactively, which is another thing to install and keep current.
The proto file is a barrier for public API consumers. Inside a team it is a clear benefit. For external developers, it means managing a schema file and running a code generator rather than just reading a JSON response and writing a fetch call. That friction compounds as the number of consumers grows.
Ecosystem coverage has also improved significantly but is still uneven. In Go and Java the support is mature and well-documented. In some less common languages or restricted environments, you may find thinner library support or gaps in tooling.
The Bigger Picture
gRPC and REST are not competing for the same use cases. Most production systems end up using both. REST at the public boundary where compatibility and readability matter, gRPC across the internal service mesh where contracts and performance do.
What gRPC contributed was a production-grade framework for internal communication that codified what engineers at Google had learned the hard way: that informal conventions do not scale, that binary serialization matters at volume, that streaming should not require a separate protocol, and that a contract enforced by the build system is worth far more than one written in a README.
If you have ever spent an afternoon tracking down a bug caused by a field name mismatch between two services, or discovered that two teams had subtly different interpretations of what an error response meant, you already know the problem the proto file solves. It is not boilerplate. It is the thing that makes that whole category of problem stop happening.
Source: Published Notion page
This article
Post Reactions
Join the conversation
Write a Comment
Share your thought about this article.
Comments
Loading comments...