Performance Benchmarking: gRPC+Protobuf vs. HTTP+JSON
A fair benchmark with Go examples to compare Protocol Buffers over gRPC vs. JSON over HTTP/1 and HTTP/2.
While human-readable JSON over HTTP remains a popular choice for service communication due to its simplicity and familiarity, in Microservices architectures gRPC is emerging as a popular choice for communication.
It is mainly because in the case of internal services, the structured formats, such as Protocol Buffers, are a better choice than JSON for encoding data.
So we wanted to experiment with performance benchmarking of 2 types of communication in Go. It's important to note that results might vary across languages due to implementation specifics.
Comparing Apples to Apples
To isolate the performance impact of data transport and serialization protocols, we designed both the gRPC and HTTP endpoints to avoid any extraneous operations like database calls or memory-intensive computations. By minimizing the function's footprint, we ensure that the benchmark primarily reflects the performance differences between Protobuf over gRPC and JSON over HTTP.
It’s important to note that this benchmark was conducted on my local machine, providing a relative comparison of gRPC and HTTP performance. Real-world performance may vary depending on hardware, network conditions, and specific workloads.
Also this benchmark is a starting point for exploration and is only valid for this tiny example.
gRPC Service
Our gRPC service will have a single procedure CreateUser that accepts user information as input and returns some generic response. We will mimic the same format in our HTTP+JSON server as well.
grpc/users.proto
syntax = "proto3";
option go_package = "grpc/gen";
service Users {
rpc CreateUser(User) returns (CreateUserResponse) {}
}
message User {
string id = 1;
string email = 2;
string name = 3;
}
message CreateUserResponse {
string message = 1;
uint64 code = 2;
User user = 3;
}
Now, let's use the protoc command to create the building blocks for our service. This includes generating the Go code for both the server (unimplemented) and the client from our users.proto file.
protoc -I./grpc --go_out=. --go-grpc_out=. users.proto
Now we can proceed with the implementation of our gRCP server (UnimplementedUsersServer). Remember, the CreateUser function doesn’t do much, it just takes the request and returns a static response.
See the full source code here.
grpc/server.go
package grpc_server
import (
"context"
"github.com/plutov/packagemain/benchmark-http-grpc/grpc/gen"
)
type Server struct {
gen.UnimplementedUsersServer
}
func (s *Server) CreateUser(ctx context.Context, user *gen.User) (*gen.CreateUserResponse, error) {
return &gen.CreateUserResponse{
Message: "ok",
Code: 201,
User: user,
}, nil
}
HTTP Service
Let’s do the same for JSON over HTTP.
http/server.go
package http_server
import (
"encoding/json"
"net/http"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
type Response struct {
Message string `json:"message"`
Code uint64 `json:"code"`
User *User `json:"user"`
}
func CreateUser(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var user User
json.NewDecoder(r.Body).Decode(&user)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Response{
Code: 201,
Message: "ok",
User: &user,
})
}
Benchmarking
Now that we have two servers we can write few benchmarks using Go’s testing package. We will do 3 benchmarks:
gRPC. Server port: 60000
HTTP/1. Server port: 60001
HTTP/2. Server port: 60001
benchmark_test.go
package benchmark_test
func init() {
go grpc_server.Start()
go http_server.StartHTTP1()
go http_server.StartHTTP2()
}
func BenchmarkGRPCProtobuf(b *testing.B) {
// ...
}
func BenchmarkHTTP1JSON(b *testing.B) {
// ...
}
func BenchmarkHTTP2JSON(b *testing.B) {
// ...
}
See the full source code here to see how the tests are written.
Results
This benchmark was conducted on my local machine with the following configuration:
Go version: 1.22
Apple M1 Pro, 16Gi
The results are very interesting!
HTTP/2 (H2C) is slower than gRPC, and gRPC is slower than HTTP/1!
It is a known issue that HTTP/2 is slower than HTTP/1 in Go and documented here.
Conclusion
Our benchmarks revealed some intriguing results that challenge common assumptions. Contrary to expectations, HTTP/2 with H2C exhibited slower performance than gRPC in this specific scenario. Interestingly, gRPC itself also showed slightly slower performance compared to HTTP/1.
Still, Protocol Buffers over gRPC is a great option for inter-service communication due to its structured format.
why not try to benchmark in another languages if there's a known issue in Go?
I think you need to put an ample warning at the beginning of this article that this benchmark is only valid for such a small toy example, and it's not indicative of the actual performance of the protocols for a more common larger request body.
gRPC is famous for how fast the binary encoding and data transmission over long distances when compared to simple text encoding.
This is more apparent for larger requests and longer transmission.
Also, HTTP2 uses gRPC under the hood.
So it's only normal that if you found a toy example when gRPC is slower than HTTP1; even HTTP2 will be slower than HTTP1.
My experience tells me that in more common use cases, gRPC is faster than HTTP2 with JSON body, which is faster than HTTP1.
Another reason HTTP2 is faster than HTTP1 is the multiplexing of multiple requests over the same connection. HTTP2 solves the famous head of line blocking problem, but it's not tested in this benchmark.
A better benchmark for HTTP2 would have been the loading time of a web page with lots of resources from the same domain.