
Introduction
In a previous blog, we went through how to build gRPC microservices in Java and Spring Boot. While gRPC excels in service-to-service communication, it presents a challenge when interfacing with web browsers. Modern browsers cannot directly communicate with gRPC services due to certain limitations.
In this blog post, we'll explore why browsers can't directly use gRPC now and the two main approaches to overcome this limitation without writing code for data transformation
-
- gRPC-to-JSON conversion using Envoy as a proxy/gateway
-
- gRPC-Web with Envoy as a proxy/gateway
Why Browsers Can't Directly Call gRPC Services
gRPC is built on HTTP/2 and uses Protocol Buffers for serialization, which brings excellent performance and efficiency for service-to-service communication. However, several factors prevent browsers from making direct gRPC calls:
1. Limited HTTP/2 Support
gRPC requires access to HTTP/2 features that browsers don't expose through JavaScript APIs. While modern browsers support HTTP/2, JavaScript in the browser can't:
- Control HTTP/2 stream prioritization
- Perform HTTP/2 header compression
- Directly handle HTTP/2 connection management
2. Binary Protocol
gRPC uses Protocol Buffers, a binary serialization format. Browsers lack native support for handling binary Protocol Buffer payloads efficiently.
3. Streaming Limitations
Browsers have limitations around bidirectional streaming which is one of gRPC's powerful features.
Approach 1: gRPC-to-JSON Conversion with gateway like Envoy
The first approach involves using a proxy/gateway (like Envoy) that translates between RESTful JSON APIs (which browsers understand) and gRPC services. This approach makes your gRPC services accessible via standard HTTP/1.1 or HTTP/2 with JSON payloads.
How It Works
-
- The browser makes a standard HTTP request with a JSON payload
-
- Envoy receives the request and converts it to a gRPC call
-
- The gRPC server processes the request and returns a gRPC response
-
- Envoy translates the gRPC response back to JSON and sends it to the browser
Setting Up gRPC-to-JSON with Envoy
Let's take a look at the key components needed to set up this approach:
1. Add HTTP Annotations to Proto File
First, you need to add HTTP annotations to your proto file to indicate how gRPC methods map to HTTP endpoints:
We will make this change to our appointment.proto
file which we built in the previous blog:
syntax = "proto3"; package com.codewiz.appointment; option java_multiple_files = true; import "google/api/annotations.proto"; service AppointmentService { rpc BookAppointment (BookAppointmentRequest) returns (BookAppointmentResponse) { option (google.api.http) = { post: "/v1/appointments" body: "*" }; } rpc GetAppointmentAvailability (AppointmentAvailabilityRequest) returns (stream AppointmentAvailabilityResponse) { option (google.api.http) = { get: "/v1/appointments/availability" }; } } // Message definitions remain the same
The annotations define HTTP endpoints for each gRPC method:
BookAppointment
is exposed as a POST to/v1/appointments
GetAppointmentAvailability
is exposed as a GET to/v1/appointments/availability
2. Generate Protocol Buffer Descriptor
Now we need to generate a Protocol Buffer descriptor file that Envoy will use to understand your service:
protoc --include_imports --include_source_info -o appointment.pb appointment.proto
3. Configure Envoy Proxy
The Envoy configuration for gRPC-JSON transcoding looks like this:
admin: access_log_path: "/data/envoy_access.log" address: socket_address: { address: 0.0.0.0, port_value: 9099 } static_resources: listeners: - name: listener_0 address: socket_address: { address: 0.0.0.0, port_value: 8089 } filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: grpc_json codec_type: AUTO route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: prefix: "/" route: cluster: appointment_service timeout: 60s http_filters: - name: envoy.filters.http.grpc_json_transcoder typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_json_transcoder.v3.GrpcJsonTranscoder proto_descriptor: "/data/appointment.pb" services: com.codewiz.appointment.AppointmentService print_options: add_whitespace: true always_print_primitive_fields: true always_print_enums_as_ints: false preserve_proto_field_names: false - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: appointment_service type: LOGICAL_DNS lb_policy: ROUND_ROBIN dns_lookup_family: V4_ONLY typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http2_protocol_options: { } load_assignment: cluster_name: appointment_service endpoints: - lb_endpoints: - endpoint: address: { socket_address: { address: host.docker.internal, port_value: 9092 }} layered_runtime: layers: - name: static_layer static_layer: envoy: logger: level: debug
Key configuration points:
- The
envoy.filters.http.grpc_json_transcoder
filter does the heavy lifting of translating JSON to gRPC and back proto_descriptor
points to the compiled descriptor fileservices
specifies which service to expose- The cluster configuration tells Envoy how to connect to the actual gRPC service
Let us keep this configuration in a file named envoy_grpc_to_json.yaml
.
4. Run Envoy with Docker Compose
Docker Compose makes it easy to run Envoy alongside your services passing the necessary configuration:
services: # Database services omitted for brevity envoy-grpc-to-json: image: envoyproxy/envoy:v1.21.0 volumes: - ./envoy_grpc_to_json.yaml:/etc/envoy/envoy_grpc_to_json.yaml - ./appointment.pb:/data/appointment.pb - .:/data ports: - "9099:9099" - "8089:8089" command: [ "envoy", "-c", "/etc/envoy/envoy_grpc_to_json.yaml", "--log-level", "debug" ]
Testing the gRPC-to-JSON Conversion
Once your Envoy proxy is running, you can test the API using standard HTTP tools like curl or HTTPie:
Book an appointment using HTTPie:
http POST localhost:8089/v1/appointments \ doctor_id:=10 \ patient_id:=2 \ appointment_date="2025-02-15" \ appointment_time="14:30" \ reason="Annual check-up"
Response:
{ "appointment_id": "123", "message": "Appointment booked successfully" }
Get appointment availability using curl:
curl -X GET "http://localhost:8089/v1/appointments/availability?doctor_id=10"
Response:
{ "availability_as_of": "2025-02-10T12:30:45", "responses": [ { "appointment_date": "2025-02-15", "appointment_time": "09:00", "is_available": true }, { "appointment_date": "2025-02-15", "appointment_time": "09:30", "is_available": true } ] }
How the End-to-End Flow Works
When a request comes from a browser or HTTP client:
-
- The client makes a standard HTTP/1.1 request with a JSON payload to Envoy (port 8089)
-
- Envoy's JSON transcoder filter:
- Parses the HTTP route to determine the gRPC service and method
- Converts the JSON payload to a Protocol Buffer message
-
- Envoy makes a gRPC call over HTTP/2 to the backend service (port 9092)
-
- The gRPC service processes the request and sends back a Protocol Buffer response
-
- Envoy's transcoder:
- Converts the Protocol Buffer response back to JSON
- Structures the HTTP response appropriately
-
- The client receives a standard HTTP response with JSON payload
Advantages of gRPC-to-JSON
- Works with any HTTP client, not just browsers
- No special client-side libraries needed
- Easy to test with standard tools
Limitations of gRPC-to-JSON
- Loses some gRPC benefits (streaming, binary efficiency)
- Adds translation overhead
- No type safety in JSON payloads
- Supports only server streaming, not client streaming
Approach 2: gRPC-Web with Envoy
gRPC-Web is an official protocol designed to allow browser-based applications to use gRPC. It provides a JavaScript client library that can speak a simplified version of the gRPC protocol.
How It Works
-
- The browser uses the gRPC-Web client library to make calls to the gRPC service
-
- Envoy acts as a proxy, translating between gRPC-Web and regular gRPC
-
- The backend service receives standard gRPC calls and doesn't need any changes
Setting Up gRPC-Web with Envoy
We don't need to add the HTTP annotations to the proto file for this approach.
1. Configure Envoy for gRPC-Web
admin: access_log_path: "/tmp/admin_access.log" address: socket_address: { address: 0.0.0.0, port_value: 9199 } static_resources: listeners: - name: listener_0 address: socket_address: { address: 0.0.0.0, port_value: 8099 } filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager codec_type: AUTO stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: - "*" cors: allow_origin_string_match: - exact: "*" allow_methods: GET, PUT, DELETE, POST, OPTIONS allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout max_age: "1728000" expose_headers: custom-header-1,grpc-status,grpc-message routes: - match: prefix: "/" route: cluster: appointment_service max_grpc_timeout: 0s http_filters: - name: envoy.filters.http.cors typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors - name: envoy.filters.http.grpc_web typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: appointment_service type: LOGICAL_DNS lb_policy: ROUND_ROBIN dns_lookup_family: V4_ONLY typed_extension_protocol_options: envoy.extensions.upstreams.http.v3.HttpProtocolOptions: "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions explicit_http_config: http2_protocol_options: { } load_assignment: cluster_name: appointment_service endpoints: - lb_endpoints: - endpoint: address: { socket_address: { address: host.docker.internal, port_value: 9092 }} layered_runtime: layers: - name: static_layer static_layer: envoy: logger: level: debug
Key differences from the previous Envoy configuration:
- It uses the
envoy.filters.http.grpc_web
filter instead of the JSON transcoder - It includes CORS configuration to allow browser access
- No need for a descriptor file since we're not doing transcoding
2. Run Envoy with Docker Compose
Now let us add the below service to the Docker Compose file:
envoy-grpcweb: image: envoyproxy/envoy:v1.21.0 volumes: - ./envoy_d_grpcweb.yaml:/etc/envoy/envoy_d_grpcweb.yaml - ./appointment.pb:/data/appointment.pb - .:/data ports: - "9199:9199" - "8099:8099" command: [ "envoy", "-c", "/etc/envoy/envoy_d_grpcweb.yaml", "--log-level", "debug" ]
Now restart docker-compose to run the new envoy service.
2. Set Up Your Frontend Project with gRPC-Web
If you want to just use curl to test you can do it like below:
curl --location 'http://localhost:8099/com.codewiz.appointment.AppointmentService/BookAppointment' \ --header 'Accept: application/grpc-web-text' \ --header 'Cache-Control: no-cache' \ --header 'Connection: keep-alive' \ --header 'Content-Type: application/grpc-web-text' \ --header 'Pragma: no-cache' \ --data 'AAAAAB0IAhACGgoyMDI1LTA0LTExIgUxMTozMCoEZXdydw=='
You can see that content type is application/grpc-web-text
and the data is base64 encoded binary data.
But we will also test it from a frontend application. We will call this from a React component in a Next.js application.
After creating a Next.js application, add the below dependencies:
npm install google-protobuf grpc-web @types/google-protobuf
3. Generate JavaScript Client Code from Proto Files
You'll need to use the protoc
tool with the gRPC-Web plugin to generate client code:
protoc \ --js_out=import_style=commonjs,binary:./generated \ --grpc-web_out=import_style=typescript,mode=grpcwebtext:./generated \ ./proto/appointment.proto
This generates:
appointment_pb.js
- Contains message classesAppointmentServiceClientPb.ts
- Contains the service client
4. Use gRPC-Web in Your Frontend Application
Here's an example of how to use gRPC-Web to call our appointment service from a React component:
Displaying Available Slots in a React Component from a gRPC Streaming Service
You can find the full code for this component here - AvailabilitySlots.tsx
'use client'; import { useState, useEffect } from 'react'; import { AppointmentServiceClient } from '@/generated/proto/AppointmentServiceClientPb'; import { AppointmentAvailabilityRequest, AppointmentAvailabilityResponse, AppointmentSlot } from '@/generated/proto/appointment_pb'; interface AvailabilitySlotsProps { doctorId: number | null; onSlotSelect: (slot: AppointmentSlot.AsObject) => void; } export default function AvailabilitySlots({ doctorId, onSlotSelect }: AvailabilitySlotsProps) { const [slots, setSlots] = useState<AppointmentSlot.AsObject[]>([]); const [loading, setLoading] = useState<boolean>(false); const [error, setError] = useState<string | null>(null); const [lastUpdated, setLastUpdated] = useState<string | null>(null); useEffect(() => { if (!doctorId) return; setLoading(true); setError(null); setSlots([]); const client = new AppointmentServiceClient('http://localhost:8099'); const request = new AppointmentAvailabilityRequest(); request.setDoctorId(doctorId); const stream = client.getAppointmentAvailability(request); stream.on('data', (response: AppointmentAvailabilityResponse) => { setLastUpdated(response.getAvailabilityAsOf()); const newSlots = response.getResponsesList().map(slot => ({ appointmentDate: slot.getAppointmentDate(), appointmentTime: slot.getAppointmentTime(), isAvailable: slot.getIsAvailable(), })); setSlots(newSlots); }); stream.on('error', (err) => { setError(`Failed to fetch availability: ${err.message}`); setLoading(false); }); stream.on('end', () => { setLoading(false); }); return () => { stream.cancel(); }; }, [doctorId]); // Group slots by date const slotsByDate = slots.reduce((acc: Record<string, typeof slots>, slot) => { if (!acc[slot.appointmentDate]) { acc[slot.appointmentDate] = []; } acc[slot.appointmentDate].push(slot); return acc; }, {}); if (!doctorId) { return <div className="mt-6">Please select a doctor to view available slots</div>; } // Rest of the component rendering code to display slots. Refer to the full code in the repository. }
Booking an Appointment from a React Form by calling a unary gRPC Service
You can find the full code for this component here - AppointmentForm.tsx
'use client'; import { useState } from 'react'; import { BookAppointmentRequest, BookAppointmentResponse } from '@/generated/proto/appointment_pb'; import { AppointmentServiceClient } from '@/generated/proto/AppointmentServiceClientPb'; export default function AppointmentForm() { const [formData, setFormData] = useState({ doctorId: '', patientId: '', appointmentDate: '', appointmentTime: '', reason: '' }); const [isSubmitting, setIsSubmitting] = useState(false); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); try { const client = new AppointmentServiceClient('http://localhost:8099'); const request = new BookAppointmentRequest(); request.setDoctorId(Number(formData.doctorId)); request.setPatientId(Number(formData.patientId)); request.setAppointmentDate(formData.appointmentDate); request.setAppointmentTime(formData.appointmentTime); request.setReason(formData.reason); client.bookAppointment(request, {}, (err, response: BookAppointmentResponse) => { setIsSubmitting(false); if (err) { console.error('Error booking appointment:', err); return; } console.log(`Appointment booked successfully! ID: ${response.getAppointmentId()}`); }); } catch (error) { setIsSubmitting(false); console.error('Error:', error); } }; // Form rendering code. Refer to the full code in the repository. }
How gRPC-Web Works End-to-End
-
- The browser initiates a request using the gRPC-Web client
-
- The client serializes the request into a gRPC web binary payload
-
- The client sends an HTTP request (usually POST) with:
- Content-Type: application/grpc-web-text
- Binary Protocol Buffer payload in the body which is base64 encoded
-
- Envoy receives the HTTP request
-
- Envoy's gRPC-Web filter:
- Extracts the gRPC payload
- Creates a proper gRPC-over-HTTP/2 request to the backend service
-
- The gRPC server processes the request normally and returns a response
-
- Envoy converts the gRPC response back to a format the gRPC-Web client can understand
-
- The browser's gRPC-Web client:
- Deserializes the response
- Provides it to your application code through promises or callbacks
For server streaming (as shown in the AvailabilitySlots component), the gRPC-Web client provides an event-based API with 'data', 'error', and 'end' events to handle the stream.
Advantages of gRPC-Web
- Preserves most of the gRPC benefits (type safety, code generation)
- Better type-checking and IDE integration
- Supports server streaming
- Uses the more efficient Protocol Buffer binary format
Limitations of gRPC-Web
- Requires additional client libraries
- More setup compared to standard REST
- Debugging can be more challenging than JSON
- Supports only server streaming, not client streaming
Which Approach Should You Choose?
Here's a quick comparison to help you decide:
Feature | gRPC-to-JSON | gRPC-Web |
---|---|---|
Client Complexity | Low (standard HTTP) | Medium (requires gRPC-Web library) |
Setup Complexity | Medium | Medium |
Performance | Good | Better |
Type Safety | Limited | Strong |
Developer Experience | Familiar (REST-like) | New paradigm |
Tooling Support | Excellent (all HTTP tools) | Limited |
Consider gRPC-to-JSON when:
- You need to support multiple client types beyond browsers
- Your team is more familiar with RESTful APIs
- You want easier debugging and tooling support
Consider gRPC-Web when:
- You value performance and type safety
- You want a consistent programming model across client and server
- You need server streaming in the browser
- Your team is comfortable with Protocol Buffers
Conclusion
While browsers can't directly communicate with gRPC services, both approaches we've explored provide viable solutions. The gRPC-to-JSON approach using Envoy offers a familiar RESTful API experience, while gRPC-Web provides a more consistent programming model with better performance.
UI Source Code: healthcare-app-ui-grpcweb
Backend Source Code: healthcare-app-grpc-java
For a complete example of building gRPC services with Java and Spring Boot, check out my previous blog post.
References
Related Posts
Mastering gRPC with Java and Spring Boot: Build a Healthcare App
Learn the basics of gRPC and how to build gRPC microservices in Java and Spring Boot.
Evolution of HTTP: Comparing HTTP/0.9, HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3
Explore the evolution of the HTTP protocol, comparing HTTP/0.9, HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3. Understand their improvements, drawbacks, and use cases.