How gRPC streaming works in Node.js: Node streams over HTTP/2

Every gRPC method maps to one HTTP/2 stream. The stream keyword in the .proto file only controls how many length-prefixed messages each side may send on it. A method such as rpc Watch(WatchRequest) returns (stream Event) is server streaming. Put stream on the request for client streaming, or on both sides for bidirectional streaming.

How it works, step by step

  1. The client opens the call. It sends an HTTP/2 HEADERS frame on a new stream. The frame carries the method path (/package.Service/Method), the application/grpc content type, any metadata, and the deadline as a grpc-timeout header if one is set.
  2. Each side sends framed messages. Every protobuf message goes into DATA frames with a 5-byte prefix: a 1-byte compression flag followed by a 4-byte big-endian message length. The receiver uses the length to split the byte stream back into individual messages, whatever the frame boundaries are.
  3. Each side half-closes independently. When a sender has nothing more to send, it sets END_STREAM on its last frame. The peer sees this as the end of input, and it can keep sending in its own direction.
  4. The server finishes with trailers. The server sends a final HEADERS frame with grpc-status and an optional grpc-message. This status, not the last data message, tells the client whether the call succeeded.
  5. Flow control governs the rate. HTTP/2 WINDOW_UPDATE frames limit how much unacknowledged data each side may send, both per stream and per connection. That limit is what backpressure in Node ultimately rests on.

@grpc/grpc-js hides the framing and exposes each shape as a Node.js stream object:

Shape Client call object Server handler receives
Unary ClientUnaryCall + callback ServerUnaryCall + callback
Server streaming ClientReadableStream ServerWritableStream
Client streaming ClientWritableStream + callback ServerReadableStream + callback
Bidirectional ClientDuplexStream ServerDuplexStream
 client call object          HTTP/2 stream (one per RPC)          server call object
 ------------------   HEADERS (method, deadline) ------------>   ------------------
  write() ----------> DATA [len|msg] [len|msg] ... END_STREAM -->  'data' / 'end'
  'data' <---------- DATA [len|msg] [len|msg] ...  <----------     write()
  'status' <-------- HEADERS trailers: grpc-status, grpc-message
        <====== WINDOW_UPDATE flow control, per stream and per connection ======>

Moving bytes is the easy part. The real concerns are these:

  • Backpressure. The HTTP/2 window is the only limit on how fast a peer sends. If you ignore write() returning false, data buffers in process memory instead.
  • Cancellation. A RST_STREAM can arrive at any time, so handlers must stop work when it does.
  • Deadlines. A deadline applies to the whole stream, not to each message.
  • Errors. Failure is reported in the trailers, after any number of messages have already been delivered successfully.

Loading the schema at runtime with @grpc/proto-loader and grpc.loadPackageDefinition avoids a build step. The cost is that the result is loosely typed, so TypeScript cannot check method names or message fields. Generating types statically moves those mistakes to compile time, at the price of a generation step in your build.

// protos/watch.proto: one service, four shapes; only `stream` differs
// syntax = "proto3";
// package watch.v1;
// service WatchService {
//   rpc Get(WatchRequest) returns (Event);             // unary
//   rpc Watch(WatchRequest) returns (stream Event);    // server streaming
//   rpc Upload(stream Event) returns (UploadSummary);  // client streaming
//   rpc Sync(stream Event) returns (stream Event);     // bidirectional
// }
// message WatchRequest { string key = 1; }
// message Event { string key = 1; bytes value = 2; int64 revision = 3; }
// message UploadSummary { int64 count = 1; }

import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';

// Parse the .proto at startup. Nothing is generated at build time.
const definition = protoLoader.loadSync('./protos/watch.proto', {
  keepCase: true, // keep field names exactly as written in the .proto
  longs: String,  // int64 -> string, so revisions above 2^53 keep their precision
  enums: String,
  defaults: true,
  oneofs: true,
});

// The compiler only sees a generic GrpcObject, so you must cast to reach the service.
const proto = grpc.loadPackageDefinition(definition) as any;
const WatchService = proto.watch.v1.WatchService as grpc.ServiceClientConstructor;
const client = new WatchService('localhost:50051', grpc.credentials.createInsecure());

// Server streaming returns a ClientReadableStream. Requests and messages are untyped,
// so a typo like { kye: 'x' } still compiles and sends an empty key.
const call = client.Watch({ key: 'config/app' }, { deadline: Date.now() + 30_000 });
call.on('data', (event: any) => console.log(event.key, event.revision));
call.on('error', (err: grpc.ServiceError) => console.error(err.code, err.details));
call.on('status', (s: grpc.StatusObject) => console.log('trailers:', s.code, s.details));

Loads a four-method .proto at runtime with @grpc/proto-loader and opens a server-streaming call with a whole-stream deadline. The as any casts show where dynamic loading gives up TypeScript type checking that static code generation would provide.

Server streaming with call.write(), backpressure and client cancellation

A server-streaming handler receives a call object that is a Node writable stream. The handler sends each message with call.write(message) and signals a successful end of the stream with call.end(). Do not let the handler return early. The stream stays open until you end it or the call fails.

The return value of write() is how the stream signals backpressure. When it returns false, the outgoing buffer has passed its high-water mark. The client or the network is slower than your producer. Stop writing and wait for the 'drain' event before you produce the next message. If you ignore the return value, nothing throws and nothing is logged. grpc-js keeps queuing messages in memory, and the process grows until it slows down or runs out of memory. A paginated database export to a slow mobile client is the usual way this shows up.

Cancellation is the other half of flow control. If the client cancels, its deadline expires, or the connection drops, the server call emits 'cancelled' and sets call.cancelled to true. Check that flag inside your producer loop, and also before resuming after 'drain'. Otherwise the handler keeps running queries for a caller that has gone. A pending 'drain' wait also needs to be released when the call is cancelled, or the handler can hang.

On the client, consume the stream with for await...of. A non-OK status makes the loop throw, so wrap it in try/catch. If you attach listeners instead, handle 'error' explicitly. 'status' carries the final code and trailing metadata. 'end' only means that no more messages will arrive. Use the final status, not the end of the data, to decide whether the stream completed or failed.

const grpc = require('@grpc/grpc-js');

// Resolve on 'drain' OR 'cancelled' so a cancelled call never leaves the handler hanging.
function waitForDrainOrCancel(call) {
  return new Promise((resolve) => {
    const done = () => { call.off('drain', done); call.off('cancelled', done); resolve(); };
    call.once('drain', done);
    call.once('cancelled', done);
  });
}

// Server-streaming handler: page through a table and stream each row.
async function exportRows(call) {
  try {
    for (let page = 0; !call.cancelled; page++) {
      const rows = await db.fetchPage(call.request.tableId, page); // placeholder data source
      if (rows.length === 0) break;
      for (const row of rows) {
        if (call.cancelled) return; // client gone: stop producing work
        // false = buffer over high-water mark; pause instead of queuing in memory
        if (!call.write(row)) await waitForDrainOrCancel(call);
      }
    }
    if (!call.cancelled) call.end(); // OK status only after the last write
  } catch (err) {
    if (!call.cancelled) call.emit('error', { code: grpc.status.INTERNAL, details: 'export failed' });
  }
}

// Client: the final status, not the end of data, decides success.
async function runExport(client) {
  const call = client.exportRows({ tableId: 'orders' }, { deadline: Date.now() + 60_000 });
  call.on('status', (s) => console.log('final status', s.code, s.details));
  try {
    for await (const row of call) handleRow(row); // non-OK status makes this throw
    console.log('export completed');
  } catch (err) {
    console.error('export failed', err.code, err.details); // never treat as complete
  }
}

A grpc-js server-streaming handler pauses on write() returning false until 'drain' or 'cancelled', stops when call.cancelled is set, and is paired with a client that uses for await...of in try/catch so a failed stream is never counted as completed.

Client streaming: consuming request streams and returning one response

A client-streaming RPC inverts the server-streaming shape. The client sends many messages, and the server replies exactly once. Typical uses are batched uploads, telemetry ingestion and building one aggregate from many inputs.

Server: read until the input ends

In @grpc/grpc-js, a client-streaming handler receives (call, callback). Here call is a Readable stream of request messages. You can attach 'data' and 'end' listeners, or you can iterate with for await...of, because Node Readables are async iterable. The iterator form is usually cleaner. The loop exits when the client finishes sending. A thrown error lands in one try/catch, so you don't need separate listeners. After the loop, call callback(null, response) with the aggregate, or callback(err) with a status code to fail the RPC. Call the callback exactly once.

Aggregate incrementally, for example with running totals or by writing to storage as messages arrive. If you buffer every message in memory, a long stream turns into an unbounded allocation.

Client: write, respect backpressure, then end

The client stub returns a writable call and takes a callback that receives the single response. Each write() returns a boolean. When it returns false, the internal buffer is full. Stop writing and wait for the 'drain' event before you continue. If you ignore the return value, Node keeps buffering messages in process memory.

When you have no more input, call end(). This half-closes the stream, which is the only signal the server has that input is complete. If the client never calls end(), the server never sees 'end' and the for await loop never exits. The response is never sent and the call sits open. With a deadline set, it fails with DEADLINE_EXCEEDED. Without one, it can hold resources on both sides indefinitely.

const grpc = require('@grpc/grpc-js');
const { once } = require('node:events');

// Server handler for: rpc Record(stream Reading) returns (Summary)
async function record(call, callback) {
  let count = 0;
  let sum = 0;
  try {
    // The loop exits only after the client half-closes with end()
    for await (const reading of call) {
      count += 1;
      sum += reading.value; // keep running totals instead of buffering messages
    }
    callback(null, { count, mean: count ? sum / count : 0 }); // exactly one response
  } catch (err) {
    callback({ code: grpc.status.INTERNAL, message: err.message });
  }
}

// Client: stream readings and resolve with the single Summary
async function sendReadings(client, readings) {
  let settle;
  const result = new Promise((resolve, reject) => {
    settle = (err, summary) => (err ? reject(err) : resolve(summary));
  });
  // Without a deadline, a missing end() leaves the call open indefinitely
  const deadline = new Date(Date.now() + 10_000);
  const call = client.record({ deadline }, settle);

  for (const reading of readings) {
    // false means the buffer is full: wait for 'drain' before writing more
    if (!call.write(reading)) await once(call, 'drain');
  }
  call.end(); // half-close: the server's for-await loop can now finish
  return result;
}

module.exports = { record, sendReadings };

A client-streaming handler that aggregates readings with for await...of and replies once, and a client that respects write() backpressure, sets a deadline and calls end() to signal that input is complete.

Bidirectional streaming: independent reads and writes with correlation IDs

A bidirectional call gives each side a duplex stream. In @grpc/grpc-js, the server handler receives a call that it can read with for await and write with call.write(). On the client, the method returns a similar duplex. The two directions are independent. The server can start writing before it has read anything, and the client can keep sending while responses arrive. Don't structure the handler as "read one, write one." That lockstep pattern hides the real concurrency and stalls as soon as one side buffers.

gRPC guarantees ordering only within a direction. Requests arrive in the order the client sent them, and responses arrive in the order the server wrote them. Nothing ties response N to request N. Once the server processes items concurrently or skips some, that relationship breaks. Put a request_id field in every request message and echo it in the matching response. The client then keeps a map of pending IDs and resolves each entry when its reply arrives.

Decide in advance who closes each half. A typical contract has two steps. First, the client calls end() once it has nothing left to send. Second, the server waits for its read loop to finish, drains any in-flight work, writes the last responses and then calls call.end(). If either side skips its step, the call stays half-closed until the deadline fires.

A non-OK status terminates the entire call, including every item still in flight. Keep statuses for failures that make the stream itself unusable. Report per-item problems, such as a validation error on one record, through an error field or a oneof result in the response message. That way the stream stays open for the remaining items.

// SERVER: read and write independently; close our half only after draining.
async function processRecords(call) {
  const inFlight = new Set();
  for await (const req of call) {               // loop exits when the client calls end()
    const p = validateAndStore(req.payload)       // async: replies may finish out of order
      .then(() => call.write({ request_id: req.request_id, ok: true }))
      // Per-item failure goes in the message, not a status, so the stream stays open
      .catch((err) => call.write({ request_id: req.request_id, error: err.message }))
      .finally(() => inFlight.delete(p));
    inFlight.add(p);
  }
  await Promise.all(inFlight);                    // drain work still running
  call.end();                                     // server closes its half last
}

// CLIENT: correlate replies by request_id, not by arrival order.
const pending = new Map();
const call = client.processRecords({ deadline: Date.now() + 30_000 });

call.on('data', (res) => {
  const entry = pending.get(res.request_id);
  if (!entry) return;                             // unknown or duplicate ID
  pending.delete(res.request_id);
  res.error ? entry.reject(new Error(res.error)) : entry.resolve(res);
});
// A non-OK status ends the whole call: fail everything still pending
call.on('error', (err) => {
  for (const entry of pending.values()) entry.reject(err);
  pending.clear();
});

function send(id, payload) {
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject });
    call.write({ request_id: id, payload });
  });
}

const results = Promise.allSettled(records.map((r) => send(r.id, r.data)));
call.end();                                       // client closes its half once done sending

A @grpc/grpc-js bidirectional stream in which the server processes items concurrently and reports per-item errors in the response message. The client matches replies to requests through a request_id map, and each side ends its own half of the call in an agreed order.

Deadlines, cancellation and error semantics

Deadlines are absolute, not per-message. Pass deadline in the call options as a Date or a millisecond timestamp. gRPC sends it to the server as a timeout header, and when it expires both sides end the call with DEADLINE_EXCEEDED. For long-lived streams, use a deadline that covers the whole session, or none, and rely on keepalives. A short per-request deadline will cut off a healthy stream.

On the server, client cancellation and deadline expiry both emit cancelled on the call object, and call.cancelled becomes true. Register cleanup there: clear intervals, close database cursors, and unsubscribe from message brokers. Otherwise a producer keeps calling write() into a dead stream and holding resources. Check call.cancelled inside long loops as well, because the event only fires between ticks.

To fail a stream from the server, pass an error object with a numeric code from grpc.status and a details string to call.destroy(), or emit it as 'error'. The status travels in HTTP/2 trailers, which come after the data frames. A client can therefore receive many valid messages and then get an error. Treat the messages already received as committed, and design resumption around the last one you processed.

Status code Recommended client action
CANCELLED Stop; usually caller-initiated
DEADLINE_EXCEEDED Resume from cursor with new deadline
UNAVAILABLE Retry with backoff, resume from cursor
INVALID_ARGUMENT Fail fast; surface to user
RESOURCE_EXHAUSTED Retry after delay, reduce rate
INTERNAL Surface to user; don't retry blindly

Running long-lived streams in production and testing them

Keepalive and intermediaries

Set grpc.keepalive_time_ms and grpc.keepalive_timeout_ms on both the client channel and the server. Pings detect dead peers and keep NAT tables and idle timers from silently expiring a quiet stream. The server must permit the client's ping rate, or it will close the connection with GOAWAY. Load balancers and proxies that are not HTTP/2-aware cause trouble too. They may buffer frames, downgrade to HTTP/1.1, or close a connection they consider idle, and a stream that worked locally then fails only in production.

Connection pinning

A stream stays on the backend that accepted it. Connection-level balancing therefore skews over time. New pods receive no existing load, and a rolling deploy drains slowly or cuts streams off abruptly. You can bound stream lifetime on the server with grpc.max_connection_age_ms, or end streams periodically yourself, so that clients reconnect and get redistributed.

Reconnect and resume

gRPC has no stream resumption, so messages in flight during a disconnect may be lost or duplicated. Give every message a sequence number. The client records the last one it processed and reconnects with after: lastSeq. The server replays from that cursor. Handlers must be idempotent and keyed on the sequence number so that replayed messages are dropped.

Browsers

Browsers cannot speak native gRPC. A translation layer such as gRPC-Web supports unary and server streaming but not client or bidirectional streaming.

Testing

Start an in-process server bound to 127.0.0.1:0 and read the assigned port from bindAsync. Assert that messages arrive in order. Check that write() returns false under a slow reader. Confirm that a client cancel() fires the server's cancelled event and releases timers and subscriptions.

Who should use gRPC streaming

gRPC streaming in Node.js is aimed at backend and platform teams that own both ends of a service-to-service connection and can share a .proto contract between them. It fits best when the traffic is continuous or high-volume. Typical cases are live price or state feeds, result sets too large to buffer in one response, telemetry ingestion from many agents, and interactive sessions where both sides react to each other. In all of these, a single long-lived call avoids paying connection and per-request overhead on every message, and HTTP/2 flow control lets a slow consumer slow the producer down.

It is a good match if your team:

  • already runs gRPC between services and wants push or bulk transfer without a second protocol;
  • controls the proxies and load balancers on the path, so it can make them HTTP/2-aware and raise their idle timeouts;
  • can maintain per-message observability, resume cursors and idempotent handlers.

It is a poor match for browser-facing features that need client or bidirectional streaming, because browsers cannot make native gRPC calls. It is also a poor match for teams that cannot change intermediaries that buffer or downgrade HTTP/2.

Teams often pick streaming where it isn't needed. If clients read a bounded list once, unary RPCs with page tokens are simpler to cache, retry and load-balance. If updates arrive every few minutes, polling a unary endpoint is usually cheaper to run than holding streams open.

What are the alternatives to gRPC streaming?

Option Best for Main drawback
Unary + pagination Bounded, re-readable result sets Client drives every page
Unary polling Infrequent changes Latency equals poll interval
gRPC streaming Continuous typed service-to-service flows Proxy, timeout and observability work
WebSockets Browser bidirectional sessions No schema or flow control built in
Server-Sent Events Browser one-way feeds Text-only, server-to-client only
Message broker Durable fan-out, replay Extra infrastructure to run
  • Unary RPCs with pagination keep each request short and independent. Every page can be retried and load-balanced on its own, and the page token doubles as a resume cursor. Choose this when the result set is bounded and the client can tolerate one round trip per page.
  • Unary polling is the simplest way to observe change. Choose it when updates are infrequent and a delay of up to one poll interval is acceptable.
  • WebSockets give browsers a full-duplex channel. You define your own message format, correlation and backpressure handling, because the protocol provides none of the typed contract gRPC gives you.
  • Server-Sent Events run over ordinary HTTP and suit one-way, server-to-browser feeds such as notifications or progress updates. Browsers reconnect automatically, but the payload is text and there is no client-to-server channel on the same stream.
  • A message broker decouples producers from consumers. Messages survive a consumer restart, several consumers can read the same feed, and replay can come from the broker instead of your service. The cost is another system to deploy, secure and monitor.

These options can be combined. A common pattern is to use a broker for durable fan-out between services and gRPC server streaming at the edge to push updates to connected clients.

What gRPC streaming costs

Streaming adds development cost in four places:

  • Observability: per-call metrics hide what happens inside a single long call, so you need per-message counters.
  • Proxies and load balancers: they need idle and stream timeouts raised.
  • Bidirectional flows: these are harder to debug because ordering spans two independent directions.
  • Loading the schema: @grpc/proto-loader is quick to start with but untyped. Code generation adds a build step and gives you compile-time checks.

Streaming saves engineering time where teams would otherwise hand-build chunking, polling loops and length-prefixed framing. It replaces that code with a typed contract and flow control the transport already provides.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to grpc streaming nodejs, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.