Resource library

QA How-To

Performance Test gRPC Streaming Services

Learn to performance test gRPC streaming services with k6, realistic stream lifecycles, latency thresholds, load profiles, metrics, diagnosis, and tuning.

23 min read | 2,486 words

TL;DR

Build a deterministic server-streaming endpoint, drive concurrent streams with k6, and measure time to first message, message count, errors, and total stream duration. Increase concurrency in controlled stages while correlating client results with server, proxy, and infrastructure telemetry.

Key Takeaways

  • Model connected streams, messages per stream, and stream duration instead of HTTP-style request rate alone.
  • Use a deterministic server-streaming fixture before testing a production service.
  • Separate time to first message, inter-message delay, and full stream duration.
  • Verify status codes, message counts, sequence integrity, and terminal events for every stream.
  • Watch client, server, proxy, and network saturation together.
  • Run capacity, spike, and soak profiles because each exposes different streaming risks.
  • Preserve the load script, protobuf contract, image identity, configuration, and raw summaries with every result.

To performance test gRPC streaming services, create realistic long-lived connections, validate every stream lifecycle, and measure message timing rather than treating a stream as one ordinary request. Your pass criteria should cover connection success, time to first message, inter-message cadence, sequence correctness, terminal status, and resource limits.

This tutorial fits into the Cloud-Native Performance Testing Complete Guide, which explains how application, platform, network, and observability evidence form one performance result. Here, you will build one deterministic server-streaming lab and use it to find the concurrency point where service quality stops meeting your contract.

The runnable example uses Go for a small gRPC server and k6 for load generation. The same workload design applies to client streaming and bidirectional streaming, but those modes need a client tool that can actively send messages after opening the stream.

What You Will Build

You will build a repeatable test that can:

  • start a server-streaming Watch RPC that emits a known message sequence;
  • run the service in a container with HTTP/2 enabled end to end;
  • open controlled numbers of concurrent streams with k6;
  • record time to first message and complete stream duration;
  • verify message count, order, terminal status, and failed streams;
  • execute baseline, capacity, spike, and soak profiles;
  • correlate client measurements with Go runtime, container, and proxy signals.

The fixture is intentionally deterministic. Production streams may depend on brokers, databases, or live events, but a fixed sequence first proves that the harness can detect missing, duplicated, delayed, and reordered messages.

Prerequisites

Install Go 1.24 or newer, Docker Engine 27 or newer, and k6 1.0 or newer with gRPC streaming support. You also need protoc 29 or newer plus the current Go protobuf generators. Commands assume a POSIX shell and ports 50051 and 9090 are free.

go version
docker version
k6 version
protoc --version
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Pin exact module and image versions in your repository after validating them. A tutorial can use current compatible releases, but a formal benchmark must not change dependencies between runs. Confirm protoc-gen-go and protoc-gen-go-grpc are on PATH.

Step 1: Plan How to Performance Test gRPC Streaming Services

Write a hypothesis before code or traffic. For this lab, each Watch call requests 20 messages at a 100 millisecond interval. Under 50 concurrent virtual users, at least 99 percent of streams should finish successfully, every successful stream should contain 20 ordered messages, p95 time to first message should remain below 250 milliseconds, and p95 full duration should remain below 2.5 seconds.

A stream has multiple clocks. Connection establishment covers DNS, TCP, TLS when enabled, and HTTP/2 setup. Time to first message covers dispatch plus the first application response. Inter-message delay exposes scheduler pauses, flow-control pressure, or a slow producer. Total duration includes the entire stream and terminal status. Do not compress those into one latency percentile.

Create a workspace:

mkdir -p grpc-stream-lab/{proto,server,results}
cd grpc-stream-lab
go mod init example.com/grpc-stream-lab

Record the hypothesis, machine identity, CPU count, available memory, tool versions, and start time in the result directory. Run formal comparisons on dedicated infrastructure or document neighboring workloads.

Verification: run go env GOMOD. It must print the absolute path to grpc-stream-lab/go.mod. Confirm the written hypothesis contains a workload, a concurrency level, integrity rules, latency limits, and an allowed failure rate.

Step 2: Define and Generate the gRPC Contract

Create proto/watch.proto. The request controls message count and interval so the same server can support a fast smoke test and a longer capacity run.

syntax = "proto3";

package watch.v1;
option go_package = "example.com/grpc-stream-lab/proto;watchv1";

service WatchService {
  rpc Watch(WatchRequest) returns (stream WatchEvent);
}

message WatchRequest {
  int32 count = 1;
  int32 interval_ms = 2;
}

message WatchEvent {
  int32 sequence = 1;
  int64 sent_unix_ms = 2;
  string payload = 3;
}

Generate Go bindings and add dependencies:

protoc --go_out=. --go_opt=module=example.com/grpc-stream-lab \
  --go-grpc_out=. --go-grpc_opt=module=example.com/grpc-stream-lab \
  proto/watch.proto
go get google.golang.org/grpc google.golang.org/protobuf
go mod tidy

Commit the protobuf file and generated bindings together. The load generator uses server reflection later, but generated types keep the service implementation type-safe. In production, store a descriptor set with the test so reflection can remain disabled.

Verification: run go test ./.... It should compile the generated proto package without errors. ls proto should show watch.proto, watch.pb.go, and watch_grpc.pb.go.

Step 3: Implement a Deterministic Server Stream

Create server/main.go. It rejects unsafe input, sends numbered events, observes cancellation, and exposes standard gRPC reflection for the local k6 client.

package main

import (
    "fmt"
    "log"
    "net"
    "time"

    watchv1 "example.com/grpc-stream-lab/proto"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/reflection"
    "google.golang.org/grpc/status"
)

type watchServer struct {
    watchv1.UnimplementedWatchServiceServer
}

func (s *watchServer) Watch(req *watchv1.WatchRequest, stream grpc.ServerStreamingServer[watchv1.WatchEvent]) error {
    if req.Count < 1 || req.Count > 1000 {
        return status.Error(codes.InvalidArgument, "count must be 1..1000")
    }
    if req.IntervalMs < 1 || req.IntervalMs > 60000 {
        return status.Error(codes.InvalidArgument, "interval_ms must be 1..60000")
    }
    ticker := time.NewTicker(time.Duration(req.IntervalMs) * time.Millisecond)
    defer ticker.Stop()

    for i := int32(1); i <= req.Count; i++ {
        select {
        case <-stream.Context().Done():
            return status.FromContextError(stream.Context().Err()).Err()
        case t := <-ticker.C:
            event := &watchv1.WatchEvent{
                Sequence: i, SentUnixMs: t.UnixMilli(),
                Payload: fmt.Sprintf("event-%d", i),
            }
            if err := stream.Send(event); err != nil { return err }
        }
    }
    return nil
}

func main() {
    listener, err := net.Listen("tcp", ":50051")
    if err != nil { log.Fatal(err) }
    server := grpc.NewServer()
    watchv1.RegisterWatchServiceServer(server, &watchServer{})
    reflection.Register(server)
    log.Println("gRPC server listening on :50051")
    log.Fatal(server.Serve(listener))
}

The generic grpc.ServerStreamingServer[T] signature is generated by current grpc-go. Cancellation handling prevents abandoned clients from leaving producer work behind. The interval begins before the first message, making time to first message predictable.

Run the service:

go run ./server

Verification: the process must log gRPC server listening on :50051 and stay running. In another terminal, run grpcurl -plaintext localhost:50051 list if grpcurl is installed. It should include watch.v1.WatchService.

Step 4: Containerize and Bound the Service

Create Dockerfile so every run uses the same build and runtime.

FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY proto ./proto
COPY server ./server
RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 50051
ENTRYPOINT ["/server"]

Build and run with explicit local limits. Resource limits make saturation reproducible and stop the test server from consuming the whole workstation.

docker build -t grpc-stream-lab:local .
docker run --rm --name grpc-stream-lab \
  --cpus=2 --memory=512m -p 50051:50051 grpc-stream-lab:local

Keep plaintext only for this isolated local lab. A production-shaped test must use the actual TLS, ingress, service mesh, load balancer, keepalive, and HTTP/2 settings because each can alter connection reuse, maximum concurrent streams, and idle timeouts.

Verification: docker stats grpc-stream-lab --no-stream must show the container and its 512 MiB memory limit. grpcurl -plaintext -d '{"count":3,"intervalMs":10}' localhost:50051 watch.v1.WatchService/Watch should print sequences 1, 2, and 3, then exit successfully.

Step 5: Use k6 to Performance Test gRPC Streaming Services

Create stream.js. The current k6/net/grpc API exports Client and Stream. A stream emits data, error, and end events. This test treats end as clean completion, treats error or a five-second timeout as failure, and validates count and order before recording the result.

import { check, sleep } from 'k6';
import { Client, Stream } from 'k6/net/grpc';
import { Counter, Rate, Trend } from 'k6/metrics';

const client = new Client();
client.load([], 'proto/watch.proto');

const firstMessage = new Trend('grpc_time_to_first_message', true);
const streamDuration = new Trend('grpc_stream_duration', true);
const badSequence = new Counter('grpc_bad_sequence');
const streamFailures = new Rate('grpc_stream_failures');

export const options = {
  scenarios: {
    streams: { executor: 'constant-vus', vus: 50, duration: '2m' },
  },
  thresholds: {
    grpc_stream_failures: ['rate<0.01'],
    grpc_bad_sequence: ['count==0'],
    grpc_time_to_first_message: ['p(95)<250'],
    grpc_stream_duration: ['p(95)<2500'],
  },
};

export default function () {
  client.connect('localhost:50051', { plaintext: true });
  const started = Date.now();
  let received = 0;
  let ordered = true;
  let finished = false;
  const stream = new Stream(client, 'watch.v1.WatchService/Watch');

  stream.on('data', (event) => {
    received += 1;
    if (received === 1) firstMessage.add(Date.now() - started);
    if (Number(event.sequence) !== received) {
      ordered = false;
      badSequence.add(1);
    }
  });

  stream.on('error', () => {
    if (finished) return;
    finished = true;
    streamDuration.add(Date.now() - started);
    streamFailures.add(true);
    client.close();
  });

  stream.on('end', () => {
    if (finished) return;
    finished = true;
    streamDuration.add(Date.now() - started);
    const complete = received === 20 && ordered;
    check(null, { 'received 20 ordered messages': () => complete });
    streamFailures.add(!complete);
    client.close();
  });

  stream.write({ count: 20, intervalMs: 100 });
  stream.end();
  sleep(5);

  if (!finished) {
    finished = true;
    streamDuration.add(Date.now() - started);
    streamFailures.add(true);
    client.close();
  }
}

The five-second sleep keeps the VU alive while k6 dispatches stream events and provides a timeout above the expected two-second stream. Use one connection per iteration here to exercise setup. If production clients retain channels, connect on the first VU iteration and reuse the channel instead. Match the real lifecycle.

Verification: run k6 run stream.js. The summary must show grpc_bad_sequence equal to zero, grpc_stream_failures below one percent, a passing message check, and populated timing trends. Deliberately change the expected count to 21 once and confirm the check and failure-rate threshold fail, then restore 20.

Step 6: Run Baseline and Capacity Profiles

Start with one virtual user for two minutes. This establishes fixture timing and client overhead without meaningful contention. Then test stages such as 10, 25, 50, 100, and 200 concurrent streams, holding each level long enough to complete several stream lifecycles.

export const options = {
  scenarios: {
    capacity: {
      executor: 'ramping-vus',
      startVUs: 1,
      stages: [
        { duration: '1m', target: 10 },
        { duration: '2m', target: 25 },
        { duration: '2m', target: 50 },
        { duration: '2m', target: 100 },
        { duration: '2m', target: 200 },
        { duration: '1m', target: 0 },
      ],
      gracefulRampDown: '10s',
    },
  },
};

Save JSON results and container samples:

k6 run --summary-export results/capacity-summary.json stream.js \
  | tee results/capacity-console.txt
docker stats grpc-stream-lab --no-stream \
  > results/container-after.txt

The capacity point is not simply the highest level that completes. Find the first sustained stage where a contract threshold fails, message gaps grow, terminal errors rise, or a constrained resource approaches exhaustion. Repeat the boundary levels at least three times after returning the environment to the same baseline.

Verification: confirm the summary file is valid JSON and contains the custom metrics. Ensure the achieved VU stages match the plan and no load-generator resource is saturated. A client bottleneck can look like a stable server.

Step 7: Diagnose the First Broken Threshold

When a threshold fails, align all evidence on one timeline. On the server, capture CPU, resident memory, goroutine count, garbage collection pauses, active HTTP/2 streams, send queue delay, and RPC status counts. At the container or pod layer, capture throttling, memory working set, restarts, network retransmits, and socket counts.

Interpret patterns carefully:

Pattern Likely area Next check
First-message latency rises, later cadence is stable dispatch or setup accept queue, TLS, handler scheduling
Inter-message gaps grow across all streams producer or CPU runnable goroutines, throttling, GC
Only some streams stall flow control or slow consumers per-stream windows, client receive rate
RESOURCE_EXHAUSTED rises explicit server or proxy limit concurrent-stream and quota settings
UNAVAILABLE appears during a spike connection path proxy resets, load balancer, pod readiness
Memory grows after streams close retention or leaked goroutines heap profiles, cancellation paths

Do not automatically increase an HTTP/2 limit. A low maximum concurrent streams setting may protect the server. Decide whether to raise it, add instances, reject earlier, or change client channel pooling based on the capacity contract.

Verification: write one evidence-based cause statement, such as, At 100 active streams, CPU throttling began and p95 inter-message delay rose; error rate stayed flat. It must name the load, resource signal, user-visible metric, and time relationship. If evidence cannot support that statement, collect more telemetry before tuning.

Step 8: Add Spike and Soak Coverage

A capacity ramp finds a boundary under gradual growth. A spike test opens many streams quickly and exposes connection storms, HTTP/2 negotiation cost, queue limits, cold pods, and retry amplification. Use an externally controlled arrival rate or rapid VU stage only if the generator can achieve it.

A soak test holds a realistic number of streams for hours. Use variable stream lifetimes, normal client cancellation, reconnect jitter, and periodic messages. Track memory after streams close, goroutine count, file descriptors, connection count, token refresh, idle timeout behavior, and broker consumer lag. The detect memory leaks in long-running load tests tutorial explains how to distinguish healthy cache growth from retained objects. Review performance test result analysis techniques before you compare soak runs so you separate normal variance from a real regression.

If Kubernetes autoscaling protects the service, test the stream workload alongside the Kubernetes HPA load testing tutorial. Existing streams do not automatically rebalance when new pods appear, so new capacity may only receive newly established streams.

k6 run --summary-export results/soak-summary.json stream.js
sha256sum stream.js proto/watch.proto go.sum \
  > results/artifact-checksums.txt

Verification: after the soak stops and clients disconnect, active streams and goroutines should return near the measured idle baseline within a defined recovery window. Memory may not immediately return to the operating system, so use heap retention and repeated-run trends rather than RSS alone.

Troubleshooting

UNIMPLEMENTED or method not found -> Confirm the package, service, and method are exactly watch.v1.WatchService/Watch. Regenerate bindings after changing the protobuf contract and ensure k6 loads the same file.

The stream opens but receives no messages -> Check that the client writes the request and half-closes its send side with end(). Inspect server logs for validation failures and confirm an intermediary supports gRPC over HTTP/2 instead of downgrading to HTTP/1.1.

Streams reset near the same concurrency -> Inspect server, proxy, and load balancer maximum concurrent stream settings. Also check file descriptor limits, connection age, idle timeouts, and GOAWAY frames before increasing a limit.

First-message timing is empty -> The data handler did not execute. Fail the stream explicitly, inspect connection errors and the end event, and verify plaintext versus TLS configuration. Never treat a missing metric as zero latency.

Results improve when the server slows down -> The generator may be coordinated with response time or resource constrained. Monitor generator CPU and dropped work, distribute load if needed, and use an arrival model that preserves offered demand for connection spikes.

Memory remains high after the test -> Compare heap profiles and goroutine dumps before load, during steady state, and after a recovery window. Check cancellation handling, channel buffers, message pooling, tracing exporters, and per-stream labels.

Where To Go Next

Return to the complete cloud-native performance testing guide to place stream results beside platform capacity, resilience, and observability checks. Then extend this fixture in the direction that matches your architecture:

For client streaming or bidirectional streaming, add realistic send cadence, half-close behavior, backpressure, server acknowledgments, and slow-reader cases. Keep the same evidence model: prove the intended messages were offered, accepted, delivered, ordered, and terminated correctly.

Interview Questions and Answers

Q: Why is requests per second insufficient for gRPC streaming?

One RPC can remain active for minutes and carry thousands of messages. Capacity depends on concurrent streams, connection lifetime, message rate, message size, and flow control as well as RPC starts. Report those dimensions separately.

Q: Which latency metrics matter for server streaming?

Measure connection or RPC setup, time to first message, inter-message delay, end-to-end message age when timestamps are trustworthy, and complete stream duration. Percentiles should be associated with the correct workload stage and stream outcome.

Q: How do you validate a streaming response under load?

Count messages, check sequence or unique identifiers, validate representative payload fields, and assert the terminal gRPC status. Track duplicates, gaps, reordering, and silent timeouts as explicit metrics rather than relying only on transport success.

Q: What does HTTP/2 flow control change in a load test?

Flow control limits outstanding data at connection and stream scopes. A slow reader can consume its window and block delivery, while many streams can contend on a shared connection. Test realistic reader speed and channel pooling before blaming server compute.

Q: How would you find the maximum safe stream concurrency?

Increase concurrency in controlled stages while holding message size, cadence, and duration stable. The safe limit is the highest repeatable stage that meets integrity, latency, error, and resource-headroom criteria, not merely the last stage without a crash.

Q: Why test cancellation during a soak?

Real clients disconnect, time out, and change networks. Cancellation should stop producer work, release buffers, end downstream subscriptions, and reduce active-stream metrics. Failure to clean up becomes memory, goroutine, socket, or broker-consumer growth.

Best Practices

  • Do model stream concurrency, duration, cadence, and payload size. Do not reuse a unary request model unchanged.
  • Do validate sequence, count, and terminal status. Do not equate an opened stream with success.
  • Do separate first-message and full-duration latency. Do not hide startup delays in one aggregate.
  • Do reuse channels when real clients reuse them, and reconnect when real clients reconnect.
  • Do monitor the generator and every intermediary. Do not assume all delay originates in the service.
  • Do test slow readers, cancellation, deadlines, and retry behavior. Do not test only clean completion.
  • Do pin contracts, images, dependencies, and configuration. Do not compare runs with unknown drift.
  • Do repeat capacity boundaries and preserve raw evidence. Do not publish a single lucky run.

Conclusion

To performance test gRPC streaming services reliably, make the stream lifecycle the unit of quality. Drive realistic concurrency and message cadence, measure first-message and ongoing delivery timing, validate content and terminal status, and correlate failures with HTTP/2, runtime, proxy, and infrastructure evidence.

Begin with the deterministic fixture, prove the harness detects broken sequences and missing events, then replace its workload with production-shaped stream lifetimes and payloads. Run capacity, spike, and soak profiles, preserve every input and raw output, and turn the first broken threshold into a repeatable engineering finding.

Interview Questions and Answers

How would you design a performance test for a server-streaming gRPC method?

I would define stream starts, concurrent streams, lifetime, message cadence, size, and cancellation behavior from production evidence. The test would measure time to first message, inter-message delay, duration, status, and integrity while recording server, proxy, and infrastructure telemetry. I would run baseline, staged capacity, spike, and soak profiles and repeat the capacity boundary.

Why is unary RPC throughput not a substitute for streaming capacity?

Unary tests emphasize request setup and discrete responses, while streams retain per-stream state and repeatedly use HTTP/2 flow-control windows. Streaming capacity also depends on active duration, reader speed, channel pooling, and message cadence. A service can pass unary throughput tests and still stall or leak under persistent streams.

What evidence indicates HTTP/2 flow-control pressure?

Message delivery gaps may rise while compute remains available, especially for slow readers or streams sharing one connection. I would compare per-stream and connection windows, blocked sends, client receive rate, channel layout, and network throughput. A controlled slow-consumer test helps reproduce the pattern.

How do you decide a safe concurrent-stream limit?

I increase concurrency in stable stages with fixed cadence and payloads, then find the first repeatable breach of latency, integrity, error, or resource-headroom criteria. The safe operating point stays below that boundary and accounts for traffic variation and failure conditions. I also verify that the generator achieved the intended load.

How should cancellation be tested in gRPC streaming?

Cancel streams at different lifecycle points and verify the server observes context cancellation promptly. Producer work, buffers, subscriptions, goroutines, and active-stream metrics should return toward baseline within a defined window. I would include client deadlines, abrupt disconnects, and graceful half-close behavior where applicable.

How do you distinguish a server bottleneck from a load-generator bottleneck?

I monitor generator CPU, memory, sockets, event-loop delay, and achieved workload alongside server signals. I distribute load or reduce client-side processing and repeat the same offered profile. If server results change when generator headroom changes, the original test did not establish the intended load.

What should be stored with a gRPC streaming benchmark result?

Store the protobuf contract or descriptor, load script, immutable server and generator identities, configuration for TLS and intermediaries, environment capacity, workload profile, raw summaries, telemetry, and timestamps. Include integrity failures and generator health, not only selected latency percentiles.

Frequently Asked Questions

How do you performance test gRPC streaming services?

Open realistic numbers of concurrent streams, send or receive production-shaped messages, and validate each lifecycle. Measure time to first message, inter-message delay, stream duration, errors, integrity, and server resource use together.

Can k6 test gRPC server streaming?

Yes. Current k6 versions provide gRPC streaming support with event handlers for data, errors, and clean stream completion. Pin a validated version, load the protobuf contract or descriptors, and verify message count, sequence, error events, and clean completion in addition to custom timing metrics.

What is the best metric for gRPC stream performance?

There is no single best metric. Use concurrent streams, time to first message, inter-message delay, message throughput, total duration, terminal status, and content integrity, then correlate them with resource and HTTP/2 signals.

How many concurrent gRPC streams can a service handle?

The safe number depends on message size and cadence, stream lifetime, connection pooling, HTTP/2 settings, server resources, dependencies, and service objectives. Find it with repeatable staged tests and retain operational headroom rather than using a universal number.

Why do gRPC streams fail behind a proxy?

The proxy may lack end-to-end HTTP/2 support or enforce stream, connection-age, idle-timeout, buffer, or message-size limits. Compare direct and proxied runs, inspect status and GOAWAY behavior, and verify every hop's gRPC configuration.

How long should a gRPC streaming soak test run?

Run long enough to cross relevant token refresh, connection-age, idle-timeout, deployment, garbage-collection, and dependency cycles. Define a recovery window after disconnect and compare heap, goroutine, socket, and active-stream baselines across repeated runs.

Should a gRPC streaming test reuse channels?

Match the real client. Reuse channels if production clients keep them, but include reconnect and connection-spike scenarios when networks or deployments force channel replacement.

Related Guides