Skip to content

gRPC Migration Guide

Target Audience: Developers adding gRPC to existing REST or WebSocket applications Estimated Reading Time: 10 minutes Prerequisites: Familiarity with HTTP/REST APIs and basic Protocol Buffers

Table of Contents

  1. Introduction
  2. Can gRPC and REST Share the Same Server?
  3. Understanding HTTP/2 Multiplexing
  4. Routing Configuration
  5. Port Configuration Options
  6. Complete Migration Example
  7. Performance Implications
  8. Common Questions
  9. Best Practices

Introduction

Adding gRPC to an existing REST or WebSocket application is a common migration pattern. Unlike frameworks that require separate servers or ports, Spikard leverages HTTP/2's multiplexing capabilities to run both REST and gRPC on the same server instance, simplifying deployment and reducing operational complexity.

This guide demonstrates how to:

  • Add gRPC services to existing HTTP servers
  • Configure routing for mixed protocol traffic
  • Understand the performance trade-offs
  • Migrate incrementally without breaking existing clients

Can gRPC and REST Share the Same Server?

Yes! gRPC and REST can absolutely share the same server instance on the same port.

How It Works

gRPC uses HTTP/2 as its transport protocol. Spikard's routing layer automatically detects the protocol based on the request's Content-Type header:

  • REST requests: Content-Type: application/json
  • gRPC requests: Content-Type: application/grpc or application/grpc+proto

The server examines incoming requests and routes them to the appropriate handler based on this header, enabling transparent protocol multiplexing.

Key Benefits

  1. Single Port: No need for separate ports or proxy configurations
  2. Shared Middleware: Rate limiting, authentication, and compression work for both protocols
  3. Simplified Deployment: One server process, one configuration file
  4. Gradual Migration: Add gRPC endpoints incrementally while maintaining REST compatibility

Understanding HTTP/2 Multiplexing

HTTP/2 multiplexing is the key technology that makes mixed-protocol servers possible.

What Is Multiplexing?

HTTP/2 allows multiple request-response streams to share a single TCP connection. Each stream has a unique identifier, enabling the server to:

  • Process multiple requests concurrently on one connection
  • Interleave responses without head-of-line blocking
  • Distinguish between different protocols (REST vs gRPC)

Protocol Detection Flow

Client Request
     |
     v
[HTTP/2 Connection Established]
     |
     v
[Server Reads Content-Type Header]
     |
     +---> application/json -----> REST Handler
     |
     +---> application/grpc -----> gRPC Handler

Backward Compatibility

For clients that don't support HTTP/2:

  • REST endpoints automatically fall back to HTTP/1.1
  • gRPC requires HTTP/2 (clients must upgrade)
  • WebSocket connections continue to work via HTTP/1.1 upgrade

Routing Configuration

Spikard's routing is content-type aware and requires no special configuration for protocol detection.

Automatic Protocol Routing

The server automatically routes requests based on the path structure and content type:

REST Routing:

GET /users/123 → Matches route pattern: /users/:id

gRPC Routing:

POST /com.example.UserService/GetUser → Matches service/method pattern

Routing Implementation

Under the hood, Spikard uses the is_grpc_request function to detect gRPC traffic:

// From: crates/spikard-http/src/server/grpc_routing.rs
pub fn is_grpc_request(request: &Request<Body>) -> bool {
    request
        .headers()
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .map(|v| v.starts_with("application/grpc"))
        .unwrap_or(false)
}

This check happens before any route matching, ensuring zero overhead for REST requests.

Path Patterns

REST paths: Flexible, support parameters Examples: /users/:id, /api/v1/orders, /health

gRPC paths: Structured as /{package}.{service}/{method} Examples:

  • /com.example.api.UserService/GetUser
  • /api.v1.OrderService/CreateOrder
  • /auth.AuthService/Login

The gRPC path parser extracts service and method names automatically:

// Parses "/com.example.UserService/GetUser"
// Returns: ("com.example.UserService", "GetUser")
let (service_name, method_name) = parse_grpc_path(path)?;

Port Configuration Options

You have three primary options for configuring ports when adding gRPC to existing applications.

Run both REST and gRPC on the same port using HTTP/2 multiplexing.

from spikard import Spikard
from spikard.config import ServerConfig
from spikard.grpc import GrpcHandler

config = ServerConfig(
    host="0.0.0.0",
    port=8080,
    workers=4
)

app = Spikard(config=config)

# REST endpoint
@app.get("/health")
async def health():
    return {"status": "ok"}

# gRPC service
class UserServiceHandler(GrpcHandler):
    def service_name(self) -> str:
        return "com.example.UserService"

    async def handle_request(self, request):
        # Handle gRPC request
        pass

app.register_grpc_service(UserServiceHandler())

if __name__ == "__main__":
    app.run()
import { Spikard, runServer, GrpcHandler, GrpcService } from "spikard";

const config = {
  host: "0.0.0.0",
  port: 8080,
  workers: 4
};

const app = new Spikard();

// REST endpoint
app.addRoute(
  { method: "GET", path: "/health", handler_name: "health", is_async: true },
  async () => ({ status: "ok" })
);

// gRPC service
class UserServiceHandler implements GrpcHandler {
  async handleRequest(request) {
    // Handle gRPC request
  }
}

const grpcService = new GrpcService();
grpcService.registerUnary("com.example.UserService", "GetUser", new UserServiceHandler());
app.useGrpc(grpcService);

runServer(app, config);
require "spikard"

# Server listens on port 8080 for both REST and gRPC
config = Spikard::ServerConfig.new(
  host: "0.0.0.0",
  port: 8080,
  workers: 4
)

app = Spikard::App.new(config: config)

# REST endpoint
app.get("/health") do
  { status: "ok" }
end

# gRPC service (registered separately)
user_service = UserServiceHandler.new
app.register_grpc_service(user_service)

app.run
<?php

declare(strict_types=1);

use Spikard\App;
use Spikard\Config\ServerConfig;
use Spikard\Grpc;
use Spikard\Grpc\HandlerInterface;
use Spikard\Grpc\Request;
use Spikard\Grpc\Response;

$config = new ServerConfig(
    host: '0.0.0.0',
    port: 8080,
    workers: 4
);

$app = new App($config);

// REST endpoint
$app->get('/health', function () {
    return ['status' => 'ok'];
});

// gRPC service
class UserServiceHandler implements HandlerInterface
{
    public function serviceName(): string
    {
        return 'com.example.UserService';
    }

    public function handleRequest(Request $request): Response
    {
        // Handle gRPC request
        return new Response(payload: '');
    }
}

$grpcService = \Spikard\Grpc::createService();
$grpcService->registerHandler('com.example.UserService', new UserServiceHandler());

$app->run();
use spikard::{App, ServerConfig};
use spikard::grpc::{GrpcHandler, GrpcRequest, GrpcResponse};
use async_trait::async_trait;
use tonic::Status;

#[tokio::main]
async fn main() {
    let config = ServerConfig::builder()
        .host("0.0.0.0")
        .port(8080)
        .workers(4)
        .build();

    let app = App::new(config);

    // REST endpoint
    app.get("/health", || async {
        json!({"status": "ok"})
    });

    // gRPC service
    struct UserServiceHandler;

    #[async_trait]
    impl GrpcHandler for UserServiceHandler {
        fn service_name(&self) -> &'static str {
            "com.example.UserService"
        }

        async fn call(&self, request: GrpcRequest) -> Result<GrpcResponse, Status> {
            // Handle gRPC request
            Ok(GrpcResponse::new(vec![]))
        }
    }

    app.register_grpc_service(UserServiceHandler);

    app.run().await;
}

Option 2: Separate Ports (Legacy Compatibility)

Use separate ports if you need to support legacy clients or have strict network policies.

# REST server on port 8080
rest_config = Spikard::ServerConfig.new(port: 8080)
rest_app = Spikard::App.new(config: rest_config)

rest_app.get("/health") { { status: "ok" } }
Thread.new { rest_app.run }

# gRPC server on port 9090
grpc_config = Spikard::ServerConfig.new(port: 9090)
grpc_app = Spikard::App.new(config: grpc_config)

grpc_app.register_grpc_service(UserServiceHandler.new)
grpc_app.run

Trade-offs:

  • Pro: Easier network policy management
  • Pro: Clearer separation in load balancer configs
  • Con: Double resource usage (2x processes)
  • Con: More complex deployment
  • Con: Shared middleware requires duplication

Option 3: External Proxy (Enterprise)

Use a proxy like Envoy or nginx to route protocols to different backends.

# envoy.yaml example
static_resources:
  listeners:
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 443
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          http_filters:
          - name: envoy.filters.http.router
          route_config:
            virtual_hosts:
            - name: backend
              domains: ["*"]
              routes:
              # gRPC traffic
              - match:
                  prefix: "/"
                  headers:
                  - name: content-type
                    prefix_match: application/grpc
                route:
                  cluster: grpc_cluster
              # REST traffic
              - match:
                  prefix: "/"
                route:
                  cluster: rest_cluster

When to use:

  • Advanced routing requirements (A/B testing, canary deployments)
  • Need for protocol transformation (gRPC-Web → gRPC)
  • Enterprise security policies (mTLS termination)

Complete Migration Example

Let's walk through migrating a REST-only user service to support gRPC.

Step 1: Starting Point (REST Only)

# user_service.rb - Before gRPC
require "spikard"

config = Spikard::ServerConfig.new(
  host: "0.0.0.0",
  port: 8080
)

app = Spikard::App.new(config: config)

# Existing REST endpoints
app.get("/users/:id") do |request|
  user_id = request[:params]["id"]

  # Fetch from database
  user = UserRepository.find(user_id)

  {
    id: user.id,
    name: user.name,
    email: user.email,
    created_at: user.created_at.iso8601
  }
end

app.post("/users") do |request|
  data = JSON.parse(request[:body])

  user = UserRepository.create(
    name: data["name"],
    email: data["email"]
  )

  { id: user.id, message: "User created" }
end

app.run

Step 2: Define Protocol Buffer Schema

Create a .proto file defining your gRPC service:

// user_service.proto
syntax = "proto3";

package com.example.api.v1;

service UserService {
  rpc GetUser(GetUserRequest) returns (User);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message GetUserRequest {
  int32 user_id = 1;
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
  string created_at = 4;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message CreateUserResponse {
  int32 id = 1;
  string message = 2;
}

Step 3: Generate Code from Proto File

# Generate Ruby gRPC handlers
spikard generate protobuf \
  user_service.proto \
  --lang ruby \
  --output ./generated.rb

# This creates:
# - generated/user_service_pb.rb (message definitions)
# - generated/user_service_handler.rb (handler template)

Step 4: Implement gRPC Handler

# user_service_handler.rb
require "spikard/grpc"
require_relative "generated/user_service_pb"

class UserServiceHandler
  include Spikard::GrpcHandler

  def service_name
    "com.example.api.v1.UserService"
  end

  def call(request)
    case request.method_name
    when "GetUser"
      handle_get_user(request)
    when "CreateUser"
      handle_create_user(request)
    else
      raise Spikard::Grpc::UnimplementedError, "Method not found"
    end
  end

  private

  def handle_get_user(request)
    # Deserialize protobuf request
    req = GetUserRequest.decode(request.payload)

    # Reuse existing business logic
    user = UserRepository.find(req.user_id)

    # Build protobuf response
    response = User.new(
      id: user.id,
      name: user.name,
      email: user.email,
      created_at: user.created_at.iso8601
    )

    Spikard::Grpc::Response.new(
      payload: User.encode(response)
    )
  end

  def handle_create_user(request)
    req = CreateUserRequest.decode(request.payload)

    user = UserRepository.create(
      name: req.name,
      email: req.email
    )

    response = CreateUserResponse.new(
      id: user.id,
      message: "User created"
    )

    Spikard::Grpc::Response.new(
      payload: CreateUserResponse.encode(response)
    )
  end
end

Step 5: Register gRPC Service (Final)

# user_service.rb - After gRPC migration
require "spikard"
require_relative "user_service_handler"

config = Spikard::ServerConfig.new(
  host: "0.0.0.0",
  port: 8080  # Same port for both protocols!
)

app = Spikard::App.new(config: config)

# ===== REST endpoints (unchanged) =====
app.get("/users/:id") do |request|
  user_id = request[:params]["id"]
  user = UserRepository.find(user_id)

  {
    id: user.id,
    name: user.name,
    email: user.email,
    created_at: user.created_at.iso8601
  }
end

app.post("/users") do |request|
  data = JSON.parse(request[:body])
  user = UserRepository.create(name: data["name"], email: data["email"])

  { id: user.id, message: "User created" }
end

# ===== NEW: gRPC service =====
app.register_grpc_service(UserServiceHandler.new)

# Both REST and gRPC now served on port 8080
app.run

Step 6: Test Both Protocols

Test REST endpoint:

curl http://localhost:8080/users/123
# Response: {"id":123,"name":"Alice","email":"alice@example.com"}

Test gRPC endpoint:

# Using grpcurl
grpcurl -plaintext \
  -d '{"user_id": 123}' \
  localhost:8080 \
  com.example.api.v1.UserService/GetUser

# Response: {
#   "id": 123,
#   "name": "Alice",
#   "email": "alice@example.com"
# }

Both protocols work on the same port!

Performance Implications

Adding gRPC to your server has measurable performance characteristics you should understand.

Binary vs JSON Serialization

Payload Size Comparison (100 user records):

Format Size Reduction
JSON (REST) 4,823 bytes Baseline
Protocol Buffers (gRPC) 1,456 bytes 70% smaller

Serialization Speed (1M operations):

Operation JSON Protobuf Winner
Serialize 2,340ms 890ms Protobuf (2.6x faster)
Deserialize 1,980ms 650ms Protobuf (3.0x faster)

HTTP/2 Multiplexing Overhead

HTTP/2 adds minimal overhead compared to HTTP/1.1:

  • Connection Setup: +1 RTT for ALPN negotiation (one-time cost)
  • Frame Processing: ~2-5% CPU overhead for frame parsing
  • Memory: ~8KB per stream (vs ~16KB per connection in HTTP/1.1)

Net Result: For most applications, HTTP/2's benefits (multiplexing, header compression) outweigh the overhead.

Latency Comparison

Single Request (p50 latency):

Protocol Latency Notes
REST (HTTP/1.1) 12ms Baseline
REST (HTTP/2) 13ms +1ms for frame overhead
gRPC 8ms Faster due to binary encoding

Concurrent Requests (100 simultaneous, p50 latency):

Protocol Latency Connections
REST (HTTP/1.1) 145ms 100 connections
REST (HTTP/2) 38ms 1 connection
gRPC 22ms 1 connection

Key Insight: gRPC's advantage grows with concurrency due to HTTP/2 multiplexing.

Memory Usage

Per-Connection Memory (approximate):

REST HTTP/1.1:  16KB buffer × 100 connections = 1.6MB
REST HTTP/2:    8KB buffer × 1 connection = 8KB
gRPC HTTP/2:    8KB buffer × 1 connection = 8KB

gRPC uses 200x less memory for the same concurrency level.

When to Choose gRPC vs REST

Use gRPC when:

  • High throughput requirements (>1000 req/s)
  • Binary data transfer (files, images)
  • Streaming data (real-time updates)
  • Polyglot microservices (strong typing helps)
  • Mobile clients (battery/bandwidth critical)

Use REST when:

  • Browser clients (gRPC-Web adds complexity)
  • Public APIs (REST is more accessible)
  • Simple CRUD operations
  • Third-party integrations (REST is universal)

Use Both when:

  • Migrating from REST to gRPC
  • Supporting diverse client ecosystems
  • Backend services need gRPC, frontend needs REST

Common Questions

Do I need a separate port for gRPC?

No. Spikard's HTTP/2 implementation allows both REST and gRPC on the same port. The server automatically routes based on the Content-Type header.

How does routing work with mixed protocols?

The server checks the Content-Type header before route matching:

  1. Content-Type: application/grpc → Route to gRPC handler via service/method path
  2. Content-Type: application/json → Route to REST handler via URL pattern matching
  3. Other types → Route based on registered handlers

This happens transparently with zero configuration.

Can WebSockets and gRPC coexist?

Yes. WebSocket connections use the HTTP/1.1 upgrade mechanism and don't conflict with gRPC's HTTP/2 streams. The same server can handle:

  • REST requests (HTTP/1.1 or HTTP/2)
  • gRPC requests (HTTP/2)
  • WebSocket connections (HTTP/1.1 upgrade)

Example:

app.get("/health")  # REST
app.websocket("/chat")  # WebSocket
app.register_grpc_service(UserServiceHandler.new)  # gRPC

All three work on the same port.

Do middleware and rate limits apply to both protocols?

Yes. Middleware configured in ServerConfig applies to all protocols:

config = Spikard::ServerConfig.new(
  compression: Spikard::CompressionConfig.new(gzip: true),
  rate_limit: Spikard::RateLimitConfig.new(per_second: 100, burst: 200),
  jwt_auth: Spikard::JwtConfig.new(secret: ENV["JWT_SECRET"])
)

This configuration affects:

  • REST endpoints → Headers checked, responses compressed
  • gRPC services → Metadata checked, payloads compressed
  • WebSocket connections → Headers checked during handshake

What about existing REST clients?

Existing REST clients continue to work without changes. They use HTTP/1.1 and are unaware of gRPC's existence. You can:

  1. Keep all existing REST endpoints active
  2. Add new gRPC endpoints for internal services
  3. Gradually migrate clients to gRPC as needed

No breaking changes required.

How do I handle authentication across protocols?

Use the same authentication mechanism for both protocols. JWT example:

REST Request:

GET /users/123
Authorization: Bearer <jwt-token>

gRPC Request:

Metadata: authorization: Bearer <jwt-token>

Spikard's JWT middleware validates both:

config = Spikard::ServerConfig.new(
  jwt_auth: Spikard::JwtConfig.new(
    secret: ENV["JWT_SECRET"],
    algorithm: "HS256"
  )
)

The server checks:

  • REST: Authorization HTTP header
  • gRPC: authorization metadata field

Same validation, different transport.

Best Practices

1. Start with Internal Services

Begin your gRPC migration with internal microservice communication:

[Web Frontend] --REST--> [API Gateway] --gRPC--> [Backend Services]

Benefits:

  • Control both client and server
  • Easier debugging
  • Performance gains where they matter most

2. Version Your Proto Files

Always version your .proto schemas:

syntax = "proto3";

package com.example.api.v1;  // Version in package name

service UserService {
  // ...
}

Migration path:

v1: Current production
v2: New features, backward compatible
v3: Breaking changes (when v1 clients deprecated)

3. Reuse Business Logic

Don't duplicate logic between REST and gRPC handlers:

# Bad: Duplicate logic
app.get("/users/:id") do
  user = User.find(params[:id])
  # ... formatting logic
end

class UserServiceHandler
  def handle_get_user(request)
    user = User.find(request.user_id)
    # ... duplicate formatting logic
  end
end

# Good: Shared service layer
class UserService
  def self.find_user(id)
    user = UserRepository.find(id)
    format_user(user)
  end

  private

  def self.format_user(user)
    # Shared formatting logic
  end
end

# REST handler
app.get("/users/:id") do
  UserService.find_user(params[:id])
end

# gRPC handler
class UserServiceHandler
  def handle_get_user(request)
    user_data = UserService.find_user(request.user_id)
    User.new(user_data)  # Just convert to protobuf
  end
end

4. Monitor Protocol Usage

Track which protocol clients are using:

app.before_request do |request|
  protocol = if request.headers["content-type"]&.start_with?("application/grpc")
    "grpc"
  else
    "rest"
  end

  Metrics.increment("requests.by_protocol", tags: { protocol: protocol })
end

This helps you:

  • Measure migration progress
  • Identify clients to upgrade
  • Plan deprecation timelines

5. Test Both Protocols

Include tests for both REST and gRPC endpoints:

# spec/user_service_spec.rb
RSpec.describe "User Service" do
  describe "REST API" do
    it "returns user via GET /users/:id" do
      response = RestClient.get("http://localhost:8080/users/123")
      expect(response.code).to eq(200)
    end
  end

  describe "gRPC API" do
    it "returns user via GetUser RPC" do
      stub = UserService::Stub.new("localhost:8080", :this_channel_is_insecure)
      response = stub.get_user(GetUserRequest.new(user_id: 123))
      expect(response.id).to eq(123)
    end
  end
end

6. Document Migration Status

Maintain a migration tracker in your README:

## API Endpoints

| Endpoint | REST | gRPC | Notes |
|----------|------|------|-------|
| Get User | Yes | Yes | Both supported |
| Create User | Yes | Yes | Both supported |
| List Users | Yes | WIP | gRPC in progress |
| Delete User | Yes | No | REST only (deprecated in gRPC v2) |

This helps teams coordinate client upgrades.


Summary

Adding gRPC to existing REST/WebSocket applications in Spikard is straightforward:

  1. Same Port: Both protocols run on the same server port via HTTP/2 multiplexing
  2. Automatic Routing: Content-Type headers route requests to the correct handler
  3. Shared Middleware: Authentication, rate limiting, and compression work for both
  4. Incremental Migration: Add gRPC endpoints gradually without breaking REST clients
  5. Performance Gains: 70% smaller payloads, 2-3x faster serialization, 200x less memory

Next Steps:

Start by migrating one internal service, measure the performance improvement, then expand to more endpoints as your team gains confidence with gRPC.