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 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:
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:
gRPC uses Protocol Buffers, a binary serialization format. Browsers lack native support for handling binary Protocol Buffer payloads efficiently.
Browsers have limitations around bidirectional streaming which is one of gRPC's powerful features.
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.
Let's take a look at the key components needed to set up this approach:
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
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
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:
envoy.filters.http.grpc_json_transcoder
filter does the heavy lifting of translating JSON to gRPC and backproto_descriptor
points to the compiled descriptor fileservices
specifies which service to exposeLet us keep this configuration in a file named envoy_grpc_to_json.yaml
.
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" ]
Once your Envoy proxy is running, you can test the API using standard HTTP tools like curl or 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" }
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 } ] }
When a request comes from a browser or HTTP client:
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.
We don't need to add the HTTP annotations to the proto file for this approach.
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:
envoy.filters.http.grpc_web
filter instead of the JSON transcoderNow 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.
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
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 clientHere's an example of how to use gRPC-Web to call our appointment service from a React component:
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. }
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. }
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.
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:
Consider gRPC-Web when:
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.
Learn the basics of gRPC and how to build gRPC microservices in Java and Spring Boot.
Complete guide to HTTP protocol evolution. Compare HTTP/0.9, HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 with improvements and use cases.
Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.