ConnectRPC + TypeScript: Running Protocol Buffers-Based Type-Safe RPC on Both HTTP/1.1 and HTTP/2
After running microservices for a while, the limitations of REST APIs gradually surface. The experience of manually syncing OpenAPI specs only to have them drift from frontend types, the memory of casting fetch responses to any... I went through that cycle myself before eventually evaluating gRPC — but when I actually tried to adopt it, the Envoy proxy configuration, forced HTTP/2, and browser client support complexity were far greater than expected.
ConnectRPC sits at that balance point. This framework, developed by the Buf team, carries over the benefits of Protobuf's binary serialization and automatic code generation while stripping away gRPC's chronic weaknesses — HTTP/2-only binary framing and Envoy dependency. It supports HTTP/1.1 and HTTP/2 simultaneously, and you can debug unary RPCs directly with curl or Postman. With the official release of Connect-ES 2.0, official support for Fastify 5, Express 5, and Next.js 15 App Router is now in place, and production validation continues to accumulate.
This article summarizes practical experience from writing .proto schemas, implementing a Fastify server, connecting a type-safe client, and incrementally migrating from REST to ConnectRPC. All code examples are based on the Connect-ES v2 API, where, unlike v1, messages are treated as plain TypeScript objects rather than class instances.
Core Concepts
The gRPC Wall, and How ConnectRPC Clears It
gRPC is powerful. Protobuf serialization, bidirectional streaming, multi-language code generation — it covers nearly everything you'd want for inter-microservice communication. But the moment you try to connect directly from a web browser to a gRPC server, a wall appears. Because browsers don't support HTTP/2 trailing headers, you need the separate gRPC-Web protocol and an Envoy proxy. Infrastructure costs and operational complexity spike immediately.
ConnectRPC solves this problem at the protocol layer. It supports three protocols simultaneously, and among them the Connect Protocol is based on simple HTTP POST, so it works fine in HTTP/1.1 environments.
| Protocol | Transport | HTTP Version | Characteristics |
|---|---|---|---|
| Connect Protocol | POST | HTTP/1.1 & HTTP/2 both | Supports both JSON & binary, default choice |
| gRPC Protocol | POST | HTTP/2 only | Full compatibility with standard gRPC servers & clients |
| gRPC-Web Protocol | POST | HTTP/1.1 & HTTP/2 both | Use gRPC-Web directly from the browser without Envoy |
A Connect server handles all three protocols simultaneously. The client declares which protocol to use via a header, and the server responds accordingly. Existing gRPC clients and newly created Connect TypeScript clients can share the same server endpoint.
The Core Change in Connect-ES 2.0: Messages Go from Classes to Plain Objects
In Connect-ES v1, Protobuf messages were class instances. You created them like new SayHelloRequest({ name: "World" }) and serialized them with .toBinary(). In v2, messages become plain TypeScript objects. This makes them far more natural to pass as React state or serialize to JSON, leaving only types behind without unnecessary class hierarchies.
// v1 style — class instance (no longer recommended)
const req = new SayHelloRequest({ name: "World" });
// v2 style — plain object
import { create } from "@bufbuild/protobuf";
import { SayHelloRequestSchema } from "./gen/greet/v1/greet_pb.js";
const req = create(SayHelloRequestSchema, { name: "World" });
// req is just a plain object of the form { name: "World" }The code generation workflow has also been greatly simplified. In v1, message types and service definitions were generated separately with different plugins (protoc-gen-connect-es), but in v2 they are consolidated into a single protoc-gen-es. Message types and service definitions are generated together in the same *_pb.ts file, and the _connect.ts file and protoc-gen-connect-es plugin have been deprecated in v2.
Practical Application
1. Project Setup: From .proto Definition to Type Generation
Install the core packages. Add framework-specific packages (@connectrpc/connect-fastify, @connectrpc/connect-express, @connectrpc/connect-web) according to your environment. The Buf CLI can be installed globally or run via npx buf.
npm install @connectrpc/connect @connectrpc/connect-node @bufbuild/protobuf
npm install -D @bufbuild/protoc-gen-esWrite the .proto file that holds your service contract. In REST terms, this is the contract document equivalent to an OpenAPI spec.
// proto/greet/v1/greet.proto
syntax = "proto3";
package greet.v1;
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
string greeting = 1;
}
message SayHelloStreamRequest {
string name = 1;
int32 count = 2;
}
service GreetService {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
rpc SayHelloStream (SayHelloStreamRequest) returns (stream SayHelloResponse);
}Write the code generation config file at the root. In v2, a single plugin is all you need on the TypeScript side.
# buf.gen.yaml
version: v2
plugins:
- remote: buf.build/bufbuild/es:v2
out: src/genRunning npx buf generate produces a single src/gen/greet/v1/greet_pb.ts file containing both message types and service definitions.
npx buf generateBundling this step into your CI/CD pipeline ensures type regeneration happens automatically whenever the schema changes.
npx buf lint && npx buf generate && git diff --exit-code src/gen/2. Fastify Server Implementation
Using the Fastify 5 plugin makes wiring extremely straightforward. From v2 onward, service definitions including GreetService are imported directly from greet_pb.js. In the service implementation function, both request and response are inferred with the generated TypeScript types, and return values are plain object literals.
// src/server.ts
import { fastify } from "fastify";
import { fastifyConnectPlugin } from "@connectrpc/connect-fastify";
import { ConnectRouter } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const routes = (router: ConnectRouter) => {
router.service(GreetService, {
async sayHello(req) {
// req.name is inferred as string by TypeScript
return { greeting: `Hello, ${req.name}!` };
},
async *sayHelloStream(req) {
for (let i = 0; i < req.count; i++) {
yield { greeting: `Greeting ${i + 1} for ${req.name}` };
// Demo delay — replace with real async work in production
await new Promise((r) => setTimeout(r, 500));
}
},
});
};
// Works perfectly with http2: false — no Envoy needed
const server = fastify({ http2: false });
await server.register(fastifyConnectPlugin, { routes });
await server.listen({ port: 3000 });Being able to verify with curl right after starting the server is a clear advantage over gRPC. You can debug during development without a separate gRPC client.
curl -X POST http://localhost:3000/greet.v1.GreetService/SayHello \
-H "Content-Type: application/json" \
-d '{"name": "ConnectRPC"}'
# Response: {"greeting":"Hello, ConnectRPC!"}3. Type-Safe Client Connection
Browser Client (React / Next.js)
// src/client.ts
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const transport = createConnectTransport({
baseUrl: "http://localhost:3000",
});
const client = createClient(GreetService, transport);
// Full autocomplete and type inference in the IDE
const res = await client.sayHello({ name: "World" });
console.log(res.greeting); // inferred as stringConsuming Server Streaming — Works on HTTP/1.1 Too
for await (const chunk of client.sayHelloStream({ name: "World", count: 3 })) {
console.log(chunk.greeting);
}Streaming responses can be consumed with for await...of, eliminating the need to set up WebSocket or SSE separately.
Node.js Service-to-Service Communication
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-node";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const transport = createConnectTransport({
baseUrl: "http://greet-service:3000",
httpVersion: "2", // optimize performance with HTTP/2 for internal services
});
const client = createClient(GreetService, transport);External clients on HTTP/1.1, internal service communication on HTTP/2 — leave the server code as-is and just change the client configuration.
4. Incremental Migration from REST to ConnectRPC
The most realistic migration approach is running REST and Connect endpoints in parallel while moving services one by one. Trying to switch everything at once will inevitably break something.
There is a key point to watch when running both approaches in parallel on an Express server. The Connect middleware parses request bodies on its own, so registering express.json() globally first causes the body to be consumed twice, resulting in a conflict. Register the Connect middleware before the body parser, and for REST routes that need JSON parsing, specify the middleware at the route level.
// src/app.ts — Express 5 + Connect running in parallel
import express from "express";
import { expressConnectMiddleware } from "@connectrpc/connect-express";
import { ConnectRouter } from "@connectrpc/connect";
import { GreetService } from "./gen/greet/v1/greet_pb.js";
const app = express();
// Register Connect middleware before the body parser
app.use(
expressConnectMiddleware({
routes(router: ConnectRouter) {
router.service(GreetService, {
async sayHello(req) {
return { greeting: `Hello, ${req.name}!` };
},
});
},
})
);
// REST router — only reached for requests Connect did not handle
app.get("/api/greet/:name", (req, res) => {
res.json({ greeting: `Hello, ${req.params.name}!` });
});
// For cases that need body parsing like a REST POST, specify at the route level
// app.post("/api/something", express.json(), handler);
app.listen(3000);Once the client team has completed the switch to the new Connect client, remove the REST router. Proceeding service by service distributes the risk.
5. Polyglot Setup: Go Backend + TypeScript Frontend
Generating both Go server types and TypeScript client types from a single .proto file is the core value of ConnectRPC's polyglot combination. In v2, Go still uses the connectrpc/go plugin separately, while TypeScript is handled by bufbuild/es:v2 alone.
# buf.gen.yaml — generating Go + TypeScript simultaneously
version: v2
plugins:
# Go server code
- remote: buf.build/protocolbuffers/go
out: go/gen
- remote: buf.build/connectrpc/go
out: go/gen
# TypeScript client code (consolidated into protoc-gen-es in v2)
- remote: buf.build/bufbuild/es:v2
out: ts/src/genPutting a workflow in CI that reviews and merges the .proto API contract first and then generates per-language code fundamentally prevents situations like "a response field was added on the Go side but the TypeScript types haven't been updated yet." Using the Buf Schema Registry (BSR) as a central repository allows consistent .proto version management across teams.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| No proxy required | Direct HTTP/1.1 support enables browser↔server connections without Envoy |
| Standard HTTP tool compatibility | Instantly debug unary RPCs with curl, Postman, and browser DevTools |
| End-to-end type safety | Automatic .proto → TypeScript generation eliminates runtime type mismatches at the source |
| gRPC backward compatibility | Connect clients work with existing gRPC servers, and vice versa |
| Multi-framework support | Official plugins for Fastify 5, Express 5, and Next.js 15 App Router |
| Server streaming | Server streaming operates in HTTP/1.1 environments without Envoy |
| Single polyglot contract | Synchronize types across teams for Go, Rust, Python, TypeScript, etc. from one .proto |
| Protobuf conformance | protobuf-es is a JS/TS implementation that passes Protobuf specification conformance tests |
Disadvantages and Considerations
| Item | Description |
|---|---|
| Upfront Protobuf schema investment | Initial cost for writing .proto files and setting up the buf build pipeline |
| Learning curve | Need to learn Protobuf IDL, code generation workflow, and streaming patterns |
| Code generation dependency | Must re-run buf generate on every schema change — CI integration is mandatory |
| Streaming limitations | HTTP/1.1 supports only unary and server streaming; client and bidirectional streaming require HTTP/2 |
| Higher initial cost vs. TypeScript-only projects | The upfront .proto schema investment makes initial cost higher than tRPC in TypeScript-only projects |
| Middleware ecosystem vs. REST | The monitoring and logging middleware ecosystem is still smaller than REST's |
Pitfalls Commonly Encountered in Practice
Using v1-style instance creation in v2
If you upgrade to Connect-ES v2 but still use new SayHelloRequest(), you may see silent misbehavior at runtime instead of a compile error. In v2, you must use create(SayHelloRequestSchema, {...}). Import paths also all change from _connect.js to _pb.js after migration, so do a full search across the codebase.
Leaving buf generate out of CI
If you modify a .proto file, commit it, and don't run buf generate, the generated type files will be out of sync with the schema. Adding buf generate followed by git diff --exit-code src/gen/ as a CI step catches this early. I omitted this myself at first and spent a long time wondering "why does the server response differ from the type?"
Expecting client and bidirectional streaming to work on HTTP/1.1
Server streaming works on HTTP/1.1, but client streaming and bidirectional streaming absolutely require HTTP/2. If you're putting a CDN or general reverse proxy in front, confirm HTTP/2 end-to-end support before using bidi streaming.
Confusing Protobuf defaults with undefined
Because messages became plain objects in v2, using them directly as React state is possible. However, Protobuf's default for numeric fields is 0 and for strings it's an empty string (""). If you have logic that handles null or undefined from REST JSON, you may encounter unexpected blank screens on the UI side.
Closing Thoughts
ConnectRPC isn't the right fit for every project. If you're only connecting a frontend and backend within a TypeScript monorepo, tRPC is a faster choice. If you're building a public API or need a client SDK for external developers, REST is still powerful.
The use cases where ConnectRPC shines are clear — polyglot microservice communication, situations where you want to use Protobuf RPC directly from the browser without Envoy, and when you want to attach a browser client to an existing gRPC server. Using the .proto schema as a single source of truth so Go, TypeScript, and Rust teams share an API contract and catch type mismatch errors at build time is quite a different experience from the manual OpenAPI synchronization of the REST era.
If you're starting today, here's a recommended order:
-
Start with the Buf CLI and a single small
.protofile — you don't need to change your entire service. It's natural to apply it to one internal service first and get familiar with the workflow. -
Register the Connect middleware alongside your existing REST router in Fastify or Express — you can keep REST endpoints running while the team transitions to the new client.
-
Add
buf lint && buf generate && git diff --exit-codeto CI/CD — once schema and generated code synchronization is automated, the pipeline will catch it automatically when teammates modify.protofiles, without any extra guidance needed.
References
Official Documentation and Repositories
- ConnectRPC Official Documentation
- ConnectRPC Protocol Reference
- ConnectRPC Node.js Getting Started Guide
- ConnectRPC v2 Migration Guide
- connect-es GitHub Repository
- protobuf-es GitHub Repository
- Buf CLI GitHub Repository
Buf Blog
- Connect-ES v2.0 Release Announcement
- Connect: A Better gRPC
- Connect-Web: Protobuf and gRPC in the Browser
Case Studies and External References (Unofficial)
- OpenStatus: Migration Case from zod-openapi to ConnectRPC
- Why Connect RPC is a great choice for building APIs — Mark Wolfe's Blog
- ConnectRPC, a great extension for gRPC — Medium
- gRPC vs Connect-RPC vs tRPC 2026 Comparison — APIScout (unofficial comparison)
- gRPC-Web vs REST vs Connect-RPC for Frontend 2026 — APIScout (unofficial comparison)
- ConnectRPC vs gRPC — alamrafiul.com (unofficial)