Last year, our API was slow. Not broken, just slow enough to be annoying. Every internal request took around 180ms. And most user actions needed four to seven of those requests back to back. Do the math and you get a page load that spends a full second just talking to our own services. Not the internet. Not the database. Our own servers, talking to each other. We fixed it. Not by rewriting everything. Not by throwing more servers at it. We fixed it by switching our internal service-to-service calls from REST to gRPC, and letting HTTP/2 do its job. The result: 60% faster responses across the board. This is the story of how we did it, what broke along the way, and whether you should do it too. "Premature optimization is the root of all evil.", Donald Knuth We didn't ignore that quote. We measured first. Then we optimized.
Why Our API Was Slow
Before touching any code, we sat down and profiled real requests. Here's what we found.
Our architecture looked like this:
A public REST API for our customers (JSON over HTTP/1.1)
Internal REST calls between our services (also JSON)
PostgreSQL as the main database
The public API was fine. It was fast enough and cached well. The problem was internal traffic.
Here's what a typical request looked like:
User asks for their dashboard.
Frontend calls the API gateway (REST).
Gateway calls the user service (REST).
Gateway calls the billing service (REST).
Gateway calls the analytics service (REST).
Gateway calls the notification service (REST).
Four REST calls, each around 180ms. Each one opening a new connection. Each one sending a fat JSON payload with fields we never used.
Total: ~720ms of pure internal overhead. That's before a single database query runs.
Read article on "What I Learned Saying "Yes" to an Unpaid Opportunity"
The Problem With Our REST Calls
REST is great. Don't let anyone tell you otherwise. It's simple, it's everywhere, and it works. But REST was designed for public APIs, and we were using it for internal traffic, two very different jobs.
Here's where it hurt us:
Problem 1: Over-Fetching
Our /users/:id endpoint returned a full user object. That's 34 fields. But our billing service only needed the user's id and plan.
We were sending 34 fields to use 2. Every single request.
Problem 2: No Connection Reuse
Each REST call opened a new TCP connection. Then TLS handshake. Then request. Then response. Then teardown.
For four calls, that's four handshakes. Multiply that by thousands of requests per second and the overhead becomes huge.
Problem 3: Text Is Expensive
JSON is text. Every key, every quote, every curly brace is a byte that has to travel the network and get parsed on the other side.
That's not the end of the world for one call. But across millions of calls, it adds up.
Problem 4: No Schema, No Safety
We had no formal contract between services. When the user service changed a field name, the billing service only found out at runtime. In production. On a Friday.
Not great.
Read article on "Fixing Cognitive Distortions in Code"
Why We Chose gRPC
We looked at three options:
Option | Verdict |
|---|---|
Keep REST, optimize endpoints | Would help, but only a little |
Switch to GraphQL | Flexible, but adds server complexity |
Switch to gRPC | Best fit for our internal calls |
GraphQL would have solved the over-fetching problem. But it would have made caching harder, added query complexity, and required every service team to learn a new query language.
gRPC checked every box for us:
Binary payloads (Protocol Buffers), much smaller than JSON
HTTP/2 by default, multiplexing, no more handshakes
Strong contracts (
.protofiles), compile-time safetyAuto-generated client code, no more hand-written HTTP wrappers
Faster serialization, parsing binary is way quicker than parsing text
"The most important property of a system is that it fails visibly.", Joe Armstrong
That's what gRPC gave us. If a contract broke, we found out at build time. Not at 2 AM.
Read article on "Here’s Why Goals and Loops Matter"
How HTTP/2 Made the Difference
If gRPC is the star of this story, HTTP/2 is the co-star.
Most people don't realize that HTTP/1.1 has a nasty bottleneck: you can only send one request per connection at a time. Want to make four calls in parallel? You need four connections. Each one costs a handshake.
HTTP/2 throws that limitation out.
HTTP/1.1 vs HTTP/2 at a Glance
Feature | HTTP/1.1 | HTTP/2 |
|---|---|---|
Requests per connection | 1 at a time | Many in parallel |
Headers | Sent as text | Compressed (HPACK) |
Connection setup | Repeated per request | One connection, reused |
Server push | No | Yes |
Binary protocol | No | Yes |
Multiplexing is the big one. With HTTP/2, all four of our internal calls could go over a single TCP connection, at the same time. No new handshakes. No waiting for the first response to finish before the second one starts.
That single feature alone cut our average request time roughly in half.
Read article on "HTTP/3 vs HTTP/2"
The Migration: Step by Step
We didn't flip a switch. We moved one service at a time.
Here's the playbook we used.
Step 1: Define the .proto Files
Before touching any code, we wrote a Protocol Buffer file for every service we planned to migrate.
protobuf
syntax = "proto3";
package user.v1;
message GetUserRequest {
string user_id = 1;
}
message User {
string id = 1;
string email = 2;
string plan = 3;
int64 created_at = 4;
}
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}Notice how small that is. Compare it to a full REST user object with 34 fields.
Lesson: In gRPC, you only define what you actually need. That discipline is what makes it fast.
Step 2: Generate Client and Server Code
We used protoc with the gRPC plugin. Running one command generated server stubs and client code in our language (Go, in our case).
bash
protoc --go_out=. --go-grpc_out=. user.protoThat was it. No hand-writing HTTP calls. No parsing JSON by hand. The compiler did the boring work.
Step 3: Migrate the Fastest Services First
We didn't touch the billing service first. Billing is scary. Billing is money.
We started with low-risk services that everyone depended on:
User service (small, clear contract)
Notification service (fire and forget)
Analytics lookup (read-only)
We ran both REST and gRPC in parallel for two weeks. Every gRPC call was compared against the REST response to make sure nothing broke.
Lesson: Parallel-run migrations save you from surprises.
Step 4: Parallel Requests Over HTTP/2
Once the first few services were on gRPC, we rewrote our gateway to make parallel calls instead of sequential ones.
Before (sequential REST):
javascript
const user = await fetch('/api/users/123').then(r => r.json());
const invoices = await fetch('/api/invoices?user=123').then(r => r.json());
const alerts = await fetch('/api/alerts?user=123').then(r => r.json());
// Total: ~540msAfter (parallel gRPC):
javascript
const [user, invoices, alerts] = await Promise.all([
userClient.getUser({ userId: '123' }),
invoiceClient.listInvoices({ userId: '123' }),
alertClient.listAlerts({ userId: '123' }),
]);
// Total: ~190msThree calls. One connection. All at once. Same results, roughly 65% faster.
Step 5: Keep REST on the Public Edge
We didn't touch our public API. It stayed REST.
Why? Because REST works beautifully in browsers. It's cacheable by CDNs. Our customers' tools already speak it. Switching our public API to gRPC would have meant a gRPC-Web proxy layer, extra tooling, and a wave of support tickets.
The rule we settled on:
Use the right protocol for the caller, not for the whole system.
Our customers got REST. Our browsers got REST. Our internal services got gRPC.
Read article on "10 AI Skills to Stay Relevant"
The Before-and-After Numbers
Here's what actually changed. These numbers are from our own production monitoring, not a benchmark suite.
Latency (Average Request Time)
Scenario | Before (REST) | After (gRPC + HTTP/2) | Improvement |
|---|---|---|---|
Single service call | 180ms | 92ms | -49% |
4-service dashboard | 720ms | 210ms | -71% |
7-service checkout flow | 1,260ms | 505ms | -60% |
Full user session load | ~2.4s | ~960ms | -60% |
Payload Size
Endpoint | Before (JSON) | After (Protobuf) | Improvement |
|---|---|---|---|
User lookup | 2.8 KB | 0.6 KB | -79% |
Invoice list | 14 KB | 3.2 KB | -77% |
Analytics snapshot | 47 KB | 9 KB | -81% |
CPU Usage
We also saw CPU usage on our gateway drop by roughly 18%. Less time parsing text means more CPU available for actual work.
What Broke Along the Way
It wasn't all smooth. Here are the real problems we hit.
Problem 1: gRPC Doesn't Work in Browsers
We knew this going in. gRPC needs HTTP/2 with full control over the connection, and browsers don't give you that. The fix is to use gRPC-Web or a proxy like Envoy.
We chose to keep our public API on REST and only use gRPC behind our gateway. That way browsers never had to know about gRPC at all.
Lesson: Keep gRPC on internal calls. Use REST or GraphQL for browsers.
Problem 2: Debugging Is Harder
REST payloads are human-readable. You can curl an endpoint, look at the JSON, and instantly know what happened.
gRPC payloads are binary. You can't read them with your eyes.
We solved this by installing grpcurl on every developer machine and adding it to our debugging runbooks:
bash
grpcurl -plaintext localhost:9090 user.v1.UserService/GetUserIt uses JSON encoding on the front end so you still see what's going on.
Lesson: Invest in debugging tools before you migrate. It pays off on day one.
Problem 3: Caching Was Different
REST gives you CDN caching "for free." Browsers, proxies, and CDNs cache based on URL and headers. That's not how gRPC works.
For internal gRPC calls, we had to add application-level caching in our gateway. It was more work upfront but gave us more control.
Lesson: Caching strategy has to be designed, not assumed.
Problem 4: The Team Had to Learn
Not everyone on our team had used Protocol Buffers before. We spent a half-day on a team workshop, then got everyone to migrate one small service as practice.
By the end of week two, everyone was comfortable.
Lesson: Plan for the learning curve. It's smaller than you think, but it's real.
Read article on "MongoDB Schema for Optimal Performance"
Where gRPC Actually Shines
Not every service is a good candidate. Here's when we found gRPC to be worth the effort.
Great for gRPC
Internal service-to-service calls (high volume, low latency needs)
Streaming data (real-time updates, log tailing, IoT feeds)
Polyglot environments (services written in different languages)
Strictly-typed contracts (you want compile-time safety)
Not Great for gRPC
Public APIs (customers expect REST or GraphQL)
Browser-facing calls (needs gRPC-Web or proxy)
Simple CRUD apps (REST is simpler and just as fast)
Anything CDN-cached (HTTP caching doesn't apply the same way)
If your app is small, REST is still the right choice. gRPC's value shows up at scale.
A Quick Comparison: When to Use What
Here's the mental model we use on our team now:
Use Case | Best Choice | Why |
|---|---|---|
Public customer API | REST | Works everywhere, cacheable, easy to test |
Web/mobile app UI | GraphQL or REST | Flexible queries, good browser support |
gRPC | Fast, typed, HTTP/2 multiplexing | |
Real-time streaming | gRPC | Native bidirectional streaming |
Simple internal crons | REST | Not worth the setup overhead |
The key insight: you don't have to pick one. Most modern teams (and ours) use all three where each makes sense.
What We Learned
If you're thinking about making a similar switch, here's the advice we wish we'd had.
Measure first. Don't migrate because gRPC is trendy. Profile your actual bottlenecks.
Keep REST at the public edge. Let gRPC live behind the gateway.
Start small. Migrate one low-risk service. Prove the gain. Then expand.
Parallel-run everything. Keep REST versions working until you're sure gRPC works.
Invest in tooling.
grpcurl, good logging, and clear error messages save hours.Write the
.protofiles carefully. They're your contract. Treat them like database schemas.Don't over-engineer caching. Start simple. Add caching only when you have data to prove it's needed.
"Simplicity is prerequisite for reliability.", Edsger W. Dijkstra
We came back to this quote a lot. gRPC isn't simple, but it made our system simpler because the contracts were clearer.
Should You Do This?
Honest answer: maybe.
You should consider gRPC if:
Your internal services talk to each other constantly
You're paying for that chatter in latency or bandwidth
You have multiple service teams that need typed contracts
You want streaming or real-time features
You should probably stay on REST if:
Your app is small
Your main bottleneck is the database, not service calls
Your team isn't ready for a new toolchain yet
Your API is public-facing and customers rely on it
For us, gRPC was worth it. It cut our response time by 60%, made our contracts safer, and gave us room to grow. But we didn't do it because it was trendy. We did it because our numbers said we needed to.




