DEV Community

Ishaan Pandey
Ishaan Pandey

Posted on • Originally published at ishaaan.hashnode.dev

HTTP Protocols Face-Off: REST vs GraphQL vs gRPC vs tRPC — Which One Wins?

HTTP Protocols Face-Off: REST vs GraphQL vs gRPC vs tRPC

There's no single "best" API protocol — only the best one for your use case.

If you've ever found yourself in a debate about REST vs GraphQL, or wondered what gRPC and tRPC actually are, this is the guide that settles it. We'll go deep into each protocol — how it works, when it shines, when it falls flat — and give you a concrete framework for picking the right one.

No hype, no bias. Just engineering trade-offs.


Table of Contents

  1. A Brief History of API Protocols
  2. REST — The Industry Standard
  3. GraphQL — Query What You Need
  4. gRPC — High-Performance RPC
  5. tRPC — End-to-End Type Safety
  6. WebSockets, SSE & Webhooks
  7. Head-to-Head Comparison
  8. Real-World Usage
  9. Decision Framework
  10. Mixing Protocols — API Gateway & BFF Patterns
  11. Common Mistakes

A Brief History of API Protocols

1990s          2000s           2010s           2020s
  │              │               │               │
  ▼              ▼               ▼               ▼
CORBA/       SOAP/XML      REST becomes    GraphQL, gRPC,
DCOM         (verbose,      the standard   tRPC rise.
(binary,     WS-* specs)    (JSON, HTTP)   Specialization
complex)                                    era begins.
  │              │               │               │
  └──────────────┴───────────────┴───────────────┘
                    Trend: Simpler, faster, more typed
Enter fullscreen mode Exit fullscreen mode
  • 1990s: CORBA, DCOM — binary RPC protocols, very complex, language-specific
  • Early 2000s: SOAP — XML-based, with WSDLs and heavyweight tooling. Enterprise loved it, developers hated it.
  • 2000: Roy Fielding defines REST in his PhD dissertation
  • 2010s: REST + JSON becomes the universal standard. Simple, stateless, works everywhere.
  • 2012: gRPC (originally Stubby) at Google — high-performance RPC using Protocol Buffers
  • 2015: Facebook open-sources GraphQL — query exactly what you need
  • 2020s: tRPC emerges — end-to-end type safety for TypeScript stacks. The "no schema" API.

We're now in an era where different protocols solve different problems. Let's break each one down.


REST — The Industry Standard

REST (Representational State Transfer) is an architectural style for designing networked applications. It uses standard HTTP methods to perform CRUD operations on resources.

Core Concepts

Resource:    /api/users/123       (a specific user)
Collection:  /api/users           (all users)
Nested:      /api/users/123/posts (posts by user 123)

HTTP Methods:
  GET     → Read a resource
  POST    → Create a resource
  PUT     → Replace a resource entirely
  PATCH   → Partially update a resource
  DELETE  → Remove a resource
Enter fullscreen mode Exit fullscreen mode

HTTP Status Codes (The Important Ones)

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST
204 No Content Successful DELETE
400 Bad Request Invalid input / validation error
401 Unauthorized Not authenticated
403 Forbidden Authenticated but not authorized
404 Not Found Resource doesn't exist
409 Conflict Duplicate resource / state conflict
422 Unprocessable Entity Semantic validation error
429 Too Many Requests Rate limited
500 Internal Server Error Something broke on the server

HATEOAS (The Part Everyone Ignores)

REST's full vision includes HATEOAS — Hypermedia As The Engine Of Application State. The idea: API responses include links to related actions, so clients don't need to hardcode URLs.

{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com",
  "_links": {
    "self": { "href": "/api/users/123" },
    "posts": { "href": "/api/users/123/posts" },
    "update": { "href": "/api/users/123", "method": "PATCH" },
    "delete": { "href": "/api/users/123", "method": "DELETE" }
  }
}
Enter fullscreen mode Exit fullscreen mode

In practice, almost nobody implements true HATEOAS. Most "REST" APIs are really just HTTP JSON APIs — and that's fine.

Code Example — Express.js REST API

const express = require('express');
const app = express();
app.use(express.json());

// In-memory store for demo
let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
];

// GET /api/users — List all users
app.get('/api/users', (req, res) => {
  const { page = 1, limit = 10 } = req.query;
  const start = (page - 1) * limit;
  const paginated = users.slice(start, start + Number(limit));

  res.json({
    data: paginated,
    meta: { page: Number(page), limit: Number(limit), total: users.length },
  });
});

// GET /api/users/:id — Get a specific user
app.get('/api/users/:id', (req, res) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

// POST /api/users — Create a user
app.post('/api/users', (req, res) => {
  const { name, email } = req.body;
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }

  const newUser = { id: users.length + 1, name, email };
  users.push(newUser);
  res.status(201).json(newUser);
});

// PATCH /api/users/:id — Update a user
app.patch('/api/users/:id', (req, res) => {
  const user = users.find((u) => u.id === Number(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });

  Object.assign(user, req.body);
  res.json(user);
});

// DELETE /api/users/:id — Delete a user
app.delete('/api/users/:id', (req, res) => {
  users = users.filter((u) => u.id !== Number(req.params.id));
  res.status(204).end();
});

app.listen(3000, () => console.log('REST API on port 3000'));
Enter fullscreen mode Exit fullscreen mode

Pros and Cons

Pros Cons
Simple, universally understood Over-fetching / under-fetching
Works with any language/platform Multiple round-trips for related data
Cacheable by default (HTTP caching) No built-in type system
Great tooling (Postman, Swagger, etc.) API versioning can get messy
Stateless — easy to scale Can lead to endpoint explosion

When to Use REST

  • Public APIs that need to be consumed by anyone
  • Simple CRUD applications
  • When HTTP caching is important
  • When you need maximum compatibility
  • When your team knows REST well and the use case doesn't demand anything fancier

GraphQL — Query What You Need

GraphQL is a query language for APIs developed by Facebook in 2012 (open-sourced in 2015). Instead of multiple endpoints, you have a single endpoint where clients specify exactly what data they want.

Core Concepts

REST approach (3 requests):
  GET /api/user/123           → { id, name, email, bio, avatar, ... }
  GET /api/user/123/posts     → [{ id, title, body, ... }, ...]
  GET /api/user/123/followers → [{ id, name, ... }, ...]

GraphQL approach (1 request):
  POST /graphql
  {
    user(id: 123) {
      name
      posts(limit: 5) {
        title
      }
      followersCount
    }
  }
Enter fullscreen mode Exit fullscreen mode

The client gets exactly the fields it needs — no over-fetching, no under-fetching.

Schema Definition

GraphQL is strongly typed. You define your schema, and everything is validated against it.

# schema.graphql

type User {
  id: ID!
  name: String!
  email: String!
  bio: String
  posts(limit: Int = 10): [Post!]!
  followers: [User!]!
  followersCount: Int!
}

type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments: [Comment!]!
  createdAt: String!
}

type Comment {
  id: ID!
  text: String!
  author: User!
}

type Query {
  user(id: ID!): User
  users(page: Int, limit: Int): [User!]!
  post(id: ID!): Post
}

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
  createPost(title: String!, body: String!, authorId: ID!): Post!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}
Enter fullscreen mode Exit fullscreen mode

Queries, Mutations, and Subscriptions

# Query — read data
query GetUserProfile {
  user(id: "123") {
    name
    email
    posts(limit: 5) {
      title
      createdAt
    }
    followersCount
  }
}

# Mutation — write data
mutation CreatePost {
  createPost(title: "Hello World", body: "My first post", authorId: "123") {
    id
    title
    createdAt
  }
}

# Subscription — real-time updates
subscription OnNewComment {
  commentAdded(postId: "456") {
    text
    author {
      name
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Code Example — Apollo Server

const { ApolloServer, gql } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');

const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    body: String!
    author: User!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    post(id: ID!): Post
  }

  type Mutation {
    createUser(name: String!, email: String!): User!
    createPost(title: String!, body: String!, authorId: ID!): Post!
  }
`;

// Resolvers — how each field is fetched
const resolvers = {
  Query: {
    users: () => db.users.findAll(),
    user: (_, { id }) => db.users.findById(id),
    post: (_, { id }) => db.posts.findById(id),
  },
  Mutation: {
    createUser: (_, { name, email }) => db.users.create({ name, email }),
    createPost: (_, { title, body, authorId }) =>
      db.posts.create({ title, body, authorId }),
  },
  // Nested resolvers — this is where GraphQL shines
  User: {
    posts: (parent) => db.posts.findByAuthorId(parent.id),
  },
  Post: {
    author: (parent) => db.users.findById(parent.authorId),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

startStandaloneServer(server, { listen: { port: 4000 } }).then(({ url }) => {
  console.log(`GraphQL server at ${url}`);
});
Enter fullscreen mode Exit fullscreen mode

The N+1 Problem

The biggest performance pitfall in GraphQL. Consider this query:

{
  users {        # 1 query to get all users
    name
    posts {      # N queries — one per user to get their posts
      title
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you have 100 users, this fires 101 database queries (1 for users + 100 for posts). That's the N+1 problem.

Solution: DataLoader

const DataLoader = require('dataloader');

// Batch function — receives an array of IDs, returns results in same order
const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.posts.findByAuthorIds(userIds); // Single query
  // Group posts by author ID
  return userIds.map((id) => posts.filter((p) => p.authorId === id));
});

const resolvers = {
  User: {
    posts: (parent) => postLoader.load(parent.id), // Batched!
  },
};
Enter fullscreen mode Exit fullscreen mode

Now all 100 user-post lookups are batched into a single database query.

Pros and Cons

Pros Cons
No over-fetching or under-fetching More complex server implementation
Single endpoint, self-documenting N+1 problem requires DataLoader
Strongly typed schema HTTP caching is harder (everything is POST)
Great developer experience (GraphiQL, introspection) Security: query complexity attacks
Subscriptions for real-time data Steeper learning curve
Perfect for mobile (minimize data transfer) File uploads are awkward

When to Use GraphQL

  • Mobile apps where bandwidth matters
  • Complex UIs with varied data needs (dashboards)
  • When you have many related entities that clients query in different combinations
  • Internal APIs where teams need flexibility
  • When multiple frontend apps consume the same API differently

gRPC — High-Performance RPC

gRPC (Google Remote Procedure Call) is a high-performance, open-source RPC framework. It uses Protocol Buffers (protobuf) for serialization and HTTP/2 for transport.

How It Works

REST:                              gRPC:
  JSON over HTTP/1.1                 Protobuf (binary) over HTTP/2
  Text-based, human-readable         Binary, compact, fast
  Request-response only              Supports streaming
  ~100-500 bytes for simple obj      ~20-50 bytes for same obj
Enter fullscreen mode Exit fullscreen mode
┌──────────┐    protobuf/HTTP/2    ┌──────────┐
│  Client   │ ◄──────────────────▶ │  Server   │
│  (stub)   │    binary, fast      │  (impl)   │
└──────────┘                       └──────────┘
      │                                  │
      │  Generated from                  │  Generated from
      │  .proto file                     │  .proto file
      │                                  │
      └──────────┐    ┌─────────────────┘
                  │    │
                  ▼    ▼
            ┌──────────────┐
            │  .proto file  │  ← Single source of truth
            │  (schema)     │
            └──────────────┘
Enter fullscreen mode Exit fullscreen mode

Protocol Buffers (Protobuf)

The schema language for gRPC. Think of it as a strongly typed, language-agnostic data format.

// user.proto
syntax = "proto3";

package user;

service UserService {
  // Unary — single request, single response
  rpc GetUser (GetUserRequest) returns (User);
  rpc CreateUser (CreateUserRequest) returns (User);
  rpc DeleteUser (DeleteUserRequest) returns (Empty);

  // Server streaming — client sends one request, server streams responses
  rpc ListUsers (ListUsersRequest) returns (stream User);

  // Client streaming — client streams requests, server sends one response
  rpc UploadUserPhotos (stream Photo) returns (UploadResult);

  // Bidirectional streaming — both sides stream
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
  repeated string tags = 5;
}

message GetUserRequest {
  string id = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
  int32 age = 3;
}

message ListUsersRequest {
  int32 page = 1;
  int32 limit = 2;
}

message Empty {}
Enter fullscreen mode Exit fullscreen mode

Four Communication Patterns

1. Unary RPC (like REST):
   Client ──request──▶ Server
   Client ◀──response── Server

2. Server Streaming:
   Client ──request──▶ Server
   Client ◀──response 1── Server
   Client ◀──response 2── Server
   Client ◀──response N── Server

3. Client Streaming:
   Client ──request 1──▶ Server
   Client ──request 2──▶ Server
   Client ──request N──▶ Server
   Client ◀──response── Server

4. Bidirectional Streaming:
   Client ──request──▶ Server
   Client ◀──response── Server
   Client ──request──▶ Server
   Client ◀──response── Server
   (interleaved, both directions)
Enter fullscreen mode Exit fullscreen mode

Code Example — Node.js gRPC Server

const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');

// Load protobuf definition
const packageDef = protoLoader.loadSync('user.proto');
const proto = grpc.loadPackageDefinition(packageDef).user;

// In-memory store
const users = new Map();

// Implement service methods
const server = new grpc.Server();

server.addService(proto.UserService.service, {
  // Unary RPC
  GetUser: (call, callback) => {
    const user = users.get(call.request.id);
    if (!user) {
      return callback({
        code: grpc.status.NOT_FOUND,
        message: 'User not found',
      });
    }
    callback(null, user);
  },

  CreateUser: (call, callback) => {
    const { name, email, age } = call.request;
    const id = `user_${Date.now()}`;
    const user = { id, name, email, age };
    users.set(id, user);
    callback(null, user);
  },

  // Server streaming
  ListUsers: (call) => {
    for (const user of users.values()) {
      call.write(user); // Stream each user
    }
    call.end(); // Done streaming
  },

  // Bidirectional streaming
  Chat: (call) => {
    call.on('data', (message) => {
      console.log(`Received: ${message.text}`);
      // Echo back with modification
      call.write({ text: `Echo: ${message.text}`, sender: 'server' });
    });
    call.on('end', () => call.end());
  },
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
  console.log('gRPC server running on port 50051');
});
Enter fullscreen mode Exit fullscreen mode

gRPC Client

const client = new proto.UserService(
  'localhost:50051',
  grpc.credentials.createInsecure()
);

// Unary call
client.GetUser({ id: 'user_123' }, (err, user) => {
  if (err) console.error(err);
  else console.log('User:', user);
});

// Server streaming
const stream = client.ListUsers({ page: 1, limit: 10 });
stream.on('data', (user) => console.log('User:', user));
stream.on('end', () => console.log('Done'));
stream.on('error', (err) => console.error(err));
Enter fullscreen mode Exit fullscreen mode

Why HTTP/2 Matters

gRPC uses HTTP/2, which gives it significant advantages over HTTP/1.1:

HTTP/1.1 (REST):                 HTTP/2 (gRPC):
┌────────────┐                   ┌────────────┐
│ Request 1  │──▶                │ Req 1 ──▶  │
│ Response 1 │◀──                │ Req 2 ──▶  │  Multiplexed!
│ Request 2  │──▶  Sequential    │ Req 3 ──▶  │  All at once
│ Response 2 │◀──                │ ◀── Resp 2 │
│ Request 3  │──▶                │ ◀── Resp 1 │
│ Response 3 │◀──                │ ◀── Resp 3 │
└────────────┘                   └────────────┘
Enter fullscreen mode Exit fullscreen mode
  • Multiplexing — Multiple requests/responses over a single TCP connection
  • Header compression (HPACK) — Reduces overhead
  • Binary framing — More efficient than text-based HTTP/1.1
  • Server push — Server can proactively send data

Pros and Cons

Pros Cons
Extremely fast (binary, HTTP/2) Not browser-native (needs grpc-web proxy)
Strong typing via protobuf Not human-readable (binary format)
Code generation for many languages Steeper learning curve
Built-in streaming support Harder to debug (no curl)
Contract-first development Limited browser support
~10x smaller payload than JSON Less tooling than REST

When to Use gRPC

  • Microservice-to-microservice communication (internal)
  • High-throughput, low-latency systems
  • When you need streaming (real-time data, file transfers)
  • Polyglot environments (services in different languages)
  • Mobile apps talking to backends (smaller payloads)

tRPC — End-to-End Type Safety

tRPC is a framework for building type-safe APIs in TypeScript without schemas or code generation. Your API types are inferred directly from your server code.

How It Works

Traditional API:
  1. Define schema (OpenAPI, GraphQL SDL, protobuf)
  2. Generate types / client code
  3. Keep schema and implementation in sync (they drift)
  4. Runtime validation

tRPC:
  1. Write your server code in TypeScript
  2. Types are automatically shared with the client
  3. No schema, no codegen, no drift
  4. Compile-time type checking end-to-end
Enter fullscreen mode Exit fullscreen mode
┌──────────────────────────────────────────────────┐
│              TypeScript Monorepo                   │
│                                                    │
│  ┌──────────┐        types        ┌────────────┐  │
│  │  Server   │ ◄─────────────────▶│   Client    │  │
│  │  (tRPC    │   (inferred, no    │  (tRPC      │  │
│  │  router)  │    codegen)        │   client)   │  │
│  └──────────┘                     └────────────┘  │
│                                                    │
│  Change a server function → client sees it         │
│  immediately. Type errors caught at compile time.  │
└──────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Code Example — tRPC Server

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

const router = t.router;
const publicProcedure = t.procedure;

// Define your API router
export const appRouter = router({
  // Query — read data (like GET)
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      const user = await db.users.findById(input.id);
      if (!user) {
        throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
      }
      return user;
    }),

  // List users with pagination
  listUsers: publicProcedure
    .input(z.object({
      page: z.number().default(1),
      limit: z.number().max(100).default(10),
    }))
    .query(async ({ input }) => {
      return db.users.findMany({
        skip: (input.page - 1) * input.limit,
        take: input.limit,
      });
    }),

  // Mutation — write data (like POST/PUT/DELETE)
  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(1).max(100),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return db.users.create({
        data: { name: input.name, email: input.email },
      });
    }),

  updateUser: publicProcedure
    .input(z.object({
      id: z.string(),
      name: z.string().min(1).optional(),
      email: z.string().email().optional(),
    }))
    .mutation(async ({ input }) => {
      return db.users.update({
        where: { id: input.id },
        data: input,
      });
    }),

  deleteUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .mutation(async ({ input }) => {
      await db.users.delete({ where: { id: input.id } });
      return { success: true };
    }),
});

// Export the router type — this is the magic
export type AppRouter = typeof appRouter;
Enter fullscreen mode Exit fullscreen mode

tRPC Client (React)

// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc'; // Just the TYPE — no runtime import

export const trpc = createTRPCReact<AppRouter>();

// In your React component
function UserProfile({ userId }: { userId: string }) {
  // Fully typed! Autocomplete works. Errors caught at compile time.
  const { data: user, isLoading } = trpc.getUser.useQuery({ id: userId });
  //                                  ^ TypeScript knows the return type

  const updateUser = trpc.updateUser.useMutation();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <h1>{user?.name}</h1>          {/* TypeScript knows user shape */}
      <p>{user?.email}</p>
      <button
        onClick={() =>
          updateUser.mutate({
            id: userId,
            name: 'New Name',
            // email: 123,           ← TypeScript ERROR: number not assignable to string
          })
        }
      >
        Update
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The key insight: change a field name on the server, and every client that uses it gets a compile error immediately. No runtime surprises.

Pros and Cons

Pros Cons
End-to-end type safety (no drift) TypeScript only (both server and client)
Zero codegen, zero schemas Not suitable for public APIs
Incredible DX (autocomplete, errors) Requires monorepo or shared types
Built on React Query (caching, etc.) Relatively young ecosystem
Tiny bundle size Not for polyglot backends
Easy to learn if you know TypeScript Couples client and server tightly

When to Use tRPC

  • Full-stack TypeScript apps (Next.js, Remix, etc.)
  • Internal tools and admin dashboards
  • Startups / small teams where speed matters
  • When your entire stack is TypeScript and you want maximum DX
  • When you don't need to expose a public API

WebSockets, SSE & Webhooks

These aren't direct alternatives to REST/GraphQL/gRPC, but they solve related problems around real-time communication.

WebSockets

Full-duplex, persistent connection between client and server. Both sides can send messages at any time.

HTTP: Request → Response (one direction at a time)

WebSocket:
  Client ◄──────────────────▶ Server
         (persistent, bidirectional)
Enter fullscreen mode Exit fullscreen mode
// Server (ws library)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    // Broadcast to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(data.toString());
      }
    });
  });
});

// Client
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => console.log('Received:', event.data);
ws.send('Hello everyone!');
Enter fullscreen mode Exit fullscreen mode

Use for: Chat apps, multiplayer games, collaborative editing, live dashboards.

Server-Sent Events (SSE)

One-way streaming from server to client. Simpler than WebSockets when you only need server-to-client updates.

// Server (Express)
app.get('/events', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');

  // Send an event every second
  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ time: new Date() })}\n\n`);
  }, 1000);

  req.on('close', () => clearInterval(interval));
});

// Client
const source = new EventSource('/events');
source.onmessage = (event) => {
  console.log('Update:', JSON.parse(event.data));
};
Enter fullscreen mode Exit fullscreen mode

Use for: Live notifications, stock tickers, build logs, progress updates. Simpler than WebSockets, works over standard HTTP, auto-reconnects.

Webhooks

Not a protocol — a pattern. Server A sends an HTTP POST to Server B when something happens.

GitHub webhook example:

  1. You configure a webhook URL in GitHub repo settings
  2. Someone pushes to main
  3. GitHub sends POST to your URL:
     {
       "event": "push",
       "ref": "refs/heads/main",
       "commits": [...]
     }
  4. Your server processes it (deploy, notify, etc.)
Enter fullscreen mode Exit fullscreen mode

Use for: Event-driven integrations (payment notifications, CI/CD triggers, third-party integrations).

Quick Comparison

WebSockets SSE Webhooks
Direction Bidirectional Server → Client Server → Server
Connection Persistent Persistent On-demand
Protocol WS (upgrade from HTTP) HTTP HTTP POST
Browser Support Great Great N/A (server-side)
Use Case Chat, games, collab Live feeds, notifications Integrations, events

Head-to-Head Comparison

Here's the comparison you've been waiting for:

Feature REST GraphQL gRPC tRPC
Protocol HTTP/1.1+ HTTP/1.1+ HTTP/2 HTTP/1.1+
Data Format JSON JSON Protobuf (binary) JSON
Type Safety Manual (OpenAPI) Schema-based Protobuf schema Full (TypeScript)
Code Generation Optional Optional Required None needed
Performance Good Good Excellent Good
Payload Size Medium Small (exact fields) Smallest (binary) Medium
Browser Support Native Native Needs proxy Native
Streaming SSE/WebSocket Subscriptions Native (4 types) Via subscriptions
Caching Easy (HTTP) Hard (single POST) Hard Via React Query
Learning Curve Low Medium High Low (if you know TS)
Tooling Excellent Great Good Good
Public API Excellent Good Poor Not suitable
Microservices Good Medium Excellent Not suitable
File Uploads Easy Awkward Streaming Via HTTP
Real-Time Polling/SSE Subscriptions Streaming Subscriptions
Language Support Any Any Many (codegen) TypeScript only

Performance Benchmark (Approximate)

Serialization/Deserialization speed (lower is better):

  Protobuf (gRPC)  ████                        ~0.5ms
  JSON (REST)       ████████████                ~1.5ms
  GraphQL response  ████████████                ~1.5ms
  (parsing + validation adds overhead)

Payload size for same data (lower is better):

  Protobuf          ████                        ~50 bytes
  JSON (GraphQL)    ████████████                ~150 bytes
  JSON (REST)       ████████████████████        ~250 bytes
  (REST often over-fetches)

Requests per second (single server, higher is better):

  gRPC              ████████████████████████    ~50,000 rps
  tRPC              ████████████████            ~35,000 rps
  REST              ██████████████              ~30,000 rps
  GraphQL           ████████████                ~25,000 rps
  (GraphQL has resolver overhead)
Enter fullscreen mode Exit fullscreen mode

These are rough estimates — actual numbers depend heavily on your specific workload, serialization complexity, and hardware.


Real-World Usage

Companies don't just pick one protocol — they use the right tool for each job.

Company Protocols Used Why
Google gRPC (internal), REST (public) gRPC for high-perf microservices, REST for public APIs
Netflix GraphQL + gRPC GraphQL for UI backends, gRPC between microservices
Shopify GraphQL (public API) Flexible API for thousands of third-party integrations
Slack REST + WebSockets REST for CRUD, WebSockets for real-time messaging
Uber gRPC (internal) Low-latency communication between services
Meta GraphQL + Thrift GraphQL for frontend, Thrift (similar to gRPC) internal
Stripe REST (public) Industry-standard API, maximum compatibility
Discord REST + WebSockets REST for data, WebSockets for real-time voice/chat
Vercel / T3 Stack tRPC Full-stack TypeScript, maximum DX
Cloudflare REST + gRPC REST for public API, gRPC internal

Key Insight

Notice a pattern? Most companies use REST for public APIs and gRPC for internal microservice communication. GraphQL sits in between as a flexible BFF (Backend for Frontend). tRPC is for full-stack TypeScript teams.


Decision Framework

Flowchart

                      What are you building?
                    /          |           \
                   /           |            \
            Public API    Internal       Full-stack
            (3rd parties) microservices  TypeScript app
                |              |              |
                ▼              ▼              ▼
            ┌───────┐    ┌────────┐    ┌──────────┐
            │  REST  │    │  gRPC  │    │  tRPC    │
            │ (safe  │    │ (fast, │    │ (max DX, │
            │  bet)  │    │ typed) │    │ no schema│
            └───┬───┘    └────────┘    └──────────┘
                │
          Do clients need
          flexible queries?
            /         \
          Yes          No
           |            |
           ▼            ▼
      ┌─────────┐   Stick with
      │ GraphQL │   REST
      │ (BFF)   │
      └─────────┘

  Need real-time?
    │
    ├─ Bidirectional → WebSockets or gRPC streaming
    ├─ Server → Client only → SSE
    └─ Server → Server events → Webhooks
Enter fullscreen mode Exit fullscreen mode

Quick Decision Matrix

Scenario Recommended
Public API for external developers REST
Mobile app with varied data needs GraphQL
Internal microservices (polyglot) gRPC
Full-stack TypeScript (Next.js) tRPC
Real-time chat / gaming WebSockets
Live notifications / feeds SSE or GraphQL subscriptions
Payment / integration callbacks Webhooks
High-throughput data pipeline gRPC streaming
Simple CRUD with basic clients REST
E-commerce storefront with many integrations GraphQL

Mixing Protocols

In the real world, you rarely use just one protocol. Here are two common patterns.

API Gateway Pattern

A single entry point that routes to different backend services, each potentially using different protocols.

┌──────────────────────────────────────────────────┐
│                    Clients                        │
│   Browser    Mobile App    Partner API            │
└──────┬───────────┬─────────────┬─────────────────┘
       │           │             │
       ▼           ▼             ▼
┌──────────────────────────────────────────────────┐
│              API Gateway                          │
│  (Kong, AWS API Gateway, Nginx, etc.)            │
│                                                   │
│  Routes:                                          │
│    /api/*      → REST services                   │
│    /graphql    → GraphQL server                  │
│    /ws         → WebSocket server                │
│                                                   │
│  Also handles: Auth, rate limiting, logging       │
└──────┬───────────┬─────────────┬─────────────────┘
       │           │             │
       ▼           ▼             ▼
  ┌─────────┐ ┌─────────┐ ┌─────────┐
  │  REST   │ │ GraphQL │ │  gRPC   │
  │ Service │ │ Server  │ │Services │
  └─────────┘ └────┬────┘ └─────────┘
                    │
              ┌─────┴──────┐
              │ Talks gRPC │
              │ to internal│
              │ services   │
              └────────────┘
Enter fullscreen mode Exit fullscreen mode

BFF Pattern (Backend for Frontend)

Each client type gets its own backend that aggregates and shapes data from internal services.

┌──────────┐  ┌──────────┐  ┌──────────┐
│  Web App  │  │ Mobile   │  │ Partner  │
│ (React)   │  │ App      │  │ API      │
└─────┬────┘  └────┬─────┘  └────┬─────┘
      │            │              │
      ▼            ▼              ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Web BFF  │ │Mobile BFF│ │ Public   │
│ (tRPC or │ │(GraphQL) │ │ REST API │
│  GraphQL)│ │          │ │          │
└─────┬────┘ └────┬─────┘ └────┬─────┘
      │           │             │
      └───────────┼─────────────┘
                  │
                  ▼
         ┌────────────────┐
         │ Internal gRPC  │
         │ Microservices  │
         └────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • Web BFF uses tRPC or GraphQL — rich, typed queries for complex UIs
  • Mobile BFF uses GraphQL — flexible queries, minimal data transfer
  • Public API uses REST — universal compatibility for partners

This is how companies like Netflix, Airbnb, and Spotify architect their systems.


Common Mistakes

1. Using GraphQL for Everything

GraphQL is great, but it adds complexity. Don't use it for a simple CRUD app with one client. REST is fine.

2. Choosing gRPC for Browser-Facing APIs

gRPC doesn't work natively in browsers. You need grpc-web, which adds a proxy layer. If your primary client is a browser, use REST or GraphQL.

3. Not Versioning Your REST API

Bad:   /api/users          ← Breaking changes affect everyone
Good:  /api/v1/users       ← Clients can migrate at their own pace
Also:  Accept: application/vnd.myapp.v2+json  ← Header-based versioning
Enter fullscreen mode Exit fullscreen mode

4. Over-fetching in REST When GraphQL Would Help

If your mobile app is making 5 REST calls per screen and discarding 80% of the data, that's a sign you need GraphQL or a BFF.

5. Not Handling the N+1 Problem in GraphQL

If you don't use DataLoader (or an equivalent), your GraphQL API will be slower than REST. Always batch nested queries.

6. Using tRPC for Public APIs

tRPC is designed for TypeScript-to-TypeScript communication. If you need Java, Python, or Go clients, use REST or gRPC.

7. Ignoring Error Handling

// Bad REST error response
res.status(500).json({ error: "Something went wrong" });

// Good REST error response
res.status(422).json({
  error: {
    code: "VALIDATION_ERROR",
    message: "Email is required",
    details: [
      { field: "email", message: "Must be a valid email address" }
    ]
  }
});
Enter fullscreen mode Exit fullscreen mode

8. Not Considering Team Expertise

The "best" protocol means nothing if your team doesn't know how to use it. A well-built REST API beats a poorly-implemented GraphQL one every time.


TL;DR

┌──────────────────────────────────────────────────────────────┐
│                    API PROTOCOL CHEAT SHEET                    │
├──────────────────────────────────────────────────────────────┤
│                                                               │
│  REST .......... Universal, simple, cacheable                 │
│                  Best for: Public APIs, simple CRUD           │
│                                                               │
│  GraphQL ....... Flexible queries, no over-fetching           │
│                  Best for: Complex UIs, mobile apps           │
│                                                               │
│  gRPC .......... Fast, typed, streaming                       │
│                  Best for: Microservices, high-throughput      │
│                                                               │
│  tRPC .......... E2E type safety, zero schema                 │
│                  Best for: Full-stack TypeScript apps          │
│                                                               │
│  GOLDEN RULES                                                 │
│  ────────────                                                 │
│  1. Public API? → REST (safe, universal)                      │
│  2. Internal services? → gRPC (fast, typed)                   │
│  3. Complex UI? → GraphQL (flexible queries)                  │
│  4. Full-stack TS? → tRPC (maximum DX)                        │
│  5. Real-time? → WebSockets or SSE                            │
│  6. You CAN mix them → API Gateway or BFF pattern             │
│                                                               │
└──────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Let's Connect!

If this comparison helped you make better architecture decisions, I'd love to connect! I regularly share deep dives on system design, backend architecture, and web development.

Connect with me on LinkedIn — let's grow together.

Share this with a developer who's still debating REST vs GraphQL!

Top comments (0)