What gRPC-Gateway and Connect are and why the difference matters

gRPC-Gateway is a protoc plugin that generates a reverse proxy. You annotate RPC methods with HTTP rules (google.api.http), and the generated code maps routes such as GET /v1/users/{id} onto gRPC calls. It decodes the JSON body and path parameters into the request message, invokes the gRPC method, and encodes the response back to JSON. The gRPC service itself does not change.

Connect is a protocol family with generated handlers, best known through its Go implementation. A single Connect handler speaks three protocols on one endpoint: the Connect protocol (plain HTTP POST with JSON or binary protobuf bodies), gRPC, and gRPC-Web. No translation layer sits in between. The handler answers each protocol directly.

gRPC-Gateway
  JSON/REST client --> gateway handler --(gRPC over network)--> gRPC server
                                       \--(in-process call)---> service impl

Connect
  JSON client     --\
  gRPC client     ----> Connect handler --> service impl
  gRPC-Web client --/

Teams pick either tool for the same reason. They want one protobuf contract to serve internal gRPC callers and external JSON callers, without maintaining a separate REST layer by hand. Both tools achieve that, but they produce different public APIs.

gRPC-Gateway gives you a REST-shaped API, with resource paths, HTTP verbs and query parameters. Connect gives you an RPC-shaped API, where every call is a POST to /package.Service/Method. So this article answers one main question: does your external contract need to look like REST, or can it look like RPC? The rest of the comparison depends on that answer.

gRPC-Gateway: REST-shaped API (e.g. GET /v1/users/{id})

+-----------+  JSON   +-----------------+ gRPC/network +-------------+
| JSON/REST |-------->| gateway handler |------------->| gRPC server |
|  client   |         |  (generated     |              +-------------+
+-----------+         | reverse proxy)  |
                      +--------+--------+
                               |
                               |  in-process call      +--------------+
                               +---------------------->| service impl |
                                                       +--------------+

Connect: RPC-shaped API (POST /package.Service/Method)

+-----------------+
| JSON client     |-- Connect proto -+
+-----------------+                  |
+-----------------+                  |  +-----------------+   +--------------+
| gRPC client     |-- gRPC ----------+->| Connect handler |-->| service impl |
+-----------------+                  |  +-----------------+   +--------------+
+-----------------+                  |
| gRPC-Web client |-- gRPC-Web ------+
+-----------------+

gRPC-Gateway translates REST/JSON requests into gRPC calls, made over the network or in-process, while a single Connect handler serves Connect, gRPC and gRPC-Web clients directly on one endpoint.

How it works: REST transcoding vs RPC over plain HTTP

With gRPC-Gateway, the REST shape comes from google.api.http annotations on each RPC. An annotation names an HTTP verb and a path template, such as get: "/v1/{name=users/*}". Path variables bind to request fields by name. The body field controls what is read from the JSON payload: body: "*" maps the whole request message, body: "user" maps a single field, and omitting it means no body. Request fields not bound by the path or body become query parameters. The generated gateway is a reverse proxy that parses the HTTP request, builds the protobuf message, calls your gRPC server, and translates the response back to JSON.

The annotations belong to the proto contract, not to gRPC-Gateway. Other transcoders that understand google.api.http, including proxy-based ones, can read the same rules. This lets you change how transcoding is deployed later without rewriting the API surface.

Connect does not translate anything. A unary call is a POST to /acme.user.v1.UserService/GetUser. The body is either JSON (Content-Type: application/json) or binary protobuf (application/proto), and the response carries an ordinary HTTP status code derived from the RPC error code. Because the server speaks this protocol natively, no proxy is involved. Methods marked as having no side effects can also be called with GET, with the message encoded in the query string. Responses to those calls can then be cached by standard HTTP caches.

The difference is easiest to see in the curl requests. A gateway client sends curl a URL shaped around the resource. A Connect client POSTs {"name":"users/42"}` to the method path, a URL shaped around the operation.

// user.proto: the HTTP mapping is part of the contract
syntax = "proto3";
package acme.user.v1;

import "google/api/annotations.proto";

service UserService {
  rpc GetUser(GetUserRequest) returns (User) {
    // Transcoders read this: GET /v1/users/42 sets name = "users/42"
    option (google.api.http) = { get: "/v1/{name=users/*}" };
    // Connect reads this: the method may also be called with GET
    option idempotency_level = NO_SIDE_EFFECTS;
  }
  rpc UpdateUser(UpdateUserRequest) returns (User) {
    // user.name comes from the path, and the JSON body fills only the user field
    option (google.api.http) = {
      patch: "/v1/{user.name=users/*}"
      body: "user"
    };
  }
}

message GetUserRequest { string name = 1; }
message UpdateUserRequest { User user = 1; }
message User { string name = 1; string display_name = 2; }

# gRPC-Gateway: resource-shaped URL, translated by the proxy into a gRPC call
curl

curl -X PATCH  \
  -H 'Content-Type: application/json' \
  -d '{"displayName":"Ada"}'

# Connect: operation-shaped URL, handled natively by the server with no proxy
curl -X POST  \
  -H 'Content-Type: application/json' \
  -H 'Connect-Protocol-Version: 1' \
  -d '{"name":"users/42"}'
# A missing user returns a plain HTTP 404 with a JSON error body

One annotated proto method called two ways: gRPC-Gateway maps it to a resource-shaped REST URL, and Connect POSTs the JSON message to the /package.Service/Method path.

Protobuf JSON and error formats: the behaviour clients actually see

Both tools serialise messages with protobuf's canonical JSON mapping (protojson), so the rules below apply whichever one you choose. Consider a User with an int64 id, an enum, a Timestamp and a login_count of zero, returned as {"id":"9007199254740993","displayName":"Ada","status":"USER_STATUS_ACTIVE","createdAt":"2026-01-05T09:30:00Z"}. The id is a quoted string, and loginCount is missing entirely.

Rule Default behaviour What breaks for clients
Field names lowerCamelCase (display_name → displayName) Partners expecting snake_case from the .proto file
64-bit integers Encoded as strings Code doing id + 1 or strict schema validation on type
Enums Value names, not numbers Clients switching on integers
Timestamp / Duration RFC 3339 string / "1.5s" Parsers expecting epoch numbers
Default values Omitted user.loginCount is undefined, not 0
Unknown fields on input Rejected unless discarding is enabled Older servers reject requests from newer clients

The int64 rule exists because JavaScript numbers lose precision above 2^53. If a client converts the string with Number(), it silently gets a different ID. Defaults can differ between the two tools and between their versions. Set the marshal and unmarshal options explicitly, and add contract tests that decode real responses in each client language.

Both tools map gRPC status codes to HTTP statuses, so NOT_FOUND becomes 404, and both put error details in the response body. The JSON shapes differ, though. gRPC-Gateway serialises google.rpc.Status, with a numeric code such as 5. Connect uses a string code such as "not_found", and each detail carries a type and a base64 value. Client error handling written for one tool will not parse errors from the other.

// Response body from the JSON endpoint, using protojson defaults
const body = '{"id":"9007199254740993","displayName":"Ada","status":"USER_STATUS_ACTIVE","createdAt":"2026-01-05T09:30:00Z"}';

interface UserJson {
  id: string;          // int64 is sent as a string
  displayName?: string;
  status?: string;     // enum value name, not a number
  createdAt?: string;  // RFC 3339 Timestamp
  loginCount?: number; // int32, left out when the value is 0
}

const user = JSON.parse(body) as UserJson;

console.log(Number(user.id)); // 9007199254740992: wrong ID, no error
const id = BigInt(user.id);   // 9007199254740993n: exact
console.log(user.loginCount); // undefined, not 0
const loginCount = user.loginCount ?? 0; // restore the proto default
const createdAt = user.createdAt ? new Date(user.createdAt) : undefined;

// Error bodies differ: gRPC-Gateway sends {"code":5,...}, Connect sends {"code":"not_found",...}
const GRPC_CODE_NAMES: Record<number, string> = {
  3: "invalid_argument", 5: "not_found", 7: "permission_denied", 16: "unauthenticated",
};

function errorCode(err: { code?: number | string }): string {
  if (typeof err.code === "number") return GRPC_CODE_NAMES[err.code] ?? "unknown";
  return err.code ?? "unknown";
}

console.log(errorCode({ code: 5 }));           // gRPC-Gateway shape -> "not_found"
console.log(errorCode({ code: "not_found" })); // Connect shape -> "not_found"
console.log(id, loginCount, createdAt);

A TypeScript client reads a default protojson response. It keeps the int64 ID exact with BigInt, restores the omitted zero-valued field, and converts both tools' error code formats to one form.

Streaming, OpenAPI and middleware compared

Streaming is where the two tools differ most. gRPC-Gateway maps a REST call onto a unary or server-streaming RPC, so it has no REST equivalent for client or bidirectional streams. Connect keeps the RPC model end to end, so streaming survives translation. However, it is still limited by the transport underneath it.

Concern gRPC-Gateway Connect
Server streaming Newline-delimited JSON, each message wrapped in a result envelope Native in the Connect protocol, using enveloped messages
Client and bidi streaming Not available over REST; clients must use native gRPC Supported; bidi requires HTTP/2 end to end
Browsers Any HTTP client can read the NDJSON stream Server streaming works over Fetch; client and bidi streaming are limited by Fetch request-body streaming
API documentation OpenAPI generated from the same google.api.http annotations Usually generated typed clients; OpenAPI needs extra tooling
Middleware layers HTTP middleware on the gateway mux, plus gRPC interceptors on the server HTTP middleware, plus Connect interceptors that apply to all three protocols

Documentation follows from each design. Gateway annotations already describe paths and verbs, so generating an OpenAPI document from them costs little. Connect URLs are derived from the RPC names, so teams usually ship generated clients instead of a hand-browsable spec.

Middleware is the most common source of bugs. A gateway that dials the gRPC server over a connection passes through HTTP middleware and then gRPC interceptors, so auth and metrics can run twice. A gateway registered in-process calls the service implementation directly and skips gRPC interceptors entirely. Connect interceptors cover gRPC, gRPC-Web and Connect requests in one chain, but a separate grpc-go server running alongside it has its own chain.

Streaming and generator coverage varies by language and runtime, so verify each one your clients use.

Implementation and deployment topologies

Three shapes

Separate gateway process. The gateway runs as its own deployment and dials the gRPC server. Clients hit it over HTTP/1.1 or HTTP/2, and it forwards to the backend over HTTP/2. You get two hops, two sets of metrics, and an extra service to scale and patch.

In-process gateway. The gateway mux and the grpc-go server live in one binary, usually on separate ports. Only the gRPC port needs HTTP/2.

Single Connect handler. One net/http server on one port answers gRPC, gRPC-Web and Connect. It needs HTTP/2 for gRPC clients. Without TLS, that means enabling h2c.

Topology Processes / ports HTTP/2 requirement TLS termination Observability
Separate gateway 2 / 2+ Gateway→backend, gRPC clients Edge, plus gateway→backend if encrypted Two hops; propagate trace context
In-process gateway 1 / usually 2 gRPC port only Per port One process, two interceptor chains
Connect handler 1 / 1 Whole path for gRPC clients Once, at edge or server One net/http middleware chain

A load balancer or ingress that downgrades upstream traffic to HTTP/1.1 breaks gRPC calls. JSON and Connect unary calls keep working through it. If your tests only use curl, the deployment looks healthy until the first gRPC client connects. Add a native gRPC health check behind the real proxy.

Wiring sketch

Generate code with buf generate or protoc. Use protoc-gen-go plus one of these plugin sets:

  • Gateway: protoc-gen-go-grpc and protoc-gen-grpc-gateway. Then call runtime.NewServeMux(). Register handlers in-process with RegisterXHandlerServer, or through the network with RegisterXHandlerFromEndpoint.
  • Connect: protoc-gen-connect-go. The generated NewXHandler returns a path and an http.Handler. Mount both on an http.ServeMux.
#!/usr/bin/env bash
set -euo pipefail

# --- Code generation (protoc; buf generate with buf.gen.yaml is equivalent) ---
# Messages are always generated by protoc-gen-go.
# Gateway path: grpc-go stubs plus reverse-proxy handlers.
# third_party/ must contain google/api/annotations.proto and http.proto.
protoc -I proto -I third_party \
  --go_out=gen --go_opt=paths=source_relative \
  --go-grpc_out=gen --go-grpc_opt=paths=source_relative \
  --grpc-gateway_out=gen --grpc-gateway_opt=paths=source_relative \
  proto/greet/v1/greet.proto

# Connect path: one plugin. The generated handler serves gRPC, gRPC-Web and Connect.
protoc -I proto \
  --go_out=gen --go_opt=paths=source_relative \
  --connect-go_out=gen --connect-go_opt=paths=source_relative \
  proto/greet/v1/greet.proto

# --- Smoke tests run THROUGH the real load balancer, not against the pod ---
HOST=api.example.internal

# 1) JSON over HTTP/1.1. This passes even if the proxy downgrades to HTTP/1.1.
curl --http1.1 -sS -X POST "" \
  -H 'Content-Type: application/json' -d '{"name":"test"}'

# 2) Native gRPC. This fails if any hop downgrades to HTTP/1.1.
# The server must register grpc.health.v1 and enable server reflection.
grpcurl "${HOST}:443" grpc.health.v1.Health/Check

# 3) Confirm the edge negotiated HTTP/2. This should print 2.
curl -sS -o /dev/null -w '%{http_version}\n' ""

Generates gateway and Connect stubs with protoc, then checks deployment through the real proxy. A JSON call alone can pass while gRPC is broken, so the script also runs a native gRPC health check and an HTTP/2 negotiation check.

When to use each, when to avoid them, and the alternatives

Choose gRPC-Gateway when the REST contract is the product. If partners expect resource URLs, standard verbs and a published OpenAPI document, the google.api.http annotations give you that without a second codebase. It also fits retrofits: an existing gRPC server gains a JSON front door without changing its handlers.

Avoid it when the mapping starts to bend the proto. Nested resources, field-mask partial updates and non-CRUD verbs such as :cancel or :batchGet all have workable annotations. Each one adds review effort, though, and leaves room for the gateway and the backend to drift apart. If you find yourself reshaping messages so the URLs look right, the translation layer is costing more than it saves.

Choose Connect when you own the clients. Web, mobile and internal callers get generated, type-safe stubs, and every method is a plain POST, so there are no route templates to maintain. Avoid it when outside consumers expect conventional REST resources, or when you depend on HTTP caching keyed on GET URLs and ETags.

Alternatives

  • Plain gRPC only: the simplest option when every caller can speak HTTP/2 gRPC.
  • Proxy-based transcoders: these read the same google.api.http annotations, which moves the translation step out of your binary.
  • Both together: mount Connect handlers for your own clients and gRPC-Gateway routes for partners, all from one proto.
  • Hand-written REST: worth the cost only when the public API deliberately differs from the internal model.
Requirement Recommendation
Public REST resources plus OpenAPI gRPC-Gateway
Existing gRPC server, JSON needed quickly gRPC-Gateway or a proxy transcoder
First-party web and mobile clients Connect
Browser streaming without an extra proxy Connect
HTTP caching on resource URLs gRPC-Gateway or hand-written REST
Internal service-to-service traffic only Plain gRPC
Partners and in-house clients Connect and gRPC-Gateway together

Impact on development cost and engineering time

The recurring cost of gRPC-Gateway is operational. The gateway is a separate component with its own release cadence, dashboards and alerts, and it must be regenerated and redeployed whenever the proto changes. Every request is decoded from JSON and re-encoded as protobuf. If the gateway runs as its own process, the request also crosses an extra network hop. The design cost is easier to miss. Every RPC needs an HTTP mapping, so the team designs a resource-oriented REST API and an RPC API at the same time, and reviews both.

Connect shifts the cost onto consumers. Teams that expect resource URLs and standard HTTP verbs push back on POST requests to method paths. If you respond by adding a REST facade, you have rebuilt the transcoding layer you chose Connect to avoid.

Switching later is the expensive case. Moving from gRPC-Gateway to Connect changes URLs and error bodies for every REST consumer. Moving from Connect to gRPC-Gateway means annotating every RPC.

Illustrative estimate (assumed numbers, not a benchmark): annotation effort = RPC count × minutes per mapping × (1 + review overhead). With 120 RPCs, 20 minutes each and 25% review overhead: 120 × 20 × 1.25 = 3,000 minutes, or about 50 hours. That figure covers the annotations only, not migrating any clients.

Both approaches save time through generated clients and a single proto contract. gRPC-Gateway adds two more savings. Its HTTP annotations are portable to other transcoders, and it can publish OpenAPI for consumers who never see the proto.

Make the decision based on who consumes the API.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to grpc gateway vs connect, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.