Resource library

QA How-To

How to Use k6 to Test gRPC Streaming APIs (2026)

Learn how to k6 test gRPC streaming APIs with runnable server, client, and bidirectional scripts, custom metrics, thresholds, and debugging steps today.

18 min read | 2,159 words

TL;DR

Use k6/net/grpc Stream with an async default function, event handlers, and an awaited Promise. Validate every message and enforce custom latency, error, and count thresholds under realistic concurrent-stream load.

Key Takeaways

  • Use Client and Stream from k6/net/grpc for server, client, and bidirectional streaming.
  • Make the default function async and await a Promise resolved by the stream end event.
  • Measure first-message, per-message, and total stream latency separately.
  • Correlate duplex responses and assert counts, gaps, duplicates, and application results.
  • Reuse connections for steady-state tests and reconnect only when handshake cost is in scope.
  • Model concurrent streams and message cadence instead of relying on request rate alone.

To k6 test grpc streaming apis effectively, model a complete stream as a business transaction, keep the k6 default function asynchronous, and measure message timing separately from stream completion. A passing connection is not enough. Your test must prove that expected messages arrive, ordering rules hold, errors remain controlled, and latency stays inside service objectives under concurrent streams.

This tutorial builds a runnable local lab around the official RouteGuide example shape. You will define a small Protocol Buffers contract, start a Node.js gRPC server, and exercise server-streaming, client-streaming, and bidirectional-streaming RPCs with k6. If you need a broader foundation first, read the gRPC API testing guide and the k6 load testing tutorial.

TL;DR

Streaming type Client sends Server sends Main k6 assertion
Server streaming One request Many messages Count, content, first-message latency
Client streaming Many messages One summary Acknowledged count and total duration
Bidirectional Many messages Many messages Correlation, ordering, loss, per-message latency

Use Client and Stream from k6/net/grpc. Register data, error, and end handlers before writing. Await a Promise so handlers execute before the iteration exits. Add custom Trend, Rate, and Counter metrics because a single stream duration cannot reveal slow first responses, lost messages, or application-level failures.

What You Will Build

By the end, you will have:

  • A local gRPC service exposing all three streaming patterns.
  • A smoke script that validates the contract with one virtual user.
  • A load script that opens concurrent bidirectional streams.
  • Custom metrics for first-message latency, message latency, stream duration, errors, and received messages.
  • Thresholds that turn performance and correctness requirements into a failing process exit code.

The lab uses an intentionally small message schema so you can see every correlation rule. The same structure applies to event feeds, telemetry ingestion, chat, market data, and device command channels.

Prerequisites

Use these exact baseline versions for reproducibility:

  • k6 1.2.2 or a newer 1.x release with k6/net/grpc streaming support.
  • Node.js 22 LTS.
  • npm 10 or newer.
  • Protocol Buffers compiler protoc 29.x or newer, optional for descriptor inspection.
  • macOS, Linux, or Windows with two terminals.

Verify the tools:

k6 version
node --version
npm --version
protoc --version

Create a clean lab and install the server dependencies:

mkdir k6-grpc-streaming-lab
cd k6-grpc-streaming-lab
npm init -y
npm install @grpc/grpc-js@1.13.4 @grpc/proto-loader@0.7.15
mkdir -p proto server tests

Verification: npm ls @grpc/grpc-js @grpc/proto-loader must show both packages without UNMET DEPENDENCY. If your organization pins later compatible versions, record them with the test results.

Step 1: Define the Streaming Contract

Create proto/telemetry.proto:

syntax = "proto3";

package telemetry;

service Telemetry {
  rpc WatchDevice(WatchRequest) returns (stream Reading);
  rpc UploadReadings(stream Reading) returns (UploadSummary);
  rpc Exchange(stream Command) returns (stream CommandResult);
}

message WatchRequest {
  string device_id = 1;
  int32 limit = 2;
}

message Reading {
  string device_id = 1;
  int64 sequence = 2;
  double value = 3;
}

message UploadSummary {
  int32 accepted = 1;
  double average = 2;
}

message Command {
  string request_id = 1;
  string device_id = 2;
  string action = 3;
}

message CommandResult {
  string request_id = 1;
  bool accepted = 2;
  string detail = 3;
}

The three method signatures determine stream direction. WatchDevice has stream only on the response. UploadReadings has it only on the request. Exchange uses it on both sides. Stable numeric field tags matter more than field order because encoded data identifies fields by tag.

The sequence field lets the test detect duplicates and gaps. request_id provides end-to-end correlation for duplex traffic. Do not rely on arrival position alone when the production server is allowed to process messages concurrently.

Verification:

protoc --descriptor_set_out=/tmp/telemetry.pb proto/telemetry.proto
test -s /tmp/telemetry.pb && echo "descriptor OK"

The command should print descriptor OK. If protoc is unavailable, the Node server and k6 can still load the source proto directly.

Step 2: Start a Deterministic gRPC Server

Create server/server.js:

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const path = require('node:path');

const definition = protoLoader.loadSync(
  path.join(__dirname, '../proto/telemetry.proto'),
  { keepCase: false, longs: String, defaults: true, oneofs: true }
);
const telemetry = grpc.loadPackageDefinition(definition).telemetry;

function watchDevice(call) {
  const limit = Math.max(1, Math.min(call.request.limit || 5, 100));
  let sequence = 1;
  const timer = setInterval(() => {
    call.write({
      deviceId: call.request.deviceId,
      sequence,
      value: 20 + sequence / 10,
    });
    sequence += 1;
    if (sequence > limit) {
      clearInterval(timer);
      call.end();
    }
  }, 25);
  call.on('cancelled', () => clearInterval(timer));
}

function uploadReadings(call, callback) {
  let accepted = 0;
  let total = 0;
  call.on('data', (reading) => {
    accepted += 1;
    total += reading.value;
  });
  call.on('end', () => callback(null, {
    accepted,
    average: accepted === 0 ? 0 : total / accepted,
  }));
}

function exchange(call) {
  call.on('data', (command) => {
    call.write({
      requestId: command.requestId,
      accepted: command.action.length > 0,
      detail: command.action.length > 0 ? 'queued' : 'missing action',
    });
  });
  call.on('end', () => call.end());
}

const server = new grpc.Server();
server.addService(telemetry.Telemetry.service, {
  watchDevice,
  uploadReadings,
  exchange,
});
server.bindAsync(
  '127.0.0.1:50051',
  grpc.ServerCredentials.createInsecure(),
  (error) => {
    if (error) throw error;
    console.log('Telemetry gRPC server listening on 127.0.0.1:50051');
  }
);

This server is deliberately predictable. Server-streamed readings arrive every 25 milliseconds, uploads return one aggregate, and each duplex command produces one correlated result. Predictability makes the first test useful. Add jitter, backpressure, and injected faults only after the basic oracle passes.

Start it in terminal one:

node server/server.js

Verification: Expect Telemetry gRPC server listening on 127.0.0.1:50051. Keep the process running. In another terminal, nc -z 127.0.0.1 50051 && echo "port open" should print port open.

Step 3: k6 Test gRPC Streaming APIs With a Server Stream

Create tests/server-stream.js:

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

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

const firstMessageMs = new Trend('grpc_first_message_ms', true);
const streamDurationMs = new Trend('grpc_stream_duration_ms', true);
const messagesReceived = new Counter('grpc_messages_received');
const streamErrors = new Rate('grpc_stream_errors');

export const options = {
  vus: 1,
  iterations: 1,
  thresholds: {
    checks: ['rate==1'],
    grpc_stream_errors: ['rate==0'],
    grpc_messages_received: ['count==5'],
    grpc_first_message_ms: ['p(95)<250'],
  },
};

export default async function () {
  client.connect(__ENV.GRPC_ADDR || '127.0.0.1:50051', { plaintext: true });

  const started = Date.now();
  let firstAt = 0;
  const readings = [];
  let failed = false;
  const stream = new Stream(
    client,
    'telemetry.Telemetry/WatchDevice',
    { tags: { rpc: 'WatchDevice' } }
  );

  await new Promise((resolve, reject) => {
    stream.on('data', (reading) => {
      if (firstAt === 0) {
        firstAt = Date.now();
        firstMessageMs.add(firstAt - started);
      }
      readings.push(reading);
      messagesReceived.add(1);
    });
    stream.on('error', (error) => {
      failed = true;
      streamErrors.add(true);
      reject(error);
    });
    stream.on('end', () => {
      streamErrors.add(false);
      streamDurationMs.add(Date.now() - started);
      resolve();
    });
    stream.write({ deviceId: 'device-7', limit: 5 });
  });

  check(readings, {
    'received exactly five readings': (items) => items.length === 5,
    'all readings belong to device': (items) =>
      items.every((item) => item.deviceId === 'device-7'),
    'sequence is contiguous': (items) =>
      items.every((item, index) => Number(item.sequence) === index + 1),
    'stream emitted no error': () => !failed,
  });
  client.close();
}

The default function is async and awaits a Promise. Without that shape, k6 can finish the iteration before stream callbacks run. Handlers are attached before stream.write, preventing a fast server response from racing handler registration.

Run it from the lab root:

k6 run tests/server-stream.js

Verification: The summary must show 100 percent checks, grpc_messages_received equal to 5, and zero grpc_stream_errors. The illustrative 250 ms threshold leaves room for local scheduling; replace it with your measured service objective.

Step 4: Validate Client Streaming and Half-Close Behavior

Create tests/client-stream.js:

import { check } from 'k6';
import { Client, Stream } from 'k6/net/grpc';

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

export const options = {
  vus: 1,
  iterations: 1,
  thresholds: { checks: ['rate==1'] },
};

export default async function () {
  client.connect(__ENV.GRPC_ADDR || '127.0.0.1:50051', { plaintext: true });
  const summaries = [];
  const stream = new Stream(client, 'telemetry.Telemetry/UploadReadings');

  await new Promise((resolve, reject) => {
    stream.on('data', (summary) => summaries.push(summary));
    stream.on('error', reject);
    stream.on('end', resolve);

    for (let sequence = 1; sequence <= 4; sequence += 1) {
      stream.write({
        deviceId: 'device-7',
        sequence,
        value: 10 * sequence,
      });
    }
    stream.end();
  });

  check(summaries, {
    'one summary returned': (items) => items.length === 1,
    'server accepted four readings': (items) => items[0]?.accepted === 4,
    'average equals 25': (items) => items[0]?.average === 25,
  });
  client.close();
}

stream.end() half-closes the client side. It tells the server that no more request messages will arrive, but the test must continue waiting for the response and the server's end event. Calling client.close() before that event can discard the summary.

Run:

k6 run tests/client-stream.js

Verification: All three checks must pass. If the run waits forever, first confirm that the client calls stream.end(), then confirm the server registers its request-side end handler.

Step 5: Exercise Bidirectional Correlation

Create tests/bidi-smoke.js:

import { check } from 'k6';
import { Client, Stream } from 'k6/net/grpc';

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

export const options = {
  vus: 1,
  iterations: 1,
  thresholds: { checks: ['rate==1'] },
};

export default async function () {
  client.connect(__ENV.GRPC_ADDR || '127.0.0.1:50051', { plaintext: true });
  const sent = new Set();
  const received = new Set();
  const stream = new Stream(client, 'telemetry.Telemetry/Exchange');

  await new Promise((resolve, reject) => {
    stream.on('data', (result) => received.add(result.requestId));
    stream.on('error', reject);
    stream.on('end', resolve);

    for (let index = 0; index < 3; index += 1) {
      const requestId = `vu-${__VU}-iter-${__ITER}-cmd-${index}`;
      sent.add(requestId);
      stream.write({
        requestId,
        deviceId: 'device-7',
        action: 'sample',
      });
    }
    stream.end();
  });

  check(received, {
    'one result per command': (ids) => ids.size === sent.size,
    'every result is correlated': (ids) =>
      [...sent].every((requestId) => ids.has(requestId)),
  });
  client.close();
}

Sets make the assertion independent of response order while still detecting missing correlations. They do not detect duplicates by themselves because a Set collapses duplicates. In a production script, keep both a received counter and a Set when duplicate delivery is a failure.

k6 run tests/bidi-smoke.js

Verification: Both checks must pass. Change one server response to a fixed requestId temporarily and confirm every result is correlated fails. Restore the server before continuing.

Step 6: Add Streaming Metrics and Thresholds

A stream has several clocks. Connection setup, time to first message, inter-message delay, request-to-correlated-response latency, and total lifetime answer different questions. Built-in gRPC duration metrics alone cannot express all of them.

Create tests/bidi-load.js:

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

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

const messageLatency = new Trend('grpc_message_latency_ms', true);
const streamDuration = new Trend('grpc_stream_duration_ms', true);
const streamFailures = new Rate('grpc_stream_failures');
const receivedMessages = new Counter('grpc_received_messages');

export const options = {
  scenarios: {
    concurrent_streams: {
      executor: 'constant-vus',
      vus: Number(__ENV.VUS || 10),
      duration: __ENV.DURATION || '30s',
      gracefulStop: '5s',
    },
  },
  thresholds: {
    checks: ['rate>0.99'],
    grpc_stream_failures: ['rate<0.01'],
    grpc_message_latency_ms: ['p(95)<200', 'p(99)<500'],
    grpc_stream_duration_ms: ['p(95)<2000'],
  },
};

export default async function () {
  if (__ITER === 0) {
    client.connect(__ENV.GRPC_ADDR || '127.0.0.1:50051', {
      plaintext: true,
      timeout: '5s',
    });
  }

  const pending = new Map();
  const results = [];
  const started = Date.now();
  let failed = false;
  const stream = new Stream(client, 'telemetry.Telemetry/Exchange', {
    tags: { rpc: 'Exchange', stream_type: 'bidi' },
  });

  try {
    await new Promise((resolve, reject) => {
      stream.on('data', (result) => {
        const sentAt = pending.get(result.requestId);
        if (sentAt !== undefined) {
          messageLatency.add(Date.now() - sentAt);
          pending.delete(result.requestId);
        }
        results.push(result);
        receivedMessages.add(1);
      });
      stream.on('error', (error) => {
        failed = true;
        reject(error);
      });
      stream.on('end', resolve);

      for (let index = 0; index < 5; index += 1) {
        const requestId = `${__VU}-${__ITER}-${index}`;
        pending.set(requestId, Date.now());
        stream.write({
          requestId,
          deviceId: `device-${__VU}`,
          action: 'sample',
        });
      }
      stream.end();
    });
  } finally {
    streamFailures.add(failed);
    streamDuration.add(Date.now() - started);
  }

  check(results, {
    'received five results': (items) => items.length === 5,
    'no correlations remain pending': () => pending.size === 0,
    'all commands accepted': (items) => items.every((item) => item.accepted),
  });
}

export function teardown() {
  client.close();
}

The client connects once per VU because each VU owns its JavaScript runtime and client instance. Each iteration creates a fresh logical stream on the reused HTTP/2 connection. That distinction matters: reconnecting per stream benchmarks TLS and connection setup, while reuse focuses on steady-state multiplexing.

Run a small load first:

VUS=10 DURATION=30s k6 run tests/bidi-load.js

Verification: Confirm the iteration count advances, checks exceed 99 percent, grpc_stream_failures remains below 1 percent, and both latency thresholds pass. A local deterministic server should finish quickly, but do not publish its numbers as capacity evidence.

Step 7: Shape Load Around Concurrent Streams

Streaming capacity is governed by open streams and message rate, not merely completed requests per second. A constant-vus executor is useful when one VU maintains one active stream at a time. A constant-arrival-rate executor is useful when you must start a defined number of new stream sessions per second, but k6 may allocate more VUs to sustain that rate.

Goal Executor Watch closely
Hold concurrent sessions constant-vus Active streams, memory, connection reuse
Start streams at a fixed rate constant-arrival-rate Dropped iterations, allocated VUs
Model staged concurrency ramping-vus Recovery after each stage
Find a breaking point Incremental staged test Error codes, saturation, queue growth

For a staged test, replace the scenario in bidi-load.js with:

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

Keep your existing thresholds when making this replacement. Run smoke, baseline, load, stress, and soak as separate jobs so each answers one capacity question. See k6 scenarios and executors for scheduling details and k6 thresholds and checks for release gates.

Verification: During a ramp, the k6 progress line should track the target VU stage. If dropped_iterations appears in an arrival-rate version, the generator lacks available VUs or the system cannot finish iterations at the requested rate.

Step 8: Test Metadata, TLS, and Failure Paths

Production services usually require metadata and TLS. Pass stream parameters as the third Stream constructor argument:

const stream = new Stream(client, 'telemetry.Telemetry/Exchange', {
  metadata: {
    authorization: `Bearer ${__ENV.ACCESS_TOKEN}`,
    'x-tenant-id': __ENV.TENANT_ID,
  },
  tags: {
    rpc: 'Exchange',
    tenant: __ENV.TENANT_ID || 'unset',
  },
  timeout: '10s',
});

For TLS, remove plaintext: true from client.connect and use the service address without an https:// prefix. Never print access tokens. Avoid high-cardinality metric tags such as request IDs and device IDs because every unique tag set creates another time series.

Negative tests should be separate from capacity tests. Send a missing action and expect accepted: false. Use an expired token and expect an authentication error. Cancel or time out a slow stream and verify server cleanup. Exceed documented message limits and assert the exact gRPC status. This makes the error budget interpretable instead of mixing deliberately rejected traffic with unexpected failures.

Run the secured environment like this:

GRPC_ADDR=api.example.test:443 \
ACCESS_TOKEN="$TEST_ACCESS_TOKEN" \
TENANT_ID=load-test \
k6 run tests/bidi-load.js

Verification: On a secure endpoint, the TLS handshake must succeed, application results must correlate, and test output must contain no token value. On the local plaintext lab, keep plaintext: true; otherwise the connection fails because the sample server has no certificate.

How to Interpret k6 Test gRPC Streaming APIs Results

Start with correctness. If message counts, sequence checks, or correlations fail, percentiles describe an invalid transaction and should not approve a release. Next, compare first-message latency with per-message latency. A slow first response with fast later messages points toward admission, authentication, cold caches, or initial query work. Increasing inter-message gaps can indicate downstream production delays, flow control, CPU contention, or garbage collection.

Read client and server telemetry together. k6 tells you the caller-observed result. Server metrics explain whether saturation came from CPU, memory, thread pools, HTTP/2 connection limits, concurrent stream limits, or a dependency. Export traces when you need to follow one correlation ID across gateways and workers; the k6 OpenTelemetry traces tutorial covers that workflow.

Do not compare a 30-second localhost run with production capacity. Use production-like payload sizes, stream lifetimes, metadata, network delay, and message cadence. Warm the service deliberately, then preserve both warm and cold measurements if real traffic includes cold starts. Report concurrency, messages per second, payload distribution, error statuses, and test-generator utilization alongside latency percentiles.

Best Practices

  • Define a stream-level success rule and message-level success rules. Both must pass.
  • Attach all handlers before the first write.
  • Await stream completion with a Promise inside an async default function.
  • Use unique correlation IDs, but keep them out of metric tags.
  • Measure first-message, per-message, and whole-stream time independently.
  • Reuse connections when modeling steady state; reconnect intentionally when testing handshakes.
  • Bound stream lifetime so a server defect cannot hang the test indefinitely.
  • Keep payload content and cadence realistic instead of writing messages as fast as JavaScript can loop.
  • Validate sequence gaps, duplicates, and unexpected messages, not only total count.
  • Run the generator near the service region and monitor generator CPU and network.
  • Ramp gradually and include a recovery stage.
  • Version the proto file beside the load script so results remain reproducible.

For a wider performance strategy, use the API performance testing tutorial. If streaming tests expose service-wide bottlenecks, the microservices performance testing guide helps connect endpoint behavior to dependencies.

Troubleshooting

Problem: method not found when constructing Stream. -> Confirm that client.load(['proto'], 'telemetry.proto') runs in init context and that the method is spelled telemetry.Telemetry/Exchange. Package, service, and method names are case-sensitive.

Problem: callbacks run after the iteration or no messages are counted. -> Declare export default async function and await a Promise resolved by the stream's end handler. Register handlers before calling write.

Problem: a client-streaming test never ends. -> Call stream.end() after the final request message. This half-close allows the server to calculate and return its summary.

Problem: connection error: desc = transport appears locally. -> Use an address shaped like 127.0.0.1:50051, without a URL scheme, and set plaintext: true for this lab. Check that the Node process still owns the port.

Problem: latency looks good while messages are missing. -> Add an expected count, a received counter, unique correlation IDs, and a pending-ID check. A quick incomplete stream is a correctness failure, not a performance success.

Problem: metrics explode in the backend. -> Remove request IDs, user IDs, and device IDs from tags. Keep bounded dimensions such as RPC name, scenario, environment, and result class.

Interview Questions and Answers

The interview-ready questions for this tutorial are included in the structured section below. Focus on explaining async event handling, half-close semantics, correlation, custom metrics, connection reuse, and workload modeling with concrete examples from the lab.

Where To Go Next

Move from the deterministic lab to a representative staging environment. Replace the sample proto, address, metadata, message builder, and assertions while preserving the Promise lifecycle and metric model. Run one VU first, record a baseline, then ramp concurrency only after every message-level check passes.

Continue with performance testing gRPC streaming services for system-level planning, k6 distributed load testing with the Kubernetes Operator when one generator is insufficient, and k6 scripting interview questions to practice explaining the design. You can also use the QA practice workspace to sharpen performance-testing scenarios.

Conclusion

A reliable k6 streaming test treats each stream as an asynchronous conversation, not a large unary request. Load the real proto, attach event handlers, await completion, correlate every response, and separate message timing from total lifetime.

Start with the three smoke scripts in this guide. Once their correctness checks are stable, introduce production authentication, realistic pacing, controlled concurrency, and observability. That sequence gives you results a team can use for release decisions instead of a graph that merely proves a connection opened.

Interview Questions and Answers

How does k6 execute gRPC streaming callbacks?

Stream.on registers event handlers for data, error, and end events. The default function should be async and await a Promise resolved on end, otherwise the iteration can return before callbacks execute. I register handlers before the first write to avoid races.

What is half-close in client-streaming gRPC?

Half-close means the client signals that it has finished sending by calling stream.end(), while its receive side remains available. The server can then aggregate the requests, send its final response, and close its side. Closing the entire Client too early can lose that response.

How would you assert a bidirectional stream?

I assign a unique correlation ID to every request and keep the send timestamp in a Map. On each response I validate the ID, record its latency, and remove it. At stream end I assert no IDs remain, counts match, duplicates are absent, and application results are valid.

Which latency metrics matter for a streaming API?

I separate connection time, time to first message, inter-message delay, correlated message latency, and total stream duration. Each identifies a different bottleneck. A single duration percentile can hide a slow start or missing data.

How do you model gRPC streaming load?

I start from concurrent streams, stream lifetime, messages per stream, message cadence, and payload size. Constant or ramping VUs fit session concurrency, while an arrival-rate executor fits stream-start rate. I also verify the load generator is not the bottleneck.

Why reuse a gRPC connection during a load test?

gRPC multiplexes logical streams over HTTP/2 connections, so connection reuse usually matches steady-state production behavior. Reconnecting every iteration adds DNS, TCP, TLS, and HTTP/2 setup costs and answers a different question. I test both patterns only when both exist in production.

How do checks and thresholds differ in k6?

A check records whether an assertion passed but does not automatically abort or fail the process. A threshold evaluates an aggregate metric and determines the test exit status. I use checks for message correctness and thresholds for release criteria such as check rate, error rate, and latency percentiles.

What failure modes are unique to streaming performance tests?

Streams can open successfully yet lose, duplicate, reorder, or delay messages. They can also leak server resources after cancellation, stall without an end event, or hit HTTP/2 concurrent-stream and flow-control limits. Tests need message-level oracles and server telemetry to expose those cases.

Frequently Asked Questions

Does k6 support gRPC streaming?

Yes. The k6/net/grpc module supports server-streaming, client-streaming, and bidirectional-streaming RPCs through the Stream class. Load the proto, connect the Client, register handlers, write messages, and await the end event.

Why must a k6 gRPC streaming test use an async function?

Stream events execute when the JavaScript call stack is free. An async default function that awaits a Promise keeps the iteration alive so data, error, and end handlers can run before k6 records completion.

How do I measure time to first message in k6?

Record Date.now() immediately before creating or writing to the stream, then add the difference to a custom Trend in the first data handler invocation. Keep that metric separate from total stream duration.

Should a k6 streaming test reconnect for every iteration?

Usually not for steady-state traffic. Connect once per VU and create a new logical stream per iteration to model HTTP/2 connection reuse; reconnect deliberately when TLS, authentication, DNS, or connection establishment is the subject.

How do I detect missing messages in a bidirectional stream?

Assign each outgoing command a unique request ID and store it in a pending Map. Delete IDs as correlated responses arrive, then assert that the Map is empty and that received count matches sent count.

Which k6 executor is best for gRPC streaming?

Use constant-vus or ramping-vus when concurrent open sessions are the primary load dimension. Use constant-arrival-rate when the requirement specifies new stream starts per second, while monitoring dropped iterations and allocated VUs.

Can k6 test authenticated gRPC streams?

Yes. Supply authorization and tenant values in the metadata member of the Stream parameters and use a TLS connection for secure endpoints. Pass secrets through environment variables and never log them.

Related Guides