---
title: "gRPC Performance Testing with k6: Protobuf, Streaming, and Timeouts"
description: "k6 gRPC load tests: protobuf services, unary vs streaming RPCs, deadlines, and observability—per grpc.io and Grafana k6 gRPC module docs."
publishDate: "2026-06-22"
draft: false
keyword: "k6 grpc load testing"
intent: "MOFU"
cta: "Use Performate to turn this playbook into runnable k6 scenarios, thresholds, and shareable reports without losing days to glue code."
tags: ["k6","load-testing","api-performance","grpc"]
---

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](https://k6.io/docs/using-k6/grpc/) 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](/en/blog/http2-http3-load-testing-latency)).
- **Streaming RPCs** accumulate backpressure—slow consumers stall producers.
- **Deadlines and cancellation** propagate—or do not—across hop chains ([gRPC deadlines](https://grpc.io/docs/guides/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:** `GetOrder` at high arrival rate with deadline metadata.
- **Server-streaming probe:** separate scenario with fewer VUs watches for stuck streams.
- **RPC-scoped tags and thresholds:** `rpc:GetOrder` vs `rpc:WatchInventory` segment metrics.

```javascript
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](/en/blog/k6-scenario-types-explained)).
- **Deadlines in metadata** mirror production client policies—catch missing propagation early.
- **Descriptor in git** versioned with service—regenerate when `.proto` changes.
- **Pair with service mesh metrics** and [correlation IDs](/en/blog/correlation-ids-distributed-tracing-k6) for traces.

**Anti-patterns to avoid**

- Reusing REST `http_req_duration` thresholds 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):**

```bash
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](/en/blog/load-testing-in-ci-cd)).
- [ ] Archive scenario JSON and `UNARY_RPS` per 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**

1. **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.
2. **Create `unary_orders` scenario** targeting arrival rate from production analytics. *Problem solved:* throughput tuning without rewriting executor boilerplate.
3. **Add low-VU `stream_watch` scenario** with longer think time between stream opens. *Problem solved:* streaming risk visible beside unary dashboards.
4. **Apply tags `rpc:*` and `pattern:unary|server_streaming`** matching the k6 example. *Problem solved:* reports segment by RPC shape.
5. **Run against staging, export integrated report** for service owner sign-off. *Problem solved:* one artifact for backend and platform teams.
6. **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.

[Try Performate free](https://performate.app) | [Book a demo](/demo) | [k6 gRPC module](https://k6.io/docs/using-k6/grpc/)
