By Performate
gRPC Performance Testing with k6: Protobuf, Streaming, and Timeouts
k6 gRPC load tests: protobuf services, unary vs streaming RPCs, deadlines, and observability—per grpc.io and Grafana k6 gRPC module docs.
Your microservices speak gRPC on HTTP/2—strongly typed, multiplexed, and invisible to curl-based smoke tests. REST load scripts miss serialization CPU, streaming backpressure, and deadline propagation that dominate tail latency under concurrency. k6's gRPC module loads protobuf descriptors and drives unary or streaming RPCs like production clients.
gRPC performance testing is not REST with a different port. It is scenario design per RPC shape: unary hot paths vs server-streaming feeds vs bidirectional sessions—each with different VU math and failure modes. In this guide you will learn where gRPC bottlenecks hide, how to structure k6 scenarios with deadlines and tags, and which signals should gate releases—not just "RPC returns OK once."
Why gRPC latency diverges from REST assumptions
Protobuf services look simple in .proto files. Under load, behavior differs from JSON HTTP:
- Serialization cost shifts CPU to marshal/unmarshal—especially large repeated fields.
- HTTP/2 multiplexing shares connections; head-of-line blocking behaves differently than REST connection pools (HTTP/2 load testing).
- Streaming RPCs accumulate backpressure—slow consumers stall producers.
- Deadlines and cancellation propagate—or do not—across hop chains (gRPC deadlines).
- Keepalive and max concurrent streams on channels affect connection churn under many VUs.
Think of gRPC like a phone call with typed transcripts: the wire efficiency helps until someone holds the line open on a server stream nobody reads.
Practical k6 implementation: unary vs streaming scenarios
Load descriptor-backed unary RPCs for throughput SLOs; add a separate low-rate streaming scenario for tail-latency and hang detection.
Example script (illustrative—not a production-ready test). Place service.proto and generated descriptor set beside the script per k6 docs.
What this example demonstrates:
- Client with loaded proto:
grpc.load()connects with TLS options and invokes methods by fully qualified name. - Unary hot path:
GetOrderat high arrival rate with deadline metadata. - Server-streaming probe: separate scenario with fewer VUs watches for stuck streams.
- RPC-scoped tags and thresholds:
rpc:GetOrdervsrpc:WatchInventorysegment metrics.
import grpc from 'k6/grpc';
import { check, sleep } from 'k6';
const client = new grpc.Client();
client.load(['./proto'], 'inventory.proto');
const HOST = __ENV.GRPC_HOST || 'grpc-staging.example.com:443';
export const options = {
scenarios: {
unary_orders: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.UNARY_RPS || 40),
timeUnit: '1s',
duration: '5m',
preAllocatedVUs: 15,
maxVUs: 80,
exec: 'getOrder',
tags: { rpc: 'GetOrder', pattern: 'unary' },
},
stream_watch: {
executor: 'constant-vus',
vus: 3,
duration: '5m',
exec: 'watchInventory',
tags: { rpc: 'WatchInventory', pattern: 'server_streaming' },
},
},
thresholds: {
'grpc_req_duration{rpc:GetOrder}': ['p(95)<120', 'p(99)<250'],
'grpc_req_duration{rpc:WatchInventory}': ['p(95)<500'],
checks: ['rate>0.99'],
},
};
export function getOrder() {
client.connect(HOST, { tls: true });
const response = client.invoke('inventory.OrderService/GetOrder', {
order_id: `ORD-${__VU}-${__ITER}`,
}, { metadata: { deadline: '500ms' } });
check(response, {
'GetOrder status OK': (r) => r && r.status === grpc.StatusOK,
});
client.close();
sleep(0.1);
}
export function watchInventory() {
client.connect(HOST, { tls: true });
const stream = client.invoke('inventory.InventoryService/WatchInventory', {
sku: 'SKU-100',
}, { metadata: { deadline: '2s' } });
check(stream, {
'stream started': (r) => r && r.status === grpc.StatusOK,
});
client.close();
sleep(1);
}
Patterns that work
- One scenario per RPC pattern—unary, client-stream, server-stream, bidi (k6 scenario types).
- Deadlines in metadata mirror production client policies—catch missing propagation early.
- Descriptor in git versioned with service—regenerate when
.protochanges. - Pair with service mesh metrics and correlation IDs for traces.
Anti-patterns to avoid
- Reusing REST
http_req_durationthresholds for gRPC without baseline. - One mega-stream per VU forever—masks consumer lag until prod.
- Skipping TLS/options that prod clients use—distorts connection behavior.
Pro tip (example command):
k6 run grpc-inventory.js --summary-trend-stats="p(95),p(99)" -e UNARY_RPS=60
What this command demonstrates: tune unary throughput independently while watching streaming scenario for hangs in the same run.
Decision framework: unary focus vs streaming stress
| Situation | Recommended action |
|---|---|
| CRUD-style gRPC API | High-rate unary scenarios; deadline tags |
| Live inventory / chat feed | Server-streaming scenario; low VUs; long duration soak |
| Bidirectional sync | Dedicated small-VU scenario; monitor stream duration |
| Mixed REST + gRPC gateway | HTTP scenarios for edge; gRPC for internal SLO |
| Proto change in release | Regenerate descriptor; smoke unary + one stream |
Use unary-heavy scenarios if product SLOs cover request/response RPCs only.
Use streaming scenarios if incidents involved stuck streams, memory growth, or consumer lag.
Use gateway HTTP tests if clients hit REST but perf risk is internal gRPC—test both layers.
Observability, documentation, and next steps
gRPC load tests need proto version discipline. Before peak traffic:
- Document proto git SHA, descriptor path, and deadline policy per RPC in runbook.
- Alert when
grpc_req_duration{rpc:*}crosses SLO—separate from REST dashboards. - Correlate k6 RPC tags with OpenTelemetry gRPC spans in staging.
- Automate unary smoke in CI after proto merges (CI/CD load testing).
- Archive scenario JSON and
UNARY_RPSper release for regression compare.
How Performate simplifies gRPC load testing
Protobuf paths and TLS options slow teams used to Postman HTTP. Below is a concrete workflow example for order/inventory gRPC services.
Example: unary throughput + streaming probe in one workspace
- Document gRPC host, proto path, and sample payloads in scenario notes (Performate HTTP import for gateway edge if applicable). Problem solved: onboarding without hunting wiki pages.
- Create
unary_ordersscenario targeting arrival rate from production analytics. Problem solved: throughput tuning without rewriting executor boilerplate. - Add low-VU
stream_watchscenario with longer think time between stream opens. Problem solved: streaming risk visible beside unary dashboards. - Apply tags
rpc:*andpattern:unary|server_streamingmatching the k6 example. Problem solved: reports segment by RPC shape. - Run against staging, export integrated report for service owner sign-off. Problem solved: one artifact for backend and platform teams.
- Export k6 gRPC script for CI smoke after proto changes. Problem solved: desktop edits and pipeline stay aligned.
That workflow maps to the cta: runnable scenarios, thresholds, and shareable reports without glue-code sprints.
Closing takeaway
gRPC performance is an RPC-shape problem: unary throughput, streaming backpressure, and deadlines each need their own scenario. Load-test with loaded descriptors, tag every method, and treat stuck streams as release gates—not optional extras.
Run this week's unary rate against staging—and keep the streaming scenario running long enough to catch the consumer lag your REST tests never saw.
Ready to optimize your API performance?
Use Performate to turn this playbook into runnable k6 scenarios, thresholds, and shareable reports without losing days to glue code.