Why gRPC over REST internally
- Binary serialization: Protocol Buffers are 3-10x smaller than JSON for the same data
- HTTP/2: multiplexed streams, header compression, bidirectional communication
- Code generation: the
.protofile generates typed clients and servers — no OpenAPI codegen pipeline - Streaming: server-streaming, client-streaming, and bidirectional streaming are first-class
Define the contract
syntax = "proto3";
package users;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc ListUsers (ListUsersRequest) returns (stream User);
}
message GetUserRequest {
string id = 1;
}
message ListUsersRequest {
int32 page_size = 1;
string page_token = 2;
}
message User {
string id = 1;
string email = 2;
string name = 3;
string role = 4;
}
This is the single source of truth. Both client and server are generated from it.
Server implementation
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
const packageDef = protoLoader.loadSync('users.proto', {
keepCase: true,
longs: String,
defaults: true,
});
const proto = grpc.loadPackageDefinition(packageDef) as any;
const server = new grpc.Server();
server.addService(proto.users.UserService.service, {
getUser: async (call, callback) => {
const user = await db.user.findUnique({ where: { id: call.request.id } });
if (!user) {
return callback({ code: grpc.status.NOT_FOUND, message: 'User not found' });
}
callback(null, user);
},
listUsers: async (call) => {
const users = await db.user.findMany({ take: call.request.page_size });
for (const user of users) {
call.write(user);
}
call.end();
},
});
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
console.log('gRPC server running on :50051');
});
Client usage
const client = new proto.users.UserService(
'user-service:50051',
grpc.credentials.createInsecure(),
);
// Unary call
client.getUser({ id: 'user_123' }, (err, user) => {
if (err) throw err;
console.log(user.email);
});
// Server streaming
const stream = client.listUsers({ page_size: 100 });
stream.on('data', (user) => console.log(user.name));
stream.on('end', () => console.log('Done'));
Error handling with status codes
gRPC has its own status codes — NOT_FOUND, PERMISSION_DENIED, DEADLINE_EXCEEDED, etc. Map them consistently:
const GRPC_TO_HTTP: Record<number, number> = {
[grpc.status.NOT_FOUND]: 404,
[grpc.status.PERMISSION_DENIED]: 403,
[grpc.status.INVALID_ARGUMENT]: 400,
[grpc.status.DEADLINE_EXCEEDED]: 504,
};
Deadlines, not timeouts
gRPC uses deadlines — an absolute point in time, not a duration. This is better for chains of services because the deadline propagates. If service A gives service B a 5-second deadline, and B calls C, C inherits the remaining time. No one accidentally waits longer than the original caller intended.
const deadline = new Date();
deadline.setSeconds(deadline.getSeconds() + 5);
client.getUser({ id: 'user_123' }, { deadline }, callback);
When to stay with REST
- Public APIs: gRPC needs client-side code generation. REST is universally accessible.
- Browser clients: gRPC-Web exists but adds complexity. REST or tRPC is simpler.
- Small teams with few services: the proto toolchain has overhead. If you have three services, REST with TypeScript shared types is enough.
