All articles
23 September 2026
10 min read

REST or gRPC for backend services?

By Tim Rutte, Cloud & Software ArchitectTopicBackend & Platforms

On the left a white pegboard with a loosely fitted wooden dowel, on the right two aluminium blocks that interlock precisely along a blue dovetail groove.

For me, REST is not the automatic standard for every API. It is the standard many teams have stopped questioning. As soon as two backend services need to talk to each other, the same architecture appears almost every time: an HTTP endpoint, JSON in, JSON out, an OpenAPI file on top, done.

It works. The more interesting question is whether it is the right interface for this particular line of communication.

My default has become fairly clear: for a public or widely used API, I start with REST. For controlled communication between backend services, I look at gRPC first. Not because gRPC is more modern, but because internal services have different requirements from an API used by browsers, partners or unknown clients.

Two mindsets, not two formats

The debate is often framed as if REST and gRPC were two technical variants of the same thing. That falls short.

A REST API describes resources over HTTP. It is easy for people to understand, can be called with standard tools and needs no special client. A gRPC service, by contrast, starts with an explicit contract: methods and messages live in a .proto file, and client and server code is generated from it for every language in use. On top of the plain call, gRPC also knows client, server and bidirectional streaming.

That changes how I think about the interface. With REST, I think in resources:

GET   /customers/4711
POST  /payments
PATCH /subscriptions/815

With gRPC, I think in what a service can do:

service PaymentService {
  rpc AuthorizePayment(AuthorizePaymentRequest) returns (AuthorizePaymentResponse);
  rpc CapturePayment(CapturePaymentRequest)     returns (CapturePaymentResponse);
  rpc RefundPayment(RefundPaymentRequest)       returns (RefundPaymentResponse);
}

Neither is better in principle. But depending on who controls the interface and how it is used, one of them feels far more natural.

The advantage is the contract, not the speed

gRPC is often sold on speed. Protocol Buffers are compact, gRPC runs over HTTP/2, connections are reused, streaming is part of the model. For certain communication patterns that makes a noticeable difference.

Even so, performance is rarely my first reason. If serialization takes two milliseconds and the database call behind it takes eighty, switching protocols solves the wrong problem.

The bigger advantage lies elsewhere: the contract between the services gets harder. Take one service that needs information about a customer from another. With REST, the response looks roughly like this:

{
  "id": 4711,
  "status": "active",
  "limit": 5000
}

What does limit mean? Cents or euros? Daily limit, available limit, credit line? Of course it can be documented: with OpenAPI, with JSON Schema, with generated clients and contract tests. I do that too. With gRPC, though, the interface starts with the schema:

enum CustomerStatus {
  // Required in proto3 and deliberately meaningless: a missing
  // field then reads as "not set", not as "active".
  CUSTOMER_STATUS_UNSPECIFIED = 0;
  CUSTOMER_STATUS_ACTIVE = 1;
  CUSTOMER_STATUS_BLOCKED = 2;
}

message Customer {
  int64 id = 1;
  CustomerStatus status = 2;
  int64 credit_limit_cents = 3;
}

The difference is not that REST could not do this. The difference is that with gRPC it is much harder to avoid. When ten developers work across several backend systems, I do not want every client to develop its own idea of what a response means.

This matters even more when the interface changes. Failures in distributed systems rarely happen inside a service; they happen between services. An enum gains a new value, a field is renamed, one client reads null differently from the next, one team updates the server and another updates the client two weeks later. That is not an exotic case. That is normal operation.

Protocol Buffers force you to treat this as a contract change. Fields travel by number, not by name. Whoever replaces a field assigns a new number and reserves the old one:

message Customer {
  reserved 3;          // was: int64 limit. Never reuse.
  reserved "limit";

  int64 id = 1;
  CustomerStatus status = 2;
  int64 credit_limit_cents = 4;
}

A tool such as buf breaking compares the .proto files in CI against the last state and reports when a change would break existing clients. I can still break an interface; no tool prevents bad decisions. But the bar is higher, and for internal services exactly this friction is useful.

Where the boundary became code

I have used both, REST and gRPC, in several production backend systems, among them environments with Go services, legacy PHP, AWS and several million users.

Especially when extracting logic step by step from an existing system, the interface boundary was decisive. The new service was not supposed to simply access the same database tables. Otherwise I would not have built a service, only moved part of the monolith into another process. The new component needed a clear contract with the existing system.

That is exactly where gRPC is strong. Not because of a benchmark bar, but because "the payment service somehow needs this data" turns into an explicit interface:

rpc GetPaymentState(GetPaymentStateRequest) returns (GetPaymentStateResponse);

The message types are part of the contract, changes to them become visible, the clients are generated from them. The boundary becomes code. What that looked like in a payment system with millions of users is in the case study Payment backend for 6M users.

One caveat belongs here for PHP: as a gRPC client, PHP works well through the grpc extension. As a gRPC server it is not intended to run under PHP-FPM and needs its own runtime such as RoadRunner. In practice, the legacy system therefore usually calls the new Go service rather than the other way round, which tends to match the direction of the modernization anyway.

How Go services come into being next to an existing PHP application without rebuilding everything is covered in Go in a PHP world.

When I prefer REST

I would still not start replacing existing REST APIs with gRPC. In four cases REST is clearly the better choice for me.

Public APIs. If I do not control who uses the interface, REST almost always wins. An API for customers, integration partners or external developers should assume as little as possible. Practically any platform can send an HTTP request, almost anyone can read JSON, and a developer can try an endpoint with curl without generating code first. For public interfaces, accessibility is itself an architectural property.

The browser as a direct client. gRPC in the browser works through gRPC-Web, but usually needs a proxy that translates between the browser and a classic gRPC service. If a frontend simply needs to load data, I do not automatically put that in between. REST is the more boring solution there, and in architecture boring is often a compliment.

Small systems. With three endpoints between two applications, I do not need an interface definition language, code generation or extra build steps. I try not to anticipate architectural problems a system does not have yet.

Interfaces people inspect regularly. REST is transparent: copy the request, run curl, look at the JSON. For support, debugging or manual integrations, that is not to be underestimated. I therefore judge an admin endpoint or a partner API differently from an internal call that runs several million times a day between two services.

When I prefer gRPC

The decision tips towards gRPC when several conditions come together.

Both sides of the interface are ours. This is the most important precondition. When my team or my organization controls client and server, the universal accessibility of REST loses much of its weight. I do not have to optimize for every conceivable consumer; I build the interface for exactly the systems that talk to each other. That is where a strict contract becomes valuable.

There are many calls between services. A single REST call hardly matters. With a very large number of internal calls, connections, serialization, payload size, timeouts, retries and observability become part of the architecture. For exactly this service-to-service communication, gRPC brings a lot of infrastructure with it.

Several languages meet. I find this case particularly interesting in legacy modernization. The existing system is PHP, new components are written in Go, and later a Python service might join. Then I do not want to maintain three hand-written implementations of the same contract. The .proto file becomes the common denominator. PHP does not decide the interface, and neither does Go. The contract decides.

Streaming really is part of the problem. When data does not flow as a single request with a single response, REST becomes awkward. That does not mean I use streaming everywhere, quite the opposite: long-running streams change load balancing, error handling and debugging, and even the gRPC documentation advises using streaming only when it brings a real benefit to the application or to performance. But when it belongs to the problem, gRPC spares me from bolting it onto an interface that only ever knew request and response.

Good REST beats bad gRPC

Let me clear up one misunderstanding straight away: a bad REST API is not an argument for gRPC.

REST can be built very cleanly. OpenAPI provides a formal contract, clients can be generated from it, schemas get validated, versioning can be run with discipline, and contract tests safeguard changes. A good REST system is better than a badly built gRPC system.

So the real question is not which technology forces me into good architecture. None does. It is: which technology makes the way I want to work the natural path? For internal, closely coupled service communication, I often like gRPC's answer better.

The network stays unreliable

Whichever protocol I choose, at one point the architecture stays equally hard. A service can answer. It can fail to answer. It can answer after the client has long given up. The client can lose its connection although the server has already carried out the operation. A retry can therefore trigger a second payment.

gRPC does not change that. It does bring status codes, deadlines, cancellation and metadata as fixed concepts, but deadlines have to be set deliberately. Without one, a client can wait for a response indefinitely, and that is the default.

ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()

resp, err := client.AuthorizePayment(ctx, &paymentv1.AuthorizePaymentRequest{
    IdempotencyKey: order.ID,
    AmountCents:    order.AmountCents,
})
if status.Code(err) == codes.DeadlineExceeded {
    // Do not retry blindly: the payment may have gone through anyway.
    // Ask again with the same key; the server recognizes the operation
    // and returns the first result instead of booking a second one.
}

The real work stays with the application:

  • Which operation is idempotent, and what may be retried?
  • Which deadline makes sense for the business, and what happens after a timeout?
  • Which trace ID crosses the service boundary?
  • How do I find out whether the operation was carried out on the other side after all?

These are more important questions than JSON versus Protobuf. Whoever leaves them unanswered only gets a faster distributed problem with gRPC.

Then there is visibility. With REST, simple tools show a lot: URL, method, headers, JSON, status code. gRPC is binary and works through generated clients and specialized tools such as grpcurl. In operation that is no problem as long as observability is in place; otherwise it becomes one. For me, a gRPC service therefore comes with structured logs, tracing across service boundaries, per-method metrics with latencies and error rates, and a clear correlation between calling and called service from day one. The less readable the wire, the better the observability has to be.

How tracing is introduced across a PHP and a Go side is covered in Introducing OpenTelemetry in PHP and Go.

REST outside, gRPC inside

An architecture can use REST and gRPC at the same time, and that is often my preferred solution. Especially with grown systems, I see little point in pushing a new technology through everywhere. If a PHP monolith has a working REST API, I do not replace it just because a Go service appears next to it. That would be busywork. Instead I ask which new boundary is emerging:

Browser / partner / customer
          |
        REST          public boundary: accessibility
          |
   PHP application
          |
        gRPC          internal boundary: contract
          |
 Go payment service
          |
        gRPC
          |
  Go account service

That is not inconsistency. These are two boundaries with different requirements. The public one optimizes for compatibility and accessibility, the internal one for an explicit contract, controlled clients and reliable communication. The existing system keeps its outside world while a new, clearer service boundary forms inside.

Whether an internal boundary should be a direct call at all, or an event, is the question that comes first: Synchronous or event-driven?

To me, that is much closer to sensible modernization than a project whose goal is "we are moving our APIs to gRPC now". Technology is not a modernization goal. A better boundary can be one.

How a stable external interface is put in front of a legacy system is covered in Putting an API in front of the monolith.

The decision as a table

This is how I would actually use the comparison for a decision:

QuestionRESTgRPC
Public APIusually the first choiceonly with good reason
Internal service-to-service communicationperfectly possibleusually the first thing I check
Browser as clientvery goodonly via gRPC-Web and a proxy
Directly readable by peoplevery goodworse
Contractvery achievable with OpenAPIa core component
Client generationpossiblea natural part of the workflow
Several languages in the backendgoodvery strong
Streamingneeds additional conceptspart of the model
Very many internal callsworksoften more attractive
Debugging with simple toolsexcellentmore effort
Connecting a legacy systemoften easier as the first boundarystrong between new, controlled services
Experience for external developersvery strongusually an unnecessary hurdle

The table is still only the start. The decision is made at the system boundary, not in the row.

When I deliberately do not choose gRPC

Even though I like gRPC for internal backend communication, there are clear counter-indications. I do not introduce it when

  • practically all calls come from browsers,
  • external developers consume the API,
  • the team has no experience with the extra tooling and gains nothing from it,
  • only two or three simple endpoints exist,
  • the existing REST interface is cleanly specified and stable,
  • the only reason is "performance" and nobody has measured a performance problem.

The last point matters to me. An architecture is not supposed to win a benchmark; it is supposed to solve a problem.

The first question

When I build a new backend service today, I ask one question first: who controls both sides of this interface?

If the answer is "we do", gRPC becomes interesting, and I keep checking: how often is it called? How many clients are there, in how many languages? How important is a hard contract? Do we need streaming? And what do observability and deployment look like?

For an internal interface between controlled backend services, especially in Go, I take gRPC very seriously today and often choose it. For an interface for browsers, partners, customers or unknown future consumers, I mostly stay with REST.

Not because REST is old and gRPC is new. Not because JSON is slow and Protobuf is fast. But because the boundaries have different requirements. Public APIs need openness; internal APIs above all need a contract that holds. That is what I choose the protocol by.

If you are standing at exactly this boundary right now, the approach is described on the pages for Go services alongside PHP and backend development.