← all articles

What I learned after the first real gRPC-Web interface

Topics: grpc · react · frontend

Most gRPC-Web guides stop at the comfortable point: a unary request returns a valid response and a React component renders it. That is enough to try the library. It is not enough to build a screen someone depends on.

In the real application, the user changes a filter quickly, leaves the page before a response returns, loses the network in the middle of a stream, or receives an error that cannot be shown as-is. Those are the decisions I wanted to see collected in one place.

This is not a recipe for one client library. It is how I arrange the transport boundary so gRPC details do not spread into every component.

The proxy is part of the system

A browser does not talk directly to native gRPC. A proxy translates gRPC-Web into the connection understood by the backend service. When a request sits in pending, I do not look only at React and the service. I check proxy timeouts, message limits, allowed headers, and how it closes the connection.

Streaming makes this especially important. gRPC-Web does not expose every streaming pattern available between backend services. If the design needs bidirectional streaming, that needs to be decided before the frontend is built around an assumption the browser transport cannot meet.

For me, the connection diagram is incomplete until it answers three questions:

  • Who sets the deadline?
  • Who can cancel the request?
  • Where does an error appear when the proxy cannot reach the service?

If those answers are unclear, the failure eventually becomes a screen that appears to load forever.

I do not call the generated client from components

The client generated from proto definitions is useful, but I do not make it the application's interface. I put a small handwritten adapter between it and React. The adapter accepts types used by the app and returns the shape needed by the screen.

type Report = {
  id: string;
  state: "ready" | "running" | "failed";
};
 
export async function loadReport(id: string): Promise<Report> {
  const request = new GetReportRequest();
  request.setId(id);
 
  const response = await reportClient.getReport(request);
 
  return {
    id: response.getId(),
    state: mapReportState(response.getState()),
  };
}

This can look like an unnecessary wrapper at first. The value becomes clear after the first proto change: one adapter changes instead of dozens of components that know about generated getters, wrappers, and optional fields.

Authentication, locale, request ID, and deadlines belong here too. A component asks for a report. It should not know which metadata the proxy expects.

Cancellation is not a small optimization

Open a search screen, type one character, type another, and then leave the route. Without cancellation, older requests keep working. The slowest old response may arrive last and replace the newest data.

If the generated client provides a cancellation handle, I pass it through the adapter and cancel during hook cleanup. I also keep a request ID as a second guard because a cancellation message can arrive late.

useEffect(() => {
  const call = searchReports(query, {
    onData: setReports,
    onError: setError,
  });
 
  return () => call.cancel();
}, [query]);

Ignoring an old response in React prevents a stale state update, but it does not stop the proxy and backend from doing work nobody needs. I prefer real transport cancellation when the client supports it, with the request ID as a backup.

Every request needs an end

A request without a deadline creates a loading state with no defined end. I set a default in the adapter and override it only for operations that genuinely need more time.

const deadline = new Date(Date.now() + 10_000);
const response = await client.getReport(request, { deadline });

That number is an example, not a recommendation for every service. A fast lookup and a large export should not share a deadline. The important part is that the value is an explicit decision, not a behavior left to the library or proxy.

When it expires, the user needs a useful state. DEADLINE_EXCEEDED should not appear as interface copy. It becomes a short message and a retry action when retrying is safe.

Map errors once

gRPC status codes are useful to developers. They are not user-facing messages. I map them centrally into a smaller application type:

type AppError =
  | { kind: "offline"; retryable: true }
  | { kind: "unauthorized"; retryable: false }
  | { kind: "invalid-input"; retryable: false }
  | { kind: "unknown"; retryable: false };

UNAVAILABLE and DEADLINE_EXCEEDED are often retryable. UNAUTHENTICATED and PERMISSION_DENIED need an authentication or permission path. INVALID_ARGUMENT often means the frontend and service disagree about the request.

The benefit is not only consistent copy. There is now one place to decide what gets logged, which calls can be retried, and when the client should stop.

A stream needs memory

Server streaming is not a WebSocket with another name. The connection can end because of the network or a proxy timeout. If a reconnect has no record of the last item received, the interface may lose events or show them twice.

I prefer a resume token or stable ID in the request:

const stream = client.watchEvents({
  resumeFrom: lastSeenId,
});
 
stream.on("data", enqueue);
stream.on("end", reconnect);

Inside React, I do not turn every message into a setState call. Messages enter a buffer and reach the interface in batches. The stream may be fast; the screen does not need to repaint at the same rate.

I also show connection state. During a short reconnect, the last trusted data can remain visible with a stale indicator. When the retry budget ends, the loop stops and the user gets a manual action. An endless spinner hides the most important fact: the client no longer knows how current the screen is.

Do not retry writes blindly

Retrying a read is often safe. Retrying a write may execute the operation twice. The adapter needs to know more than the status code; it needs to know whether the method is idempotent.

I use limited backoff with jitter for reads. Writes need an idempotency key or an explicit user decision. Streams need a resume point, not a new connection that forgets the old one.

This policy belongs in one place. Left to each screen, the same outage will produce ten different behaviors.

What I log

I log the method name, duration, status, attempt count, and request ID. I do not log the full message by default. Security and telecom payloads can contain sensitive data, and large messages turn logs into noise.

The goal is to answer one question quickly: did the request fail before the proxy, inside it, or in the service? I do not need a copy of the user's data to find that boundary.

Once these rules are in place, gRPC-Web becomes quiet infrastructure. Components know what they request and what they receive, while deadlines, cancellation, mapping, and logging stay in one layer. That is when generated types begin saving time instead of moving network complexity into every screen.