MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

gRPC vs REST: High-Performance API Communication

Compare gRPC and REST: Protocol Buffers, streaming, performance, and use cases.

gRPCRESTAPIMicroservices

By MinhVo

Introduction

The choice between gRPC and REST represents a fundamental architectural decision that impacts system performance, developer experience, and operational complexity. While REST has dominated API design for over two decades, gRPC—Google's open-source RPC framework—has emerged as the preferred choice for high-performance inter-service communication in microservice architectures.

gRPC offers Protocol Buffers for binary serialization, native HTTP/2 multiplexing, bidirectional streaming, and automatic code generation across multiple languages. These capabilities deliver 3-10x performance improvements over REST's JSON-based communication. However, REST's simplicity, universal browser support, and HTTP alignment make it the safer choice for public APIs and client-facing services.

This comprehensive comparison dissects both paradigms across performance, developer experience, streaming capabilities, tooling ecosystems, and operational concerns. We'll benchmark real-world scenarios, implement equivalent services in both approaches, and provide a clear decision framework for choosing between them.

API Performance Comparison

Understanding gRPC: The High-Performance Alternative

gRPC is a modern, open-source RPC framework that uses Protocol Buffers (protobuf) as its interface definition language and serialization format. Built on HTTP/2, gRPC provides features that REST cannot match: bidirectional streaming, header compression, and multiplexing multiple requests over a single TCP connection.

Protocol Buffers: Binary Serialization

Protocol Buffers define service contracts in .proto files and generate client and server code in multiple languages. The binary serialization format produces payloads 3-10x smaller than JSON while serializing and deserializing significantly faster.

syntax = "proto3";
 
package users;
 
import "google/protobuf/timestamp.proto";
 
service UserService {
  // Unary RPC - request/response
  rpc GetUser (GetUserRequest) returns (User);
 
  // Server streaming - server sends multiple responses
  rpc ListUsers (ListUsersRequest) returns (stream User);
 
  // Client streaming - client sends multiple requests
  rpc CreateUsers (stream CreateUserRequest) returns (CreateUsersResponse);
 
  // Bidirectional streaming - both sides stream
  rpc WatchUsers (WatchUsersRequest) returns (stream UserEvent);
}
 
message User {
  string id = 1;
  string name = 2;
  string email = 3;
  UserRole role = 4;
  repeated string tags = 5;
  google.protobuf.Timestamp created_at = 6;
  google.protobuf.Timestamp updated_at = 7;
}
 
enum UserRole {
  USER_ROLE_UNSPECIFIED = 0;
  USER_ROLE_USER = 1;
  USER_ROLE_ADMIN = 2;
}
 
message GetUserRequest {
  string id = 1;
}
 
message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
  string filter = 3;
}
 
message CreateUserRequest {
  string name = 1;
  string email = 2;
  UserRole role = 3;
}
 
message CreateUsersResponse {
  repeated User users = 1;
  int32 created_count = 2;
}
 
message WatchUsersRequest {
  repeated string user_ids = 1;
}
 
message UserEvent {
  enum EventType {
    EVENT_TYPE_UNSPECIFIED = 0;
    EVENT_TYPE_CREATED = 1;
    EVENT_TYPE_UPDATED = 2;
    EVENT_TYPE_DELETED = 3;
  }
 
  EventType type = 1;
  User user = 2;
  google.protobuf.Timestamp timestamp = 3;
}

HTTP/2 Advantages

gRPC leverages HTTP/2 features that REST (typically using HTTP/1.1) cannot:

Multiplexing: Multiple requests and responses can be in flight simultaneously over a single connection, eliminating head-of-line blocking that plagues HTTP/1.1.

Header Compression: HPACK compression reduces header overhead, particularly valuable for repetitive metadata like authentication tokens.

Binary Framing: HTTP/2's binary framing layer is more efficient to parse than HTTP/1.1's text-based protocol.

// gRPC client with HTTP/2 connection
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
 
const packageDefinition = protoLoader.loadSync('user_service.proto', {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
 
const proto = grpc.loadPackageDefinition(packageDefinition);
 
const client = new proto.users.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure(),
  {
    'grpc.max_concurrent_streams': 1000,
    'grpc.keepalive_time_ms': 10000,
    'grpc.keepalive_timeout_ms': 5000,
    'grpc.http2.min_time_between_pings_ms': 10000,
  }
);

Understanding REST: The Web Standard

REST (Representational State Transfer) leverages HTTP's existing semantics—verbs, status codes, headers, and URIs—to create a uniform interface for accessing resources. Its alignment with web standards makes it universally accessible and immediately understandable.

Resource-Oriented Design

REST organizes APIs around resources, each identified by a unique URI. Operations use HTTP verbs to express intent, and status codes communicate outcomes universally.

// Express.js REST API
import express from 'express';
import { PrismaClient } from '@prisma/client';
 
const app = express();
const prisma = new PrismaClient();
 
app.use(express.json());
 
// GET /users - List users with pagination
app.get('/users', async (req, res) => {
  const { page = 1, limit = 20, filter } = req.query;
  const offset = (Number(page) - 1) * Number(limit);
 
  const where = filter ? { name: { contains: String(filter), mode: 'insensitive' } } : {};
 
  const [users, total] = await Promise.all([
    prisma.user.findMany({
      where,
      skip: offset,
      take: Number(limit),
      orderBy: { createdAt: 'desc' },
    }),
    prisma.user.count({ where }),
  ]);
 
  res.set('X-Total-Count', String(total));
  res.set('Link', buildLinkHeader(Number(page), Math.ceil(total / Number(limit)), req.baseUrl));
  res.json(users);
});
 
// GET /users/:id - Get single user
app.get('/users/:id', async (req, res) => {
  const user = await prisma.user.findUnique({
    where: { id: req.params.id },
    include: { profile: true },
  });
 
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});
 
// POST /users - Create user
app.post('/users', async (req, res) => {
  const { name, email } = req.body;
 
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }
 
  try {
    const user = await prisma.user.create({ data: { name, email } });
    res.status(201).set('Location', `/users/${user.id}`).json(user);
  } catch (err) {
    if (err.code === 'P2002') {
      return res.status(409).json({ error: 'Email already exists' });
    }
    throw err;
  }
});
 
// Streaming with Server-Sent Events
app.get('/users/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });
 
  const listener = (user) => {
    res.write(`data: ${JSON.stringify(user)}\n\n`);
  };
 
  eventEmitter.on('user:changed', listener);
 
  req.on('close', () => {
    eventEmitter.off('user:changed', listener);
  });
});

Architecture and Design Patterns

Error Handling Comparison

REST and gRPC handle errors fundamentally differently, reflecting their design philosophies.

REST uses HTTP status codes with structured error bodies:

// REST error handling
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: Record<string, any>
  ) {
    super(message);
  }
}
 
// Error middleware
app.use((err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      error: {
        code: err.code,
        message: err.message,
        details: err.details,
      },
    });
  }
 
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: { code: 'INTERNAL', message: 'Internal server error' },
  });
});
 
// Usage
app.get('/users/:id', async (req, res) => {
  const user = await prisma.user.findUnique({ where: { id: req.params.id } });
  if (!user) {
    throw new AppError(404, 'USER_NOT_FOUND', `User ${req.params.id} not found`);
  }
  res.json(user);
});

gRPC uses status codes and metadata:

// gRPC error handling
import * as grpc from '@grpc/grpc-js';
 
const serviceImpl = {
  getUser: async (call, callback) => {
    try {
      const user = await prisma.user.findUnique({
        where: { id: call.request.id },
      });
 
      if (!user) {
        return callback({
          code: grpc.status.NOT_FOUND,
          message: `User ${call.request.id} not found`,
          metadata: new grpc.Metadata({
            'error-type': 'USER_NOT_FOUND',
          }),
        });
      }
 
      callback(null, user);
    } catch (err) {
      callback({
        code: grpc.status.INTERNAL,
        message: 'Internal server error',
      });
    }
  },
};
 
// Client-side error handling
client.getUser({ id: 'nonexistent' }, (err, response) => {
  if (err) {
    switch (err.code) {
      case grpc.status.NOT_FOUND:
        console.log('User not found:', err.message);
        break;
      case grpc.status.PERMISSION_DENIED:
        console.log('Permission denied');
        break;
      case grpc.status.UNAVAILABLE:
        console.log('Service unavailable, retrying...');
        break;
    }
  }
});

Streaming Patterns

gRPC's four streaming modes enable patterns that REST cannot implement natively.

// gRPC server streaming - list users with live updates
const listUsersStream = (call) => {
  const { pageSize, pageToken, filter } = call.request;
  let cursor = pageToken ? Buffer.from(pageToken, 'base64').toString() : null;
 
  const streamUsers = async () => {
    const users = await prisma.user.findMany({
      take: pageSize,
      skip: cursor ? 1 : 0,
      cursor: cursor ? { id: cursor } : undefined,
      where: filter ? { name: { contains: filter } } : undefined,
      orderBy: { createdAt: 'desc' },
    });
 
    for (const user of users) {
      call.write(user);
    }
 
    if (users.length === pageSize) {
      cursor = users[users.length - 1].id;
      // Continue streaming
      setTimeout(streamUsers, 0);
    } else {
      call.end();
    }
  };
 
  streamUsers().catch(err => call.destroy(err));
};
 
// gRPC bidirectional streaming - real-time user updates
const watchUsers = (call) => {
  const subscriptions = new Map();
 
  call.on('data', (request) => {
    request.userIds.forEach(userId => {
      const listener = (event) => {
        call.write({
          type: event.type,
          user: event.user,
          timestamp: { seconds: Date.now() / 1000 },
        });
      };
 
      eventEmitter.on(`user:${userId}`, listener);
      subscriptions.set(userId, listener);
    });
  });
 
  call.on('end', () => {
    subscriptions.forEach((listener, userId) => {
      eventEmitter.off(`user:${userId}`, listener);
    });
    call.end();
  });
 
  call.on('cancelled', () => {
    subscriptions.forEach((listener, userId) => {
      eventEmitter.off(`user:${userId}`, listener);
    });
  });
};

REST achieves similar patterns through Server-Sent Events and WebSockets, but with additional infrastructure requirements:

// REST Server-Sent Events for real-time updates
app.get('/users/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });
 
  const sendEvent = (type, data) => {
    res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
  };
 
  const listeners = {
    created: (user) => sendEvent('created', user),
    updated: (user) => sendEvent('updated', user),
    deleted: (user) => sendEvent('deleted', user),
  };
 
  Object.entries(listeners).forEach(([event, listener]) => {
    eventEmitter.on(`user:${event}`, listener);
  });
 
  req.on('close', () => {
    Object.entries(listeners).forEach(([event, listener]) => {
      eventEmitter.off(`user:${event}`, listener);
    });
  });
});

gRPC vs REST Performance

Step-by-Step Implementation

gRPC Server with Node.js

npm install @grpc/grpc-js @grpc/proto-loader
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
 
const packageDefinition = protoLoader.loadSync('./proto/user_service.proto', {
  keepCase: true,
  longs: String,
  enums: String,
  defaults: true,
  oneofs: true,
});
 
const proto = grpc.loadPackageDefinition(packageDefinition);
 
const server = new grpc.Server();
 
server.addService(proto.users.UserService.service, {
  getUser: async (call, callback) => {
    try {
      const user = await prisma.user.findUnique({
        where: { id: call.request.id },
      });
 
      if (!user) {
        return callback({
          code: grpc.status.NOT_FOUND,
          message: `User ${call.request.id} not found`,
        });
      }
 
      callback(null, user);
    } catch (err) {
      console.error('getUser error:', err);
      callback({
        code: grpc.status.INTERNAL,
        message: 'Internal server error',
      });
    }
  },
 
  createUser: async (call, callback) => {
    try {
      const user = await prisma.user.create({
        data: {
          name: call.request.name,
          email: call.request.email,
          role: call.request.role || 'USER',
        },
      });
 
      callback(null, user);
    } catch (err) {
      if (err.code === 'P2002') {
        return callback({
          code: grpc.status.ALREADY_EXISTS,
          message: 'Email already exists',
        });
      }
      callback({
        code: grpc.status.INTERNAL,
        message: 'Internal server error',
      });
    }
  },
 
  listUsers: (call) => {
    const { pageSize = 10, pageToken, filter } = call.request;
    let cursor = pageToken ? Buffer.from(pageToken, 'base64').toString() : null;
 
    prisma.user.findMany({
      take: pageSize,
      skip: cursor ? 1 : 0,
      cursor: cursor ? { id: cursor } : undefined,
      where: filter ? { name: { contains: filter, mode: 'insensitive' } } : undefined,
      orderBy: { createdAt: 'desc' },
    })
    .then(users => {
      users.forEach(user => call.write(user));
      call.end();
    })
    .catch(err => {
      console.error('listUsers error:', err);
      call.destroy({
        code: grpc.status.INTERNAL,
        message: 'Internal server error',
      });
    });
  },
 
  watchUsers: (call) => {
    const subscriptions = new Map();
 
    call.on('data', (request) => {
      request.userIds.forEach(userId => {
        const listener = (event) => {
          call.write({
            type: event.type,
            user: event.user,
            timestamp: { seconds: Math.floor(Date.now() / 1000) },
          });
        };
 
        eventEmitter.on(`user:${userId}`, listener);
        subscriptions.set(userId, listener);
      });
    });
 
    call.on('end', () => {
      subscriptions.forEach((listener, userId) => {
        eventEmitter.off(`user:${userId}`, listener);
      });
      call.end();
    });
  },
});
 
server.bindAsync(
  '0.0.0.0:50051',
  grpc.ServerCredentials.createInsecure(),
  (err, port) => {
    if (err) {
      console.error('Failed to bind server:', err);
      process.exit(1);
    }
    console.log(`🚀 gRPC server running on port ${port}`);
  }
);

REST Client with Fetch

const API_BASE = 'http://localhost:3000';
 
async function getUser(id: string): Promise<User> {
  const response = await fetch(`${API_BASE}/users/${id}`);
  if (!response.ok) {
    if (response.status === 404) throw new Error('User not found');
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}
 
async function createUser(data: CreateUserRequest): Promise<User> {
  const response = await fetch(`${API_BASE}/users`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message);
  }
  return response.json();
}
 
function subscribeToUserEvents(onEvent: (event: UserEvent) => void): () => void {
  const eventSource = new EventSource(`${API_BASE}/users/events`);
 
  eventSource.addEventListener('created', (e) => onEvent(JSON.parse(e.data)));
  eventSource.addEventListener('updated', (e) => onEvent(JSON.parse(e.data)));
  eventSource.addEventListener('deleted', (e) => onEvent(JSON.parse(e.data)));
 
  return () => eventSource.close();
}

Real-World Use Cases

Use Case 1: Internal Microservice Communication

A payment processing service communicates with user, inventory, and notification services at high volume. gRPC's binary serialization and HTTP/2 multiplexing reduce latency by 60% compared to the previous REST implementation, directly impacting checkout conversion rates.

Use Case 2: Public API for Third-Party Developers

An API consumed by thousands of external developers with diverse technology stacks benefits from REST's universal support. Every programming language has robust HTTP client libraries, and developers can test endpoints with curl or browser-based tools.

Use Case 3: Real-Time Data Pipeline

A data pipeline service streams millions of events per second between processing stages. gRPC's bidirectional streaming handles this workload efficiently over persistent connections, while REST would require WebSocket infrastructure with additional operational complexity.

Use Case 4: Mobile Application Backend

A mobile application's backend uses REST for its public-facing API (simpler client libraries, better caching) but gRPC for internal service-to-service communication. This hybrid approach optimizes for both developer experience and performance.

Best Practices for Production

  1. Use gRPC for internal services, REST for external APIs: The performance benefits of gRPC are most impactful for high-volume internal communication. REST's simplicity and universal support make it better for external-facing APIs.

  2. Implement proper timeout propagation: In gRPC, always propagate deadlines through call chains. A client setting a 5-second timeout expects the entire operation to complete within that window, including downstream service calls.

  3. Design REST resources around business concepts: Avoid exposing database tables directly. Resources should represent business entities and support the operations that clients actually need.

  4. Use Protocol Buffer versioning: Add new fields to proto messages rather than modifying existing ones. Use field numbers consistently to maintain backward compatibility.

  5. Implement health checking: Both gRPC and REST services should expose health check endpoints. gRPC has a standard health checking protocol; REST services should return structured health status.

  6. Set connection keepalive parameters: Configure gRPC keepalive to detect dead connections quickly without overwhelming the network with pings.

  7. Use REST content negotiation: Support multiple response formats (JSON, MessagePack, Protocol Buffers) through content negotiation headers to optimize for different client needs.

  8. Monitor all service communication: Track request rates, error rates, and latency percentiles for both gRPC and REST services. Set up alerts for anomalies.

Common Pitfalls and Solutions

PitfallImpactSolution
gRPC in browser without proxyBrowser can't make gRPC requestsUse gRPC-Web or Envoy proxy
REST without paginationUnbounded responses crash clientsImplement cursor or offset pagination
gRPC missing deadline propagationCascading timeouts in call chainsAlways propagate deadlines
REST versioning debtMaintaining multiple versions is costlyPrefer additive evolution
Protocol buffer backward incompatibilityClients break on schema changesUse field numbers consistently, add don't modify
REST over-fetchingWasted bandwidth on mobileSupport sparse fieldsets or use BFF pattern
gRPC connection exhaustionConnection pool depletionUse connection pooling and proper cleanup

Performance Optimization

Benchmarking Results

Benchmark comparisons between gRPC and REST reveal significant performance differences:

Serialization Speed: Protocol Buffers serialize 5-10x faster than JSON. For a typical user object with nested data, protobuf serializes in 0.01ms vs JSON's 0.08ms.

Payload Size: Binary payloads are 3-5x smaller. A user list response of 100 users is ~15KB in protobuf vs ~50KB in JSON.

Throughput: gRPC handles 2-5x more requests per second under load, primarily due to HTTP/2 multiplexing and reduced serialization overhead.

Latency: p50 latency is similar for simple operations, but p99 latency is significantly better for gRPC due to HTTP/2's connection efficiency.

// Performance benchmark setup
import Benchmark from 'benchmark';
 
const suite = new Benchmark.Suite();
 
suite
  .add('REST JSON serialization', () => {
    JSON.stringify(userPayload);
  })
  .add('Protobuf serialization', () => {
    User.encode(userPayload).finish();
  })
  .on('cycle', (event) => {
    console.log(String(event.target));
  })
  .on('complete', function () {
    console.log('Fastest: ' + this.filter('fastest').map('name'));
  })
  .run();

Comparison with Alternatives

FeaturegRPCRESTGraphQLtRPC
SerializationBinary (protobuf)JSON (text)JSON (text)JSON
ProtocolHTTP/2HTTP/1.1, HTTP/2HTTPHTTP
StreamingNative bidirectionalSSE, WebSocketSubscriptionsN/A
Code GenerationProto compilerOpenAPIGraphQL CodegenBuilt-in
Browser SupportgRPC-Web proxyNativeNativeNative
Type SafetyGeneratedOptionalSchema-firstEnd-to-end
CachingCustomHTTP cacheApplicationHTTP cache
Learning CurveModerateLowModerateLow

Advanced Patterns

gRPC with Envoy Service Mesh

# envoy.yaml - gRPC service mesh configuration
static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address:
          address: 0.0.0.0
          port_value: 8080
      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
                route_config:
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - match:
                            prefix: "/"
                          route:
                            cluster: user_service
                http_filters:
                  - name: envoy.filters.http.grpc_web
                  - name: envoy.filters.http.cors
                  - name: envoy.filters.http.router
  clusters:
    - name: user_service
      connect_timeout: 0.25s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      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: user_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: user-service
                      port_value: 50051

REST with OpenAPI Code Generation

# openapi.yaml
openapi: 3.0.3
info:
  title: User Service API
  version: 1.0.0
paths:
  /users:
    get:
      summary: List users
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
      responses:
        '200':
          description: User list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
    post:
      summary: Create user
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: User created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        email:
          type: string

Testing Strategies

gRPC Integration Testing

import * as grpc from '@grpc/grpc-js';
import { createTestClient } from './test-utils';
 
describe('UserService gRPC', () => {
  let client: any;
 
  beforeAll(() => {
    client = createTestClient();
  });
 
  it('creates and retrieves a user', (done) => {
    client.createUser({ name: 'Test', email: 'test@example.com' }, (err, created) => {
      expect(err).toBeNull();
      expect(created.name).toBe('Test');
 
      client.getUser({ id: created.id }, (err, retrieved) => {
        expect(err).toBeNull();
        expect(retrieved.name).toBe('Test');
        done();
      });
    });
  });
 
  it('handles not found correctly', (done) => {
    client.getUser({ id: 'nonexistent' }, (err) => {
      expect(err.code).toBe(grpc.status.NOT_FOUND);
      done();
    });
  });
});

REST Integration Testing

import request from 'supertest';
import { app } from '../server';
 
describe('Users REST API', () => {
  it('creates and retrieves a user', async () => {
    const createRes = await request(app)
      .post('/users')
      .send({ name: 'Test', email: 'test@example.com' })
      .expect(201);
 
    expect(createRes.body.name).toBe('Test');
 
    const getRes = await request(app)
      .get(`/users/${createRes.body.id}`)
      .expect(200);
 
    expect(getRes.body.name).toBe('Test');
  });
 
  it('returns 404 for nonexistent user', async () => {
    await request(app)
      .get('/users/nonexistent')
      .expect(404);
  });
});

Future Outlook

gRPC adoption accelerates with the growth of microservice architectures and Kubernetes-native deployments. The Connect protocol from Buf addresses gRPC's browser compatibility limitations, while gRPC-Web continues improving. REST remains dominant for public APIs, with OpenAPI 3.1 bringing full JSON Schema alignment and tools like tRPC offering end-to-end type safety.

The trend toward polyglot API strategies—using gRPC for internal services and REST or GraphQL for external APIs—will likely accelerate as organizations recognize that different communication patterns require different tools.

Conclusion

The gRPC vs REST decision should be driven by your specific context, not technology trends. gRPC excels in high-performance internal communication—its binary serialization, HTTP/2 multiplexing, and native streaming deliver measurable performance improvements for microservice architectures. REST remains the pragmatic choice for public APIs, browser-facing services, and teams that value simplicity and universal tooling support.

The most successful architectures use both: gRPC for internal service mesh communication where performance is critical, and REST for external APIs where developer experience and broad compatibility matter. Invest in proper timeout propagation, health checking, and observability regardless of which approach you choose.

Start with REST unless you have specific, measurable performance requirements that gRPC addresses. If you do adopt gRPC, invest in proper error handling, deadline propagation, and monitoring from day one. The performance benefits are real, but they come with operational complexity that must be managed deliberately.