Mert Özen Further With Every Line
Backend gRPC and GraphQL — #05

gRPC from Browsers and REST Clients: gRPC-Web, JSON Transcoding and Angular

Mert Özen Nov 14, 2026 11 min 4 views
gRPC from Browsers and REST Clients: gRPC-Web, JSON Transcoding and Angular

Browsers can't speak gRPC directly because they can't reach trailers; with gRPC-Web you serve the same service to Angular through a typed client, and with JSON transcoding to clients expecting REST. We build a stock badge with Connect-ES and the resource API and show CORS, how int64 appears in JSON, the TypeRegistry trap with rich errors and the options now that Grpc.Swagger is deprecated, with real output.

Version note: The code in this post was built and run with .NET 10, Grpc.AspNetCore.Web 2.85, Microsoft.AspNetCore.Grpc.JsonTranscoding 10.0, Angular 22.2, Connect-ES 2.3 and Protobuf-ES 2.17.

Guess First

You want to show an "in stock: 12" badge on the book detail page. The data is already in Inventory's GetStock RPC. Can the Angular app call this RPC directly from the browser? Browsers have spoken version 2 of HTTP for years; what's missing?

The mobile team wants the same data but doesn't want to add a gRPC client; they expect plain REST (Representational State Transfer) and JSON (JavaScript Object Notation). When you expose the same service as JSON, does the price's units field (int64) come out as a number or a string? And one more: what happens when an error carrying rich error details is converted to JSON?

Why Browsers Can't Speak gRPC

The problem isn't HTTP/2 itself but how the browser exposes it. A web page makes HTTP requests with fetch or XMLHttpRequest, and these APIs give no access to HTTP/2 frames. gRPC, however, carries the call's result (grpc-status) in trailers that come after the body, and browser APIs don't show trailers to the page. The browser also decides by itself which connection and protocol version an HTTP/2 request goes over.

gRPC-Web is a small protocol change that closes this gap. Trailers are appended to the end of the body as a specially marked frame, so they can be read with fetch. It works over HTTP/1.1 too. The price: from the browser only unary and server streaming are available; since browsers don't support streaming request bodies, there's no client or bidirectional streaming. On the server side a translator is needed. In ASP.NET Core that translator is a middleware, and no separate proxy is required.

The Server Side: UseGrpcWeb, CORS and a Second Endpoint

We add the Grpc.AspNetCore.Web package to Inventory and set up the middleware and a CORS policy:

builder.Services.AddCors(options => options.AddPolicy("bookstore-web", policy => policy
    .WithOrigins("http://localhost:4200")
    .AllowAnyHeader()
    .WithMethods("POST")
    .WithExposedHeaders("Grpc-Status", "Grpc-Message", "Grpc-Encoding", "Grpc-Accept-Encoding")));

app.UseRouting();
app.UseGrpcWeb();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

app.MapGrpcService<InventoryGrpcService>()
    .EnableGrpcWeb()
    .RequireCors("bookstore-web");

UseGrpcWeb sits between routing and the endpoints and turns gRPC-Web requests into normal gRPC calls for services marked with EnableGrpcWeb(). The WithExposedHeaders line in the CORS policy is critical. In a successful response the status is at the end of the body, but error responses often consist of headers only (the "trailers-only" response we saw in earlier posts), and the browser doesn't show JavaScript any header that isn't explicitly allowed. Without this line Angular sees every error as a meaningless network error.

There's a Kestrel setting too. The existing endpoint has no TLS and speaks only HTTP/2; a browser won't use HTTP/2 to talk to an address without TLS. Locally we opened a second endpoint for the browser:

"Kestrel": {
  "Endpoints": {
    "Grpc": {
      "Url": "http://localhost:5101",
      "Protocols": "Http2"
    },
    "Web": {
      "Url": "http://localhost:5103",
      "Protocols": "Http1"
    }
  }
}

In production a single TLS endpoint (Http1AndHttp2) is enough; ALPN separates the two.

A Typed Client in Angular with Connect-ES

There are several gRPC-Web clients for the browser side. We use Connect-ES: it generates TypeScript-specific code, ships a ready gRPC-Web transport, and messages are used as plain objects. We generate the code from the .proto files with the Buf CLI (it comes with the @bufbuild/buf npm package) and the Protobuf-ES plugin. We put a buf.yaml into the contract folder; google/api/annotations.proto will be needed for JSON transcoding shortly, hence the dependency on googleapis:

# protos/buf.yaml
version: v2
deps:
  - buf.build/googleapis/googleapis

# bookstore-web/buf.gen.yaml
version: v2
inputs:
  - directory: ../../app/protos
plugins:
  - local: protoc-gen-es
    out: src/gen
    opt: target=ts
    include_imports: true

After npx buf dep update (in the protos folder, once) and npx buf generate, src/gen/bookstore/inventory/v1/inventory_pb.ts appears. It contains the message types and the InventoryService definition. One detail stands out: int64 units arrives in TypeScript as units: bigint. Protobuf-ES doesn't try to squeeze a 64-bit integer into JavaScript's number. We provide the client through an injection token:

import { InjectionToken } from '@angular/core';
import { createClient, type Client } from '@connectrpc/connect';
import { createGrpcWebTransport } from '@connectrpc/connect-web';
import { InventoryService } from '../gen/bookstore/inventory/v1/inventory_pb';

export const INVENTORY_CLIENT = new InjectionToken<Client<typeof InventoryService>>('INVENTORY_CLIENT', {
  providedIn: 'root',
  factory: () =>
    createClient(InventoryService, createGrpcWebTransport({ baseUrl: 'http://localhost:5103' })),
});

The stock badge component is written with the resource API, which is stable in Angular 22. It's the same model as in the httpResource post of the Angular series, except that the loader is a gRPC call instead of an HTTP request:

@Component({
  selector: 'app-book-stock',
  template: `
    @if (stock.isLoading()) {
      <p>Checking stock...</p>
    } @else if (stock.error(); as error) {
      <p class="error">{{ describe(error) }}</p>
    } @else if (stock.value(); as s) {
      <p>{{ label(s.status) }}: {{ s.available }} available, {{ price(s) }} {{ s.unitPrice?.currencyCode }}</p>
      <p>Updated {{ updatedAt(s) }}</p>
    }
  `,
})
export class BookStock {
  readonly bookId = input.required<string>();
  private readonly inventory = inject(INVENTORY_CLIENT);

  protected readonly stock = resource({
    params: () => ({ bookId: this.bookId() }),
    // abortSignal: the request is cancelled if bookId changes or the component is destroyed
    loader: ({ params, abortSignal }) =>
      this.inventory.getStock(params, { signal: abortSignal, timeoutMs: 3000 }),
  });

  protected price(stock: GetStockResponse): string {
    const amount = stock.unitPrice?.amount;
    // units arrives as a bigint (int64)
    return amount ? (Number(amount.units) + amount.nanos / 1e9).toFixed(2) : '-';
  }

  protected updatedAt(stock: GetStockResponse): string {
    return stock.updatedAt ? timestampDate(stock.updatedAt).toISOString() : '-';
  }

  protected describe(error: unknown): string {
    const err = ConnectError.from(error);
    return `${Code[err.code]}: ${err.rawMessage}`;
  }
}

The label method is a switch that turns the StockStatus enum into on-screen text; following the warning in the first post, it has a default branch for unknown values. timeoutMs is the call's deadline and abortSignal the cancellation signal; they're the browser counterparts of the two concepts from the second post. I put three badges on the page: an existing book, a book with no record and an invalid id. What the browser showed:

Book detail

In stock: 10 available, 249.90 TRY

Updated 2026-11-13T21:47:26.293Z

NotFound: Book 00000000-0000-0000-0000-000000000000 has no stock record.

InvalidArgument: Invalid request.

The status codes and messages are the same as in the .NET client. In the network tab, though, every call came with two requests:

OPTIONS http://localhost:5103/bookstore.inventory.v1.InventoryService/GetStock → 204 No Content
POST http://localhost:5103/bookstore.inventory.v1.InventoryService/GetStock → 200 OK

OPTIONS is the CORS preflight request. Because the gRPC-Web content type (application/grpc-web+proto) doesn't count as a "simple request", the browser asks for permission before the call; three calls starting at the same time sent three OPTIONS requests. If you serve the Angular app and the API from the same origin (behind a reverse proxy), the preflight disappears entirely. If separate origins are a must, letting the browser cache the permission with SetPreflightMaxAge cuts down the round trips.

JSON Transcoding: The Same Service with a REST Face

Instead of writing a separate REST API for the mobile team, we publish the same gRPC methods as JSON. HTTP rules are added to the contract:

import "google/api/annotations.proto";

service InventoryService {
  rpc GetStock (GetStockRequest) returns (GetStockResponse) {
    option (google.api.http) = {
      get: "/v1/books/{book_id}/stock"
    };
  }
  rpc ReserveStock (ReserveStockRequest) returns (ReserveStockResponse) {
    option (google.api.http) = {
      post: "/v1/orders/{order_id}/reservations"
      body: "*"
    };
  }
  rpc WatchStock (WatchStockRequest) returns (stream StockChanged) {
    option (google.api.http) = {
      get: "/v1/stock-changes"
    };
  }
}

{book_id} binds from the route to the request message's field. body: "*" says that every field not used in the route is read from the JSON body. Once <IncludeHttpRuleProtos>true</IncludeHttpRuleProtos> was added to Inventory's csproj, the Microsoft.AspNetCore.Grpc.JsonTranscoding package supplied the needed googleapis files itself. The client projects that compile the same .proto (Ordering, StockCheck), however, needed the Google.Api.CommonProtos package and <IncludeGoogleApiCommonProtos>true</IncludeGoogleApiCommonProtos>; otherwise the build fails with Import "google/api/annotations.proto" was not found. On the server it's one line:

builder.Services.AddGrpc(options =>
{
    // registration order = execution order: logging is outermost
    options.Interceptors.Add<ServerLoggingInterceptor>();
    options.Interceptors.Add<ExceptionMappingInterceptor>();
}).AddJsonTranscoding(options =>
{
    // so that rich error details can be converted to JSON
    options.TypeRegistry = TypeRegistry.FromMessages(BadRequest.Descriptor, PreconditionFailure.Descriptor);

    // JavaScript's number loses precision above 2^53; write strings, as the proto JSON rules say
    options.JsonSettings.WriteInt64sAsStrings = true;
});

These two settings were added later; we'll see below why they're needed. First, the REST face with curl:

GET /v1/books/3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13/stock
HTTP/1.1 200 OK
{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":12,"reservedQuantity":3,"status":"STOCK_STATUS_IN_STOCK","unitPrice":{"currencyCode":"TRY","amount":{"units":"249","nanos":900000000}},"updatedAt":"2026-11-14T09:30:00Z","warehouseQuantities":{}}

GET /v1/books/00000000-0000-0000-0000-000000000000/stock
HTTP/1.1 404 Not Found
{"code":5,"message":"Book 00000000-0000-0000-0000-000000000000 has no stock record.","details":[]}

GET /v1/books/42/stock
HTTP/1.1 400 Bad Request
{"code":3,"message":"Invalid request.","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest","fieldViolations":[{"field":"book_id","description":"'42' is not a valid UUID.","reason":""}]}]}

Transcoding converts the gRPC status into an HTTP status: NotFound becomes 404, InvalidArgument 400. The body, however, isn't ProblemDetails but the JSON form of google.rpc.Status. [Authorize] applies as is. A POST /v1/orders/o-55/reservations without a token returned 401; with an inventory.write token:

POST /v1/orders/o-55/reservations
{"lines":[{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","quantity":2}]}
HTTP/1.1 200 OK
{"reservationId":"r-45526c9c","expiresAt":"2026-11-13T22:02:26.293220Z"}

Server streaming is supported too, as line-delimited JSON. The curl -N http://localhost:5103/v1/stock-changes I opened just before the reservation received:

{"sequence":"1","bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":10,"changedAt":"2026-11-13T21:47:26.293097Z"}

Client and bidirectional streaming can't be published through transcoding; BulkUpdateStock and ScanBarcodes don't exist on the REST face.

How the Proto JSON Rules Affect Clients

The shape of the JSON face is determined not by C# DTOs but by protobuf's JSON mapping rules. What the mobile team needs to know:

  • Field names are lowerCamelCase: book_id → bookId. As we saw in the first post, renaming a field breaks JSON clients.
  • Enums travel by name: "STOCK_STATUS_IN_STOCK", not InStock as in the C# enum.
  • Timestamp is a UTC (Coordinated Universal Time) string in RFC 3339 format, Duration a string such as "1.5s".
  • Fields at their default value are written too: "warehouseQuantities":{}. The IgnoreDefaultValues setting drops them, but then the client can't tell "0" from "absent".

The answer to the second opening question was a surprise. On the first try there was no WriteInt64sAsStrings and the response was:

{"bookId":"3f2c9a1e-7b4d-4c8e-9a51-2d6f0e8b7c13","available":12,"reservedQuantity":3,"status":"STOCK_STATUS_IN_STOCK","unitPrice":{"currencyCode":"TRY","amount":{"units":249,"nanos":900000000}},"updatedAt":"2026-11-14T09:30:00Z","warehouseQuantities":{}}

"units":249, a number. In the first post Google.Protobuf's JsonFormatter wrote the same field as "249", a string, and protobuf's JSON specification also calls for strings for int64. ASP.NET Core's transcoding, however, uses its own System.Text.Json-based serializer, and its default is a number. For 249 it makes no difference. But an id above 2⁵³, or a large total in cents, gets silently rounded in JavaScript. If you publish the same contract through both JsonFormatter and transcoding, you produce two different JSONs. That's why we followed the rule with WriteInt64sAsStrings = true.

The answer to the third question is a trap as well. Without the TypeRegistry setting, the invalid-id request returned a 500 Internal Server Error with an empty body instead of 400. In the server log:

System.InvalidOperationException: Type registry has no descriptor for type name 'google.rpc.BadRequest'.

Rich error details are packed inside Any. To convert them to JSON, the serializer has to know that type's descriptor. gRPC clients never had this problem, because there the details travel in binary. You have to add every detail type you use to the registry.

OpenAPI: What Happened to Grpc.Swagger?

For years the recommended way to document the REST face was the Microsoft.AspNetCore.Grpc.Swagger package: it produced an OpenAPI document from transcoding routes with Swashbuckle. This package never reached 1.0 (its last releases are 0.10.x) and was declared deprecated in the .NET 11 release notes (dotnet/aspnetcore #67134). The reasons given were the package's dependency on Swashbuckle rather than Microsoft.OpenApi and the maintenance cost of a rarely used experimental package. No official replacement for transcoding routes was offered in the issue.

Don't take a dependency on this package in a new project. The options are: the protoc-gen-openapiv2 plugin from the grpc-gateway ecosystem can produce OpenAPI from the same google.api.http annotations, so you generate the document at build time and publish it. Or you write the REST face as a real ASP.NET Core API instead of transcoding; then every tool from the OpenAPI post of the ASP.NET Core Web API series is available.

That brings us to the real question: should transcoding be your public API? For your own mobile app or internal tools it's great: one contract, one implementation. In an API open to the outside world, though, protobuf's fingerprints come along: enum names like "STOCK_STATUS_IN_STOCK", a {code, message, details} error body instead of ProblemDetails, string int64s, the contract's evolution tied to .proto rules. A separate REST facade for the outside world and gRPC for internal traffic is often the more comfortable split.

Code-First gRPC: When Does protobuf-net.Grpc Make Sense?

In this series the contract always lives in a .proto file. An alternative is the code-first approach with protobuf-net.Grpc: the contract is written as C# interfaces and classes marked with [DataContract]-style attributes, and there's no .proto file. In a team where both sides are .NET it's tempting: the DTOs are already in C#, there's no code generation step, and you take the interface from a shared class library.

In a polyglot environment that advantage turns around. A service written in Go or a TypeScript client can't read a contract from C# code. As we did in this post, a .proto file is input to four different tools at once (Grpc.Tools, Buf, Protobuf-ES, JSON transcoding). Even if it's all .NET today, if an Angular client or another language is coming tomorrow, stay contract-first.

In Production

Keep CORS narrow. Opening gRPC-Web with AllowAnyOrigin means letting any site send requests to your API from the user's browser. Write the origin list out explicitly and allow only POST; gRPC-Web uses no other method.

Don't leave out cancellation and timeouts in the browser client. When the user leaves the page, calls still in flight waste server resources. Passing resource's abortSignal to the call solves this in one line.

Measure bundle size. The generated code and the Protobuf-ES runtime go to the browser. The main bundle of this three-badge page came out at 331 KB, 85 KB compressed. For large contracts, feeding only the .proto files the browser uses into code generation reduces the difference.

Rethink authorization on the REST face. Transcoding endpoints use the same [Authorize] rules, but they're now reachable from browsers and the outside network. Rate limiting, request size and resource consumption controls, as covered in the API4 (Unrestricted Resource Consumption) post of the API Security series, should apply to this face as well.

Common Pitfalls

Forgetting WithExposedHeaders. Successful calls work, errors turn into a meaningless network error; the problem usually surfaces at the first error.

Trying to reach an Http2 endpoint without TLS from the browser. Browsers don't speak h2c; local development needs a separate Http1 endpoint or TLS.

Not adding rich error types to the TypeRegistry. An error that works fine for gRPC clients turns into a 500 on the JSON face.

Renaming a field on the JSON face. Binary clients don't notice; the mobile app breaks. The compatibility table from the first post applies to transcoding too.

Using int64 in the browser through Number. Protobuf-ES gives you a bigint. Number(...) is safe for small values like a price, but ids and large counters get corrupted.

A Small Experiment

★☆☆ Remove the WithExposedHeaders line from the CORS policy and reload the Angular page. Note which error message the second and third badges show.

★★☆ Wire WatchStock into the book detail page as server streaming. In Connect-ES it returns an async iterable read with for await. Make a reservation from a terminal and watch the badge update live.

★★★ Add the rule delete: "/v1/reservations/{reservation_id}" for ReleaseStock. Test with curl how binding a field inside a oneof to the route works in transcoding, and discuss whether releasing by order_id needs a second route.

The gRPC side ends here: contract, service, streaming, production settings and the browser. In the next post we move to the second half of the series, GraphQL. The same book detail screen becomes a single query this time: book, author, stock and recent reviews, exactly the fields the screen asks for.