golang

ConnectRPC против gRPC: практическое сравнение

  • суббота, 8 августа 2026 г. в 00:00:11
https://habr.com/ru/companies/usetech/articles/1067960/
Артём Трофимов

Разработчик

Привет, Хабр! Меня зовут Артём Трофимов, я работаю backend‑разработчиком в компании «Юзтех». Последние 5 лет я занимаюсь разработкой backend‑сервисов на Golang, и мне регулярно приходится работать с микросервисами, общающимися по gRPC.

Статья будет полезна backend‑разработчикам, которые выбирают RPC‑фреймворк для нового проекта, а также тем, кто уже работает с gRPC.

Для многих gRPC стал стандартом межсервисного взаимодействия: быстрый, строго типизированный, с кодогенерацией. Про его неудобства тоже все знают: сложность отладки, танцы с прокси, отдельная инфраструктура для браузерных клиентов. Всё это принято считать платой за производительность. Но в последние годы у gRPC появился конкурент, который утверждает, что платить больше необязательно. Речь, конечно, про ConnectRPC. Это проект компании Buf, уже принятый в CNCF (Cloud Native Computing Foundation).

Я решил разобраться, что он предлагает, собрать на нём тестовый сервис и сравнить с классическим gRPC. Спойлер: местами разница впечатляет, но обо всём по порядку.

Почему gRPC стал стандартом

gRPC представляет собой RPC‑фреймворк (Remote Procedure Call, удалённый вызов процедур), созданный Google и переданный в CNCF в 2017 году. Его популярность держится на нескольких китах:

  • Protobuf как контракт. Схема API описывается в.proto‑файле, из которого генерируется код для клиента и сервера. Строгая типизация, единый источник правды, никаких расхождений между документацией и реальностью. 

  • Производительность. Бинарная сериализация Protobuf компактнее JSON, а HTTP/2 даёт мультиплексирование запросов в одном соединении. 

  • Стриминг. Из коробки поддерживаются серверный, клиентский и двунаправленный стриминг. 

  • Экосистема. Официальные библиотеки почти для всех популярных языков, интерцепторы, дедлайны, встроенная поддержка в service mesh и облачных балансировщиках. 

Для внутреннего общения микросервисов в дата‑центре это выглядело (и во многом выглядит) идеально. Проблемы начинаются на границах этого уютного мира.

Где начинает болеть

Жёсткая привязка к HTTP/2 и trailers

gRPC требует не просто HTTP/2, а его полную реализацию, включая trailers, то есть заголовки, которые передаются после тела ответа. Именно в trailers gRPC кладёт статус завершения вызова. Проблема в том, что trailers являются экзотикой, значительная часть прокси, балансировщиков и CDN либо не поддерживает их, либо поддерживает с оговорками. Отсюда классические истории про то, как gRPC‑трафик не проходит через корпоративный прокси или внутренний балансировщик обрезает стриминг.

Браузеры

Браузерный fetch не даёт доступа к trailers и не позволяет управлять фреймами HTTP/2 напрямую. Поэтому «чистый» gRPC из браузера невозможен в принципе. Официальный ответ (протокол gRPC‑Web) требует промежуточный прокси, чаще всего Envoy, который транслирует gRPC‑Web в обычный gRPC. Итог: чтобы фронтенд поговорил с бэкендом, вам нужно развернуть и сопровождать ещё один инфраструктурный компонент.

Отладка

Бинарный протокол не почитаешь глазами. Быстро проверить эндпоинт curl'ом, как вы привыкли с REST, не выйдет: нужны специальные инструменты вроде grpcurl, Postman с поддержкой gRPC, рефлексия на сервере. Посмотреть запрос во вкладке Network в DevTools тоже задача со звёздочкой. Для опытной команды это решаемо, но порог входа и цена каждой мелкой проверки заметно выше.

Собственный HTTP‑стек

Официальная реализация gRPC для Go (grpc‑go) исторически использует собственную реализацию HTTP/2 вместо стандартного net/http (вариант обслуживания через net/http существует, но с ограничениями и потерей производительности). На практике это означает, что привычные middleware не работают, gRPC‑сервер живёт отдельной жизнью от остальных HTTP‑эндпоинтов вроде health checks и метрик, а объём зависимостей растёт (в grpc‑go десятки тысяч строк кода).

Каждая из этих проблем по отдельности терпима. Но вместе они складываются в устойчивое ощущение, что фреймворк борется с окружающей экосистемой веба, вместо того чтобы опираться на неё. Именно на этом наблюдении и построен ConnectRPC.

Что такое ConnectRPC

ConnectRPC является RPC‑фреймворком от компании Buf (создателей одноимённого инструментария для Protobuf), впервые представленным в 2022 году и позже переданным в CNCF. Идея проста: взять всё хорошее из gRPC (Protobuf‑контракты, кодогенерацию, стриминг) и построить это поверх обычного, стандартного HTTP, вместо того чтобы воевать с ним.

Ключевые особенности:

Три протокола в одном сервере. Connect‑сервер из коробки понимает три протокола: собственный протокол Connect, классический gRPC и gRPC‑Web. Это значит, что существующие gRPC‑клиенты продолжат работать с вашим сервисом без изменений, а браузеру больше не нужен Envoy‑прокси: сервер отвечает на gRPC‑Web напрямую.

Обычный HTTP. Протокол Connect работает поверх HTTP/1.1 и HTTP/2 и не зависит от trailers. Простые unary‑вызовы представляют собой обычные POST‑запросы с JSON или бинарным Protobuf в теле. Их видно в DevTools, их можно отправить curl'ом:

curl --header "Content-Type: application/json" \

  --data '{"sentence": "Привет, Хабр!"}' \

  https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say

Никаких плагинов, специальных клиентов и рефлексии, просто HTTP‑запрос.

Стандартные библиотеки вместо собственного стека. В Go Connect‑хендлер реализует обычный http.Handler, а клиент оборачивает http.Client. Это означает совместимость со всей экосистемой: любые роутеры, middleware, серверы. gRPC‑эндпоинты, health checks и метрики наконец живут в одном HTTP‑сервере. Сама библиотека занимает несколько тысяч строк кода против десятков тысяч в grpc‑go.

Предсказуемость. connect‑go следует семантическому версионированию и гарантирует обратную совместимость в пределах мажорной версии, в отличие от grpc‑go, где breaking changes в минорных релизах случались не раз.

Официальные реализации есть для Go, TypeScript/JavaScript (включая Node.js и браузеры), Kotlin и Swift, то есть закрыты и серверная, и мобильная разработка. Список короче, чем у gRPC, и это честный минус, к которому мы вернёмся.

Собираем демо

Слова словами, но лучше один раз потрогать. Я собрал демо‑проект: один и тот же сервис GreetService с unary‑методом и серверным стримингом, реализованный дважды, на классическом grpc‑go и на connect‑go. Бизнес‑логика общая, различаются только транспортные обвязки.

  • proto/greet/v1/greet.proto — Protobuf‑контракт (единый для обоих серверов)

  • gen/ — сгенерированный код

  • internal/greeter/ — общая бизнес‑логика

  • cmd/server‑grpc/ — сервер на grpc‑go (:50051)

  • cmd/server‑connect/ — сервер на connect‑go (:8080)

  • cmd/client‑grpc/ — клиент на grpc‑go

  • cmd/client‑connect/ — клиент на connect‑go (флаг ‑protocol)

Контракт выглядит так:

syntax = "proto3";
package greet.v1;
option go_package = "github.com/stfu69-47/grpc-connectrpc-demo/gen/greet/v1;greetv1";
service GreetService {
  // Unary-вызов: принимает имя, возвращает приветствие.
  rpc Greet(GreetRequest) returns (GreetResponse) {}

 // Server-streaming: отдаёт несколько приветствий подряд.
  rpc GreetStream(GreetStreamRequest) returns (stream GreetStreamResponse) {}
}
message GreetRequest {
  string name = 1;
}
message GreetResponse {
  string greeting = 1;
}
message GreetStreamRequest {
  string name = 1;
  // Сколько сообщений отправить в поток.

  int32 count = 2;
}
message GreetStreamResponse {
  string greeting = 1;
  int32 sequence = 2;
}

Кодогенерация выполняется через buf: buf lint && buf generate. Один.proto‑файл, три плагина (protoc‑gen‑go, protoc‑gen‑go‑grpc, protoc‑gen‑connect‑go), и у нас есть сгенерированный код для обоих стеков. Плюс connect‑go в том, что генерируется один небольшой файл: клиент и хендлер, никакого отдельного «grpc»‑слоя.

Сервер на grpc‑go, всё как мы привыкли:

package main
import (
        "context"
        "log"
        "net"
        "google.golang.org/grpc"
        "google.golang.org/grpc/reflection"
        greetv1 "github.com/stfu69-47/grpc-connectrpc-demo/gen/greet/v1"
        "github.com/stfu69-47/grpc-connectrpc-demo/internal/greeter"
        "github.com/stfu69-47/grpc-connectrpc-demo/internal/logging"
)
const addr = ":50051"
type greetServer struct {
        greetv1.UnimplementedGreetServiceServer
}
func (s greetServer) Greet(ctx context.Context, req greetv1.GreetRequest) (*greetv1.GreetResponse, error) {
        return &greetv1.GreetResponse{Greeting: greeter.Greet(req.GetName())}, nil
}
func (s greetServer) GreetStream(req greetv1.GreetStreamRequest, stream grpc.ServerStreamingServer[greetv1.GreetStreamResponse]) error {
        for i := int32(1); i <= req.GetCount(); i++ {
                resp := &greetv1.GreetStreamResponse{
                        Greeting: greeter.GreetSeq(req.GetName(), i),
                        Sequence: i,
                }
                if err := stream.Send(resp); err != nil {
                        return err
                }
        }
        return nil
}
func main() {
        lis, err := net.Listen("tcp", addr)
        if err != nil {
                log.Fatalf("listen: %v", err)
        }
      srv := grpc.NewServer(
                grpc.ChainUnaryInterceptor(logging.GRPCUnaryLogging),
        )
        greetv1.RegisterGreetServiceServer(srv, &greetServer{})
        // Рефлексия - чтобы работал grpcurl без указания .proto-файлов.
        reflection.Register(srv)
        log.Printf("gRPC-сервер слушает %s", addr)
        if err := srv.Serve(lis); err != nil {
                log.Fatalf("serve: %v", err)
        }
}

А теперь сервер на connect‑go:

package main
import (
        "context"

        "log"
        "net/http"
        "time"
        "connectrpc.com/connect"
        greetv1 "github.com/stfu69-47/grpc-connectrpc-demo/gen/greet/v1"

        "github.com/stfu69-47/grpc-connectrpc-demo/gen/greet/v1/greetv1connect"

        "github.com/stfu69-47/grpc-connectrpc-demo/internal/greeter"

        "github.com/stfu69-47/grpc-connectrpc-demo/internal/logging"
)
const addr = ":8080"
type greetServer struct{}
func (s *greetServer) Greet(
        ctx context.Context,
        req *connect.Request[greetv1.GreetRequest],
) (*connect.Response[greetv1.GreetResponse], error) {
        return connect.NewResponse(&greetv1.GreetResponse{
                Greeting: greeter.Greet(req.Msg.GetName()),
        }), nil
}
func (s *greetServer) GreetStream(
        ctx context.Context,
        req *connect.Request[greetv1.GreetStreamRequest],
        stream *connect.ServerStream[greetv1.GreetStreamResponse],
) error {
        for i := int32(1); i <= req.Msg.GetCount(); i++ {
                resp := &greetv1.GreetStreamResponse{
                        Greeting: greeter.GreetSeq(req.Msg.GetName(), i),
                        Sequence: i,
                }

                if err := stream.Send(resp); err != nil {
                        return err
                }
        }
        return nil
}
func main() {
        mux := http.NewServeMux()

// Connect-хендлер монтируется в обычный ServeMux -

// рядом можно повесить health check, метрики, любые HTTP-эндпоинты.

path, handler := greetv1connect.NewGreetServiceHandler(
&greetServer{},
connect.WithInterceptors(logging.ConnectLogging{}),
)
mux.Handle(path, handler)
mux.HandleFunc(“/healthz”, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
,  = w.Write([]byte(“ok”))
})

// HTTP/2 без TLS (h2c) нужен, чтобы классические gRPC-клиенты

// могли ходить на этот порт без сертификатов; протокол Connect

// работает и по обычному HTTP/1.1. С Go 1.24 это включается

// штатно, без golang.org/x/net.


        var protocols http.Protocols
        protocols.SetHTTP1(true)
        protocols.SetUnencryptedHTTP2(true)
        srv := &http.Server{
                Addr:              addr,
                Handler:           mux,
                Protocols:         &protocols,
                ReadHeaderTimeout: 10 * time.Second,
        }
        log.Printf("Connect-сервер слушает %s (Connect + gRPC + gRPC-Web)", addr)

        if err := srv.ListenAndServe(); err != nil {
                log.Fatalf("serve: %v", err)

        }
}

Обратите внимание на две вещи. Во‑первых, NewGreetServiceHandler возвращает обычные (path string, handler http.Handler). Это значит, что RPC‑сервис вешается на стандартный http.ServeMux рядом с любыми другими эндпоинтами. Health check в моём демо живёт в том же процессе и на том же порту:

mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {

    w.WriteHeader(http.StatusOK)

})

Во‑вторых, настройка Protocols включает HTTP/2 без TLS (h2c, HTTP/2 cleartext). Она нужна, чтобы сервер принимал и HTTP/1.1, и HTTP/2 на одном порту: протоколу Connect хватит и первого, а вот классическим gRPC‑клиентам нужен второй. Раньше для этого требовалась обёртка h2c.NewHandler из golang.org/x/net, но с Go 1.24 она объявлена устаревшей: h2c включается штатным полем http.Server.Protocols, и внешняя зависимость не нужна вовсе.

Один сервер, три протокола

Теперь самое интересное. У connect‑клиента в демо есть флаг ‑protocol, который переключает wire‑протокол. Код при этом не меняется вообще, это одна опция конструктора:

go run ./cmd/client-connect -protocol connect

go run ./cmd/client-connect -protocol grpc

go run ./cmd/client-connect -protocol grpcweb

Все три команды ходят на один и тот же порт: 8080 и получают один и тот же ответ. В логе сервера видно, кто есть кто:

call=/greet.v1.GreetService/Greet protocol=connect dur=3.306µs err=<nil>

call=/greet.v1.GreetService/Greet protocol=grpc dur=4.238µs err=<nil>

call=/greet.v1.GreetService/Greet protocol=grpcweb dur=3.747µs err=<nil>

Но настоящая проверка на совместимость другая. Возьмём клиент из мира классического gRPC (cmd/client‑grpc, в котором нет ни строчки про Connect, обычный grpc.NewClient) и направим его вместо:50051 на:8080.

$ go run ./cmd/client-grpc -addr localhost:8080

2026/07/05 20:48:24 unary: Привет, Хабр!

2026/07/05 20:48:24 stream #1: Привет, Хабр! (сообщение #1)

2026/07/05 20:48:24 stream #2: Привет, Хабр! (сообщение #2)

2026/07/05 20:48:24 stream #3: Привет, Хабр! (сообщение #3)

Работает. Это главный практический вывод для тех, у кого уже есть парк gRPC‑сервисов: миграция на connect‑go со стороны сервера не ломает существующих клиентов. Можно переводить сервисы по одному, не трогая потребителей, а фронтенд при этом получает gRPC‑Web без Envoy: бесплатно, тем же сервером.

Отладка: curl вместо grpcurl

Помните жалобу на отладку из первой части? Вот как выглядит проверка эндпоинта у connect‑сервера:

curl --header "Content-Type: application/json" \

  --data '{"name": "Хабр"}' \

  http://localhost:8080/greet.v1.GreetService/Greet

# {"greeting":"Привет, Хабр!"}

Никакой рефлексии, никакого grpcurl, никаких плагинов к Postman. Обычный POST с JSON: его же видно во вкладке Network в DevTools, его же можно положить в интеграционный тест на любом языке. URL при этом полностью предсказуем: /пакет.Сервис/Метод прямо из.proto‑файла.

Интерцепторы: четыре сигнатуры против одной

Сквозная функциональность (логирование, метрики, аутентификация) в обоих фреймворках делается через интерцепторы. Но устроены они по‑разному.

В grpc‑go интерцепторов четыре вида: unary и stream, серверные и клиентские, и у каждого своя сигнатура. Логирование «на всё» превращается в четыре реализации одной и той же идеи. Вот только серверный unary‑вариант:

// GRPCUnaryLogging - серверный unary-интерцептор для grpc-go.

// Для стриминга, а также клиентской стороны в grpc-go нужны

// отдельные реализации с другими сигнатурами.

func GRPCUnaryLogging(

        ctx context.Context,

        req any,

        info *grpc.UnaryServerInfo,

        handler grpc.UnaryHandler,

) (any, error) {

        start := time.Now()

        resp, err := handler(ctx, req)

        log.Printf("grpc call=%s dur=%s err=%v", info.FullMethod, time.Since(start), err)

        return resp, err

}

В connect-go интерцептор один и работает везде, на клиенте и на сервере:

// ConnectLogging - единый интерцептор для connect-go: одна реализация

// покрывает unary и стриминг, сервер и клиент.

type ConnectLogging struct{}

func (ConnectLogging) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {

        return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {

                start := time.Now()

                resp, err := next(ctx, req)

                log.Printf("call=%s protocol=%s dur=%s err=%v",

                        req.Spec().Procedure, req.Peer().Protocol, time.Since(start), err)

                return resp, err

        }

}

func (ConnectLogging) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {

        return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {

                log.Printf("stream client call=%s", spec.Procedure)

                return next(ctx, spec)

        }

}

func (ConnectLogging) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {

        return func(ctx context.Context, conn connect.StreamingHandlerConn) error {

                start := time.Now()

                err := next(ctx, conn)

                log.Printf("stream call=%s protocol=%s dur=%s err=%v",

                        conn.Spec().Procedure, conn.Peer().Protocol, time.Since(start), err)

                return err

        }

}

И бонус. Из req.Peer().Protocol интерцептор видит, по какому протоколу пришёл вызов. Именно так получен лог из предыдущего раздела.

Меряем: методология

Перед цифрами расскажу, как именно я мерил, чтобы вы могли не верить мне на слово, а воспроизвести.

Железо и софт: AMD Ryzen 7 5825U (8 ядер / 16 потоков), 16 ГБ RAM, Ubuntu 24.04 LTS, Go 1.26.1, grpc‑go v1.82.0, connect‑go v1.20.0. Оба сервера запускались на той же машине, без TLS (connect‑go через h2c, чтобы оба стека работали по HTTP/2 в равных условиях). Бенчмарки написаны на стандартном testing.B, по 6 прогонов (‑count=6) с усреднением через benchstat, чтобы получить доверительные интервалы, а не случайную цифру. 

Payload двух размеров: ~100 байт (типичный маленький запрос) и ~100 КБ (передача блоба). В стриминг‑сценарии одна операция включает полный цикл: открыть стрим, принять 100 сообщений по ~100 байт, закрыть.

Матрица измерений: unary и серверный стриминг × Protobuf и JSON (JSON только для протокола Connect, у классического gRPC его просто нет) × два размера payload.

Меряем: результаты

Unary‑вызовы (медиана по 6 прогонам, benchstat):

Сценарий

µs/op

B/op

allocs/op

grpc‑go, protobuf, 100 Б

83.1 ± 5%

9.8 KiB

154

connect‑go (протокол connect), protobuf, 100 Б

173.2 ± 3%

61.7 KiB

167

connect‑go (протокол grpc), protobuf, 100 Б

219.8 ± 11%

73.7 KiB

209

connect‑go, JSON, 100 Б

186.5 ± 5%

63.3 KiB

189

grpc‑go, protobuf, 100 КБ

560.6 ± 7%

660.8 KiB

176

connect‑go, protobuf, 100 КБ

741.6 ± 16%

953.8 KiB

203

connect‑go, JSON, 100 КБ

1074 ± 29%

938.2 KiB

225

Серверный стриминг (100 сообщений по ~100 байт за операцию):

Сценарий

µs/op

B/op

allocs/op

grpc‑go

266.4 ± 14%

125.8 KiB

1668

connect‑go

4295 ± 5%

1363 KiB

1644

Перцентили задержек под нагрузкой (ghz, unary, 50 000 запросов, 50 соединений, протокол gRPC в обоих случаях):


grpc‑go (:50051)

connect‑go (:8080)

p50

0.98 ms

2.97 ms

p95

1.79 ms

4.71 ms

p99

2.35 ms

6.00 ms

Requests/sec

33 907

14 907

Что тут видно. На маленьких unary‑запросах grpc‑go примерно вдвое быстрее (83 µs против 173 µs): сказывается вылизанный собственный HTTP/2-стек против стандартного net/http. На больших payload разница сжимается до ~30%: чем больше времени уходит на сериализацию и передачу данных, тем меньше значит оверхед транспорта. Под конкурентной нагрузкой (ghz) картина та же. grpc‑go держит ~34 тысячи RPS против ~15 тысяч у connect‑go, p99 отличается примерно в 2.5 раза, но обе цифры составляют единицы миллисекунд, что для абсолютного большинства сервисов незаметно на фоне похода в базу.

JSON ведёт себя интересно: на 100 байтах он практически не отличается от protobuf (187 µs против 173 µs, на таких размерах кодек вообще не важен), а вот на 100 КБ становится в полтора раза дороже. Отсюда честный вывод. JSON в Connect служит инструментом удобства (отладка, простые внешние API, браузер), а не заменой protobuf для тяжёлого межсервисного трафика. Никто, впрочем, и не заставляет выбирать, протокол и кодек являются опцией клиента, сервер понимает всё сразу.

А вот стриминг оказался главным сюрпризом со знаком минус: connect‑go медленнее в 16 раз (4.3 ms против 0.27 ms на стрим из 100 сообщений). Аллокации при этом сопоставимы, значит дело не в сериализации, а в том, что connect‑go честно флашит каждое сообщение через стандартный net/http, пока grpc‑go жонглирует фреймами в собственном стеке. Для стримов из нескольких сообщений это неважно, но если ваш профиль нагрузки состоит из длинных интенсивных стримов с мелкими сообщениями, обязательно мерьте на своих данных.

Цена зависимостей

Во второй части я упомянул объём кода grpc‑go. Проверим и посчитаем, что мы затягиваем в проект с каждым из стеков.

Транзитивные зависимости и объём кода (модули считались по go mod graph; строки считались утилитой scc по коду библиотеки без тестов и примеров):


Модулей в графе

Go‑файлов

Строк кода

google.golang.org/grpc v1.82.0

44

495

~65 000

connectrpc.com/connect v1.20.0

8

50

~9 000

(Если считать вместе с тестами, grpc‑go разрастается уже до ~173 тысяч строк.)

Размер собранных бинарников демо‑серверов (go build, без флагов оптимизации):


Размер

server‑grpc

15.9 МБ

server‑connect

18.2 МБ

И вот тут меня ждал сюрприз, о котором в маркетинге Connect не пишут: бинарник connect‑сервера получился даже больше. Разгадка простая. connect‑go тянет за собой весь серверный стек net/http плюс golang.org/x/net/http2, а grpc‑go обходится собственной, более компактной реализацией HTTP/2 без полного net/http‑сервера. Так что «в 7 раз меньше кода» относится к коду, который вы читаете, аудируете и от которого зависите, а не к размеру артефакта. Меньше кода означает меньшую поверхность атаки и более простой аудит зависимостей: 8 модулей в графе против 44 видно невооружённым глазом в отчёте любого сканера. Для кого‑то это аргумент второго порядка, а для кого‑то (например, в финтехе с обязательным аудитом сторонних библиотек) вполне себе первого.

Ложка дёгтя

Обещанный честный разговор о минусах ConnectRPC.

Языков меньше. Официальные реализации: Go, TypeScript/JavaScript, Kotlin, Swift. Если в вашем зоопарке есть Python, Java, C# или Rust, полноценного Connect‑стека для них нет (классические gRPC‑клиенты этих языков, впрочем, смогут ходить на connect‑сервер, три протокола работают в обе стороны, но нативного удобства Connect эти команды не получат).

Производительность стриминга. Как показали бенчмарки выше, интенсивный серверный стриминг на connect‑go на порядок медленнее. Это плата за использование стандартного net/http вместо собственного стека, той самой особенности, которая даёт всё остальное удобство. Для типичных сценариев (стрим на десятки сообщений, крупные сообщения, невысокая частота) это несущественно, но для стриминга как основного профиля нагрузки лучше оставить grpc‑go.

Нет клиентской балансировки и xDS. grpc‑go умеет сложные сценарии: клиентскую балансировку, интеграцию с xDS, взвешенный round‑robin, health‑checking на уровне клиента. Connect сознательно отдаёт это инфраструктуре: L7-балансировщику или service mesh. Если у вас Kubernetes с обычным ingress или mesh, вы разницы не заметите; если вы полагались именно на клиентскую балансировку grpc‑go, это блокер.

Экосистема моложе. Вокруг gRPC за десять лет наросло всё — от готовых интерцепторов на любой случай до глав в книгах. У Connect сообщество меньше, готовых рецептов меньше, и на нестандартный вопрос вы скорее пойдёте читать исходники (благо их девять тысяч строк, а не шестьдесят пять).

Что в итоге

Сравнение упирается не в «кто быстрее», а в то, где живут ваши клиенты.

Оставаться на grpc‑go разумно, если весь трафик внутренний, между сервисами в одном контуре, и вы активно используете его продвинутые возможности: клиентскую балансировку, xDS, зрелую экосистему интерцепторов. То же самое верно, если ваш профиль нагрузки состоит из интенсивного стриминга и низких задержек на hot path. Ломать работающее ради нескольких десятков тысяч сэкономленных строк зависимостей смысла нет.

Смотреть на connect‑go стоит, если у API есть внешние потребители: браузеры, мобильные клиенты, партнёры с curl'ом наперевес. Или если вы устали держать Envoy только ради gRPC‑Web и хотите, чтобы RPC, health checks и метрики жили в одном стандартном HTTP‑сервере. А благодаря совместимости протоколов пробовать можно без революции: перевести один сервис и посмотреть. Старые клиенты не заметят подмены, я проверил.

Демо‑проект со всеми примерами и бенчмарками лежит здесь.

А что используете вы? Интересен опыт тех, кто уже переводил боевые сервисы на Connect, особенно если что‑то пошло не так. Расскажите в комментариях.