CodeWiz Logo

    CodeWiz

    Integrating gRPC to Web with gRPC-Web and Envoy

    Integrating gRPC to Web with gRPC-Web and Envoy

    21/03/2025

    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

      1. gRPC-to-JSON conversion using Envoy as a proxy/gateway
      1. 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

    gRPC-to-JSON Architecture

      1. The browser makes a standard HTTP request with a JSON payload
      1. Envoy receives the request and converts it to a gRPC call
      1. The gRPC server processes the request and returns a gRPC response
      1. 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 file
    • services 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:

      1. The client makes a standard HTTP/1.1 request with a JSON payload to Envoy (port 8089)
      1. 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
      1. Envoy makes a gRPC call over HTTP/2 to the backend service (port 9092)
      1. The gRPC service processes the request and sends back a Protocol Buffer response
      1. Envoy's transcoder:
      • Converts the Protocol Buffer response back to JSON
      • Structures the HTTP response appropriately
      1. 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

    gRPC-Web Architecture

      1. The browser uses the gRPC-Web client library to make calls to the gRPC service
      1. Envoy acts as a proxy, translating between gRPC-Web and regular gRPC
      1. 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 classes
    • AppointmentServiceClientPb.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

      1. The browser initiates a request using the gRPC-Web client
      1. The client serializes the request into a gRPC web binary payload
      1. 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
      1. Envoy receives the HTTP request
      1. Envoy's gRPC-Web filter:
      • Extracts the gRPC payload
      • Creates a proper gRPC-over-HTTP/2 request to the backend service
      1. The gRPC server processes the request normally and returns a response
      1. Envoy converts the gRPC response back to a format the gRPC-Web client can understand
      1. 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:

    FeaturegRPC-to-JSONgRPC-Web
    Client ComplexityLow (standard HTTP)Medium (requires gRPC-Web library)
    Setup ComplexityMediumMedium
    PerformanceGoodBetter
    Type SafetyLimitedStrong
    Developer ExperienceFamiliar (REST-like)New paradigm
    Tooling SupportExcellent (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