Skip to content

Web Services Architecture

A deep dive into every major API paradigm. It covers how each protocol works internally, when to use each protocol, and how they compare.


Protocol Comparison Overview

graph TD
    A[Client needs data] --> B{Use case?}
    B -->|Public API, CRUD, browser-native| C[REST]
    B -->|Flexible queries, complex frontends| D[GraphQL]
    B -->|Internal service-to-service, streaming| E[gRPC]
    B -->|Real-time bidirectional| F[WebSocket]
    B -->|Server pushes only, notifications| G[SSE]
    B -->|TypeScript full-stack only| H[tRPC]
    B -->|Event notification to external systems| I[Webhooks]
    B -->|Legacy enterprise integration| J[SOAP]
Protocol Transport Format Direction Browser Native Best For
REST HTTP/1.1, HTTP/2 JSON (typically) Req/Res ✅ Public APIs, CRUD, resource modeling
GraphQL HTTP/1.1, HTTP/2 JSON Req/Res + Subscription ✅ Complex frontends, data aggregation
gRPC HTTP/2 only Protocol Buffers (binary) Req/Res + Streaming ⚠️ (needs proxy) Internal microservices, high-throughput
SOAP HTTP, SMTP, TCP XML Req/Res ✅ Legacy enterprise, financial services
WebSocket WS (TCP upgrade) Any (text/binary) Full-duplex ✅ Real-time chat, gaming, collaboration
SSE HTTP/1.1, HTTP/2 Text (UTF-8) Server → Client only ✅ Feeds, notifications, AI streaming
Webhooks HTTP POST JSON (typically) Server → Client push ✅ Event-driven integrations, automation
tRPC HTTP/WebSocket JSON Req/Res + Subscription ✅ (Node/TS only) TypeScript full-stack monorepos

REST (Representational State Transfer)

Roy Fielding defined REST in his 2000 doctoral dissertation as an architectural style, not a protocol. REST is built on six constraints. Together they produce a scalable, stateless, and cacheable web service.

The Six Architectural Constraints

1. Client–Server Separation

The client and server evolve independently. The server manages data storage and business logic. The client manages the user interface and user state. Neither side depends on the implementation details of the other — only on the shared API contract.

This decoupling lets frontend teams swap frameworks (React → Vue) and lets mobile clients evolve. No backend change is required, and the reverse holds too.

2. Stateless

Every request from client to server must contain all information necessary to understand and process the request. The server stores no session state between requests.

❌ Stateful (server stores session):
POST /login       → server creates session, returns cookie
GET /dashboard    → server reads session to identify user

✅ Stateless (client carries state):
GET /dashboard
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

Consequences: - Scalability: any server instance can handle any request — no sticky sessions - Reliability: no session state to lose if a server crashes - Overhead: every request must carry auth credentials and context (larger payloads)

3. Cacheable

Responses must declare whether they are cacheable or not. When responses are cacheable, clients and intermediaries (CDNs, proxies) can serve them without contacting the server.

Key HTTP cache headers: | Header | Purpose | Example | |---|---|---| | Cache-Control | Directives for caching behavior | Cache-Control: max-age=3600, public | | ETag | Fingerprint of resource version | ETag: "d8e8fca2dc0f896fd7cb4cb0031ba249" | | Last-Modified | When resource last changed | Last-Modified: Tue, 22 Apr 2026 12:00:00 GMT | | Vary | Which headers affect the cache key | Vary: Accept-Encoding, Authorization |

Conditional requests let clients validate their cache:

GET /users/42
If-None-Match: "d8e8fca2dc0f896fd7cb4cb0031ba249"

→ 304 Not Modified (body omitted — client uses cached copy)
→ 200 OK + new ETag + new body (cache miss — resource changed)

4. Uniform Interface

This is the single most important constraint. It defines four sub-principles:

4a. Resource Identification in Requests — every resource has a stable URI:

/users                        → collection of users
/users/42                     → specific user
/users/42/orders              → orders belonging to user 42
/users/42/orders/7/items      → items in that order

4b. Manipulation via Representations — clients hold representations (JSON, XML, HTML), not live objects. The client modifies the representation and sends it back.

4c. Self-Descriptive Messages — each request/response carries enough metadata to describe how to process it: Content-Type, method, status code, cache directives.

4d. HATEOAS — see section below.

5. Layered System

Clients cannot tell whether they are connected directly to the server or to an intermediary (load balancer, CDN, API gateway, caching proxy). Each layer only knows about the adjacent layer.

You can put these layers in transparently: - CDNs for caching at the edge - API gateways for auth, rate limiting, routing - Load balancers for distributing traffic - Service meshes for observability and mTLS

6. Code on Demand (optional)

This is the only optional constraint. Servers can temporarily extend client function by transferring executable code (for example, JavaScript). It is rarely relevant in modern API design.

HTTP Methods and Idempotency

Method Semantics Idempotent Safe Common Use
GET Retrieve resource(s) ✅ ✅ Read data
HEAD GET without body (check existence/metadata) ✅ ✅ Cache validation
POST Create a new resource. Non-idempotent actions ❌ ❌ Create, submit form, trigger action
PUT Replace entire resource (upsert) ✅ ❌ Full update
PATCH Partial update ❌* ❌ Partial update
DELETE Remove resource ✅ ❌ Delete
OPTIONS Discover allowed methods (used for CORS preflight) ✅ ✅ CORS

* PATCH can be designed idempotently but is not required to be.

Safe = no side effects (read-only). Idempotent = making the same request N times has the same effect as making it once.

HTTP Status Codes

Range Category Key Codes
2xx Success 200 OK, 201 Created, 202 Accepted, 204 No Content
3xx Redirection 301 Moved Permanently, 304 Not Modified
4xx Client Error 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests
5xx Server Error 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout

Common Status Code Mistakes

  • Never return 200 OK with an error in the body — clients must parse every body to detect errors
  • Use 401 for unauthenticated, 403 for authenticated but unauthorized
  • If the request is syntactically valid but semantically wrong (for example, an invalid field value), use 422 (not 400)
  • 404 means "resource not found", not "I do not know". Do not use it as a catch-all

PATCH Semantics: JSON Patch vs JSON Merge Patch

PATCH is the most nuanced HTTP method. The two dominant formats behave very differently:

JSON Merge Patch (RFC 7396) — simple and intuitive. Send only the fields you want to change:

PATCH /users/42 HTTP/1.1
Content-Type: application/merge-patch+json

{"email": "[email protected]", "phone": null}

The server merges the patch with the existing resource. email is updated, phone is removed (explicit null), and all other fields stay unchanged.

Limitation: you cannot set a field to null and leave it present — null always means "remove." This makes JSON Merge Patch unusable for APIs where null is a meaningful value.

JSON Patch (RFC 6902) — explicit operations array, more powerful but more complex:

PATCH /users/42 HTTP/1.1
Content-Type: application/json-patch+json

[
  { "op": "replace", "path": "/email", "value": "[email protected]" },
  { "op": "remove", "path": "/phone" },
  { "op": "add", "path": "/addresses/1", "value": {"city": "Berlin"} },
  { "op": "test", "path": "/version", "value": 3 }
]

Operations: add, remove, replace, move, copy, test. The test operation enables optimistic concurrency — the patch fails atomically if the tested value does not match.

Dimension JSON Merge Patch JSON Patch
RFC 7396 6902
Content-Type application/merge-patch+json application/json-patch+json
Format Partial JSON object Array of operations
Set field to null ❌ (null = remove) ✅ {"op": "replace", "path": "/x", "value": null}
Array operations Replace entire array only Add/remove individual elements
Atomicity No built-in check test operation for optimistic locking
Complexity Low — just send partial object Higher — must construct operation array
Adoption More common (GitHub, Stripe) Less common. Used when precision is needed

Practical Recommendation

If you need array element manipulation, optimistic concurrency via test, or a way to distinguish "set to null" from "remove", use JSON Patch. Most APIs use JSON Merge Patch for simplicity.

HATEOAS

Hypermedia as the Engine of Application State — the highest constraint of REST. Responses include hyperlinks that describe the actions available next. Clients need no prior knowledge of URL structure. They navigate by following links.

{
  "id": 42,
  "name": "Alice",
  "email": "[email protected]",
  "_links": {
    "self":   { "href": "/users/42", "method": "GET" },
    "orders": { "href": "/users/42/orders", "method": "GET" },
    "update": { "href": "/users/42", "method": "PUT" },
    "delete": { "href": "/users/42", "method": "DELETE" }
  }
}

Benefits: the API is self-documenting, the server can change URL structure without breaking clients, and workflow steps are discoverable.

In practice: very few production APIs implement full HATEOAS. Most APIs get to Level 2 of the Richardson Maturity Model (proper HTTP verbs) and stop there.

Richardson Maturity Model

A framework for measuring how RESTful an API is:

Level Name What It Adds Example
0 Swamp of POX Single endpoint, single method POST /api with XML body specifying action
1 Resources Multiple URIs, but still single HTTP verb POST /users, POST /users/42
2 HTTP Verbs Uses GET/POST/PUT/DELETE meaningfully GET /users/42, DELETE /users/42
3 Hypermedia Responses contain links for navigation (HATEOAS) JSON with _links section

Roy Fielding stated that Level 3 is the pre-condition of REST. Most production APIs sit at Level 2. That level is fine for practical purposes, even if it is technically not "truly RESTful."


GraphQL

Facebook created GraphQL in 2012 and open-sourced it in 2015. It is a query language for your API and a runtime for executing those queries. Clients ask for exactly the data they need and nothing more.

Core Concept: Single Endpoint

Unlike the resource-per-endpoint model of REST, GraphQL exposes a single endpoint (typically POST /graphql) that accepts queries describing the exact shape of data needed.

# REST requires 3 round trips:
# GET /users/42
# GET /users/42/posts
# GET /posts/7/comments

# GraphQL fetches all in one request:
query {
  user(id: 42) {
    name
    email
    posts(limit: 5) {
      title
      publishedAt
      comments(limit: 3) {
        body
        author { name }
      }
    }
  }
}

Type System and Schema

Everything in GraphQL is strongly typed. The schema is the single source of truth — it describes every piece of data the API can return and every operation clients can do.

Scalar Types

Built-in primitives: Int, Float, String, Boolean, ID. You can define custom scalars (for example, DateTime, URL, JSON).

Object Types

type User {
  id: ID!                  # ! = non-nullable
  name: String!
  email: String!
  createdAt: DateTime!
  posts: [Post!]!          # non-null list of non-null Posts
}

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

Special Root Types

type Query {
  user(id: ID!): User
  users(limit: Int = 20, offset: Int = 0): [User!]!
}

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
}

type Subscription {
  userCreated: User!
  messageReceived(roomId: ID!): Message!
}

Other Type Categories

Type Purpose Example
Input Arguments to mutations input CreateUserInput { name: String!, email: String! }
Enum Fixed set of values enum Status { ACTIVE INACTIVE SUSPENDED }
Interface Shared fields across types interface Node { id: ID! }
Union Type can be one of many union SearchResult = User \| Post \| Comment
Fragment Reusable field selection fragment UserFields on User { id name email }

Queries, Mutations, Subscriptions

Query — read data. Resolvers can be called in parallel:

query GetDashboard {
  currentUser {
    name
    notifications(unread: true) { id title }
  }
  trending { title views }
}

Mutation — write data. Resolvers execute sequentially:

mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    author { name }
  }
}

Subscription — real-time data via WebSocket (typically). Server pushes updates when events occur:

subscription OnMessageReceived($roomId: ID!) {
  messageReceived(roomId: $roomId) {
    id body sender { name } sentAt
  }
}

Resolvers

Resolvers are functions that produce data for each field in the schema. GraphQL execution is a depth-first traversal of the query tree — each field resolver receives:

  1. parent — resolved value of the parent field
  2. args — arguments passed to this field
  3. context — shared object (DB connection, auth user, DataLoaders)
  4. info — query metadata (field name, selection set, schema)
const resolvers = {
  Query: {
    user: async (_, { id }, { db }) => db.users.findById(id),
    users: async (_, { limit, offset }, { db }) =>
      db.users.findAll({ limit, offset }),
  },
  User: {
    // Parent resolver returned a user object; now resolve its posts field
    posts: async (user, { limit }, { db }) =>
      db.posts.findByUserId(user.id, limit),
  },
  Mutation: {
    createUser: async (_, { input }, { db }) => db.users.create(input),
  },
};

The N+1 Problem

This is the most common GraphQL performance trap. Without optimization, resolving a list of N users and their posts triggers 1 + N queries:

Query: users(limit: 20)    → SELECT * FROM users LIMIT 20          (1 query)
  User[0].posts            → SELECT * FROM posts WHERE user_id = 1  (1 query)
  User[1].posts            → SELECT * FROM posts WHERE user_id = 2  (1 query)
  ...
  User[19].posts           → SELECT * FROM posts WHERE user_id = 20 (1 query)
                                                                TOTAL: 21 queries

The impact compounds with nesting. Posts that fetch authors that fetch their posts can generate hundreds of queries for a single GraphQL request.

DataLoader — The Solution

Facebook's DataLoader batches and caches loads within a single request. It uses the event loop tick of Node.js:

import DataLoader from 'dataloader';

// Created once per request (NOT per application startup)
const postsByUserLoader = new DataLoader(async (userIds: readonly string[]) => {
  // Single batch query: SELECT * FROM posts WHERE user_id IN (1, 2, ..., 20)
  const posts = await db.posts.findByUserIds(userIds);
  // Return results in same order as input keys
  return userIds.map(id => posts.filter(p => p.userId === id));
});

// In resolver — these 20 calls become ONE SQL query
const resolvers = {
  User: {
    posts: (user, _, { loaders }) =>
      loaders.postsByUser.load(user.id),  // batched automatically
  },
};

Result: 21 queries → 2 queries (one for users, one batch for all posts).

DataLoader Instance Per Request

Create a new DataLoader instance for each request. DataLoader caches results for the duration of a request — sharing across requests will serve stale data.

Directives

Directives annotate schema elements or control query execution:

type User {
  email: String! @deprecated(reason: "Use contactEmail instead")
  contactEmail: String!
  password: String! @auth(requires: ADMIN)  # custom directive
}

# Built-in execution directives:
query GetUser($showEmail: Boolean!) {
  user(id: 42) {
    name
    email @include(if: $showEmail)   # conditionally include field
    phone @skip(if: $skipPhone)      # conditionally skip field
  }
}

Introspection

GraphQL APIs are self-documenting — clients can query the schema itself:

{
  __schema {
    types { name kind }
  }
  __type(name: "User") {
    fields { name type { name kind } }
  }
}

Introspection powers tools like GraphiQL, Apollo Studio, and GraphQL Playground. Disable introspection in production for security-sensitive APIs.

Query Complexity and Depth Limiting

Without limits, a malicious client can craft exponentially expensive queries:

# Denial-of-service via deeply nested query:
{ user { friends { friends { friends { friends { ... } } } } } }

Protect with: - Depth limiting: reject queries deeper than N levels (graphql-depth-limit) - Complexity analysis: assign costs to fields. Reject queries over a budget (graphql-validation-complexity) - Query whitelisting (persisted queries): only allow pre-approved queries in production

Federation

GraphQL Federation lets multiple teams own separate subgraphs that compose into a unified supergraph — one schema, one endpoint, distributed implementation.

┌─────────────────────────────────────────────┐
│           Apollo Router (Supergraph)         │
│     Single endpoint: POST /graphql           │
└────────┬──────────────┬──────────────────────┘
         │              │
   ┌─────▼─────┐  ┌─────▼──────┐  ┌──────────┐
   │  Users     │  │  Products  │  │  Orders  │
   │  Subgraph  │  │  Subgraph  │  │ Subgraph │
   │  (Team A)  │  │  (Team B)  │  │ (Team C) │
   └───────────┘  └────────────┘  └──────────┘

Key concepts: - Entities: types that can be extended across subgraphs, identified by a @key directive - __resolveReference: resolver that hydrates an entity from a key passed by the router - @external: field defined in another subgraph, referenced here - Each subgraph is independently deployable. The router composes them at query time

Federation vs Schema Stitching

Before Federation, schema stitching was the primary approach to composing multiple GraphQL services. They solve the same problem differently:

Dimension Schema Stitching Federation
Composition Gateway merges schemas at runtime Router composes via a supergraph schema
Type ownership Gateway defines cross-service types Each subgraph owns its types via @key
Coupling Gateway knows about the internal types of all subgraphs Subgraphs are self-contained. The router only knows entities
Deployment Change in one subgraph can require gateway redeploy Subgraphs deploy independently
Conflict resolution Manual: gateway resolves field name conflicts Automatic: @override, @provides, @shareable directives
Tooling GraphQL Tools (@graphql-tools/stitch) Apollo Router, Apollo Studio, Cosmo Router
Status Still works. No longer recommended for new projects Industry standard for multi-team GraphQL

Stitching still makes sense for: small teams, legacy services in gradual migration, and third-party GraphQL APIs that you do not control (federation requires subgraphs to add @key directives).

Error Handling

GraphQL errors behave fundamentally differently from REST:

Partial responses — in REST, an error means the entire response fails. In GraphQL, individual fields can fail while the rest of the response succeeds:

{
  "data": {
    "user": {
      "name": "Alice",
      "email": "[email protected]",
      "creditScore": null
    }
  },
  "errors": [
    {
      "message": "Unauthorized to access creditScore",
      "locations": [{ "line": 5, "column": 5 }],
      "path": ["user", "creditScore"],
      "extensions": {
        "code": "UNAUTHORIZED",
        "classification": "AUTHORIZATION"
      }
    }
  ]
}

The data field contains whatever succeeded. The errors field contains what failed. The client must handle both.

Error extensions — the extensions field is the standard way to add machine-readable error metadata:

// Apollo Server — throw typed error with extensions
import { GraphQLError } from 'graphql';

throw new GraphQLError('Order not found', {
  extensions: {
    code: 'NOT_FOUND',
    http: { status: 404 },
    orderId: input.id,
    traceId: ctx.traceId,
  },
});

Error masking — in production, mask internal errors to prevent leaking implementation details:

// Apollo Server 4 — format error for production
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Log full error internally
    logger.error(error);
    // Return sanitized error to client
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return { message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } };
    }
    return formattedError;
  },
});

Error classification patterns:

Code Meaning HTTP Equivalent
BAD_USER_INPUT Invalid query variables 400
UNAUTHENTICATED Missing or invalid auth 401
FORBIDDEN Authenticated but not authorized 403
NOT_FOUND Resource does not exist 404
GRAPHQL_VALIDATION_FAILED Query does not match schema 400
PERSISTED_QUERY_NOT_FOUND Unknown query hash (APQ miss) 400
INTERNAL_SERVER_ERROR Unhandled server error 500

Caching

GraphQL caching is fundamentally harder than REST caching. Requests use POST with dynamic query bodies, so HTTP caches cannot distinguish between different queries to the same /graphql endpoint.

HTTP-level caching (limited): - GET requests for queries: GET /graphql?query={user(id:42){name}}&variables={} — cacheable by CDN, but URL length limits apply - Automatic Persisted Queries (APQ) solve this: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc..."}}&variables={"id":"42"} — short, cacheable, CDN-friendly

Client-side normalized caching (Apollo Client):

Apollo Client maintains an in-memory normalized cache keyed by __typename:id:

Cache store:
  User:42  → { __typename: "User", id: "42", name: "Alice", email: "[email protected]" }
  Post:7   → { __typename: "Post", id: "7", title: "Hello", author: { __ref: "User:42" } }
  Post:8   → { __typename: "Post", id: "8", title: "World", author: { __ref: "User:42" } }

When a mutation updates User:42, every query displaying that user re-renders automatically — no manual cache invalidation. This is the primary DX advantage of GraphQL over REST for complex frontends.

Cache policies:

Policy Behavior Use Case
cache-first Read from cache. Network only on miss Default. Best for mostly-static data
network-only Always fetch. Update cache Dashboards, real-time displays
cache-and-network Return cache immediately, then update with network Instant UI + fresh data
no-cache Fetch without reading or updating cache One-off queries, sensitive data

Server-side caching: - Response-level: cache full GraphQL responses keyed by query hash + variables (Redis) - Resolver-level: cache individual resolver results (DataLoader already provides per-request caching. Add Redis for cross-request caching) - @cacheControl directive (Apollo): per-field cache hints

type Product @cacheControl(maxAge: 3600) {
  id: ID!
  name: String!
  price: Float! @cacheControl(maxAge: 60)    # price changes more often
  reviews: [Review!]! @cacheControl(maxAge: 300)
}

gRPC

gRPC (Google Remote Procedure Call) is a high-performance, open-source RPC framework. It uses Protocol Buffers as its interface definition language and serialization format. It uses HTTP/2 as the transport protocol. It is a CNCF project (since 2016).

Protocol Buffers (Protobuf)

Protobuf is a language-neutral, platform-neutral binary serialization format. Compared to JSON:

Property JSON Protobuf
Format Text (UTF-8) Binary
Size ~1x baseline 3–10x smaller
Parse speed ~1x baseline 5–10x faster
Schema Optional (JSON Schema) Required (.proto file)
Human-readable ✅ ❌ (need tools)
Schema evolution Manual / fragile Built-in field numbering

A .proto service definition:

syntax = "proto3";
package com.example.users;

// Message types
message User {
  string id        = 1;
  string name      = 2;
  string email     = 3;
  int64  created_at = 4;
}

message GetUserRequest  { string user_id = 1; }
message CreateUserRequest {
  string name  = 1;
  string email = 2;
}
message UserList { repeated User users = 1; }

// Service definition
service UserService {
  // Unary
  rpc GetUser(GetUserRequest) returns (User);

  // Server streaming
  rpc ListUsers(ListUsersRequest) returns (stream User);

  // Client streaming
  rpc CreateUsersBulk(stream CreateUserRequest) returns (UserList);

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

The protoc compiler generates strongly-typed client stubs and server interfaces in Go, Java, Python, C++, Node.js, Rust, Kotlin, Swift, and more.

HTTP/2 Features Exploited by gRPC

HTTP/2 Feature What It Enables
Multiplexing Multiple RPC calls on one TCP connection. No head-of-line blocking between requests
Binary framing Headers and data sent as binary frames — more efficient than HTTP/1.1 text headers
Header compression (HPACK) Repeated headers (auth token, content-type) sent as index references after first use. 85–90% header reduction
Full-duplex streams Client and server can send frames simultaneously on the same stream
Flow control Prevents fast producers from overwhelming slow consumers per-stream
Server push Server can pre-emptively send resources (rarely used in gRPC)

The Four Streaming Types

Unary RPC

rpc GetUser(GetUserRequest) returns (User);
This is the classic request-response pattern. The client sends one message, and the server sends one message. It is equivalent to a REST GET.

Server Streaming RPC

rpc WatchLogs(WatchRequest) returns (stream LogEntry);
The client sends one request. The server streams multiple responses. Use cases: live logs, large dataset export, real-time feeds.

Client Streaming RPC

rpc UploadMetrics(stream MetricPoint) returns (UploadSummary);
The client streams multiple messages. The server collects them and returns one response. Use cases: telemetry ingestion, file uploads chunked by the client, batch writes.

Bidirectional Streaming RPC

rpc BidirectionalChat(stream ChatMessage) returns (stream ChatMessage);
Both sides can send and receive messages in any order over a long-lived connection. The two streams operate independently. Use cases: chat, collaborative editing, real-time games, audio/video signaling.

Deadlines and Cancellation

Every gRPC call must set a deadline — the absolute time by which the client requires a response. The server checks the deadline before it starts expensive work.

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := client.GetUser(ctx, &pb.GetUserRequest{UserId: "42"})

Deadlines propagate through the entire call chain. If service A calls service B, and service B calls service C, all three respect the same deadline window. One slow downstream call then cannot cause timeouts at every layer.

Interceptors

Interceptors wrap gRPC method invocations — the gRPC equivalent of middleware:

// Unary server interceptor for logging
func loggingInterceptor(ctx context.Context, req interface{},
  info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (interface{}, error) {
  start := time.Now()
  resp, err := handler(ctx, req)
  log.Printf("Method: %s | Duration: %v | Error: %v",
    info.FullMethod, time.Since(start), err)
  return resp, err
}

// Register:
s := grpc.NewServer(
  grpc.UnaryInterceptor(loggingInterceptor),
  grpc.StreamInterceptor(streamLoggingInterceptor),
)

Common interceptors: authentication, tracing (OpenTelemetry), logging, metrics, panic recovery, rate limiting, deadline enforcement.

Load Balancing

Because gRPC multiplexes many RPCs over a single TCP connection, L4 (TCP) load balancing distributes connections, not RPCs. A single long-lived connection from service A to a single pod of service B bypasses all other pods.

Solutions: - L7 (application-layer) load balancing — proxy understands HTTP/2 streams and distributes individual RPCs: Envoy, nginx, gRPC-aware load balancers - Client-side load balancing — the gRPC client resolves all backend IPs (via DNS), maintains connections to each, and distributes RPCs itself - Headless services in Kubernetes — returns all pod IPs. Combine with gRPC client-side round-robin

gRPC-Web (Browser Bridge)

Browsers cannot make native HTTP/2 gRPC calls (no access to HTTP/2 frames or trailers). gRPC-Web bridges this gap with a protocol translation proxy.

flowchart LR
    B[Browser\ngRPC-Web Client] -->|HTTP/1.1 or HTTP/2\nContent-Type: application/grpc-web| P[Envoy Proxy\ngRPC-Web Filter]
    P -->|Native HTTP/2 gRPC| S[gRPC Server]

How it works: 1. Browser client uses @grpc/grpc-web or connect-web to make gRPC calls 2. Calls are encoded as application/grpc-web (base64 or binary) over standard HTTP 3. Envoy proxy (or Connect protocol server) translates to native gRPC 4. Server sees standard gRPC requests — no code changes needed

// Browser client using Connect (modern alternative to grpc-web)
import { createClient } from "@connectrpc/connect";
import { createGrpcWebTransport } from "@connectrpc/connect-web";
import { UserService } from "./gen/users_connect";

const transport = createGrpcWebTransport({
  baseUrl: "https://api.example.com",
});

const client = createClient(UserService, transport);
const user = await client.getUser({ userId: "42" });

gRPC-Web limitations: - Only unary and server-streaming RPCs (no client-streaming or bidirectional) - Requires a proxy (Envoy, Connect, nginx) unless using Connect protocol natively - Slightly higher latency due to protocol translation

Connect protocol (from Buf) is the modern alternative. It supports gRPC, gRPC-Web, and a new Connect protocol natively. All three run over a single HTTP endpoint. The Connect wire format works in browsers without a proxy.


SOAP / XML-RPC

SOAP (Simple Object Access Protocol) is the predecessor to REST. It is still deeply embedded in enterprise systems, financial services, healthcare (HL7), and government integrations.

Protocol Structure

A SOAP message is an XML document with a mandatory Envelope, optional Header, and mandatory Body:

<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope
  xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:usr="http://example.com/users">
  <soap:Header>
    <usr:AuthToken>abc123</usr:AuthToken>
  </soap:Header>
  <soap:Body>
    <usr:GetUser>
      <usr:UserId>42</usr:UserId>
    </usr:GetUser>
  </soap:Body>
</soap:Envelope>

WSDL (Web Services Description Language)

WSDL is the SOAP IDL — an XML document that describes the service completely. It covers operations, input/output message types, bindings (how operations map to protocols), and endpoints. It serves the same role as OpenAPI for REST or .proto files for gRPC.

<wsdl:definitions name="UserService" ...>
  <wsdl:types>
    <xs:schema>
      <xs:element name="GetUserRequest">
        <xs:complexType>
          <xs:sequence>
            <xs:element name="UserId" type="xs:string"/>
          </xs:sequence>
        </xs:complexType>
      </xs:element>
    </xs:schema>
  </wsdl:types>
  <wsdl:message name="GetUserInput">
    <wsdl:part name="parameters" element="tns:GetUserRequest"/>
  </wsdl:message>
  <wsdl:portType name="UserServicePortType">
    <wsdl:operation name="GetUser">
      <wsdl:input message="tns:GetUserInput"/>
      <wsdl:output message="tns:GetUserOutput"/>
    </wsdl:operation>
  </wsdl:portType>
</wsdl:definitions>

SOAP vs REST

Dimension SOAP REST
Payload XML (verbose) JSON (compact)
Contract WSDL (machine-readable) OpenAPI (optional)
Transport HTTP, SMTP, TCP HTTP only
State Stateful or stateless Stateless
Security WS-Security (powerful but complex) OAuth 2.0, JWT, mTLS
Error handling soap:Fault (standardized) HTTP status codes (convention-based)
Tooling Mature but heavy Light and universal
Still used for Banking, insurance, health (HL7), government Virtually everything new

XML-RPC predates SOAP. It is a simpler, less extensible ancestor that uses XML payloads over HTTP POST. It is effectively obsolete.


WebSocket

WebSocket provides a persistent, full-duplex TCP connection between client and server, established via an HTTP upgrade handshake. Once established, either side can send messages at any time with minimal overhead.

Handshake

# Client initiates upgrade:
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

# Server confirms upgrade:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the handshake, the connection is no longer HTTP. Data flows as frames — the minimal overhead unit:

Frame Type Description
Text frame UTF-8 text message
Binary frame Raw bytes (audio, video, protobuf)
Ping frame Heartbeat probe (server → client)
Pong frame Heartbeat response
Close frame Graceful connection termination

Connection Management

The primary operational challenge of WebSocket is connection state management:

  • Heartbeats (ping/pong): they detect dead connections that appear open at the TCP layer. Servers must send pings every 30–60 seconds. If no pong arrives, close the connection and clean up.
  • Reconnection: clients must implement exponential backoff for connection drops. Libraries like reconnecting-websocket handle this automatically.
  • Backpressure: if a slow client cannot consume fast enough, the send buffer of the server fills. Monitor ws.bufferedAmount on the client, or implement application-level flow control.
  • Horizontal scaling: WebSocket connections are stateful and sticky. A message sent by user A (connected to server 1) destined for user B (connected to server 2) must be routed between servers via a pub/sub layer (Redis Pub/Sub, Kafka).

When to Use WebSocket

  • Interactive real-time features: chat, collaborative document editing, multiplayer gaming
  • Financial data: live order books, tick-by-tick price feeds
  • IoT: bidirectional device control with low latency
  • When the client sends frequent data to the server (>1 msg/second)

Server-Sent Events (SSE)

SSE is a W3C standard for server-to-client streaming over plain HTTP. Unlike WebSocket, there is no protocol upgrade. An SSE stream is a long-lived HTTP response with Content-Type: text/event-stream.

Protocol

Server response:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

id: 1
event: message
data: {"type": "notification", "text": "Hello!"}

id: 2
event: update
data: {"user": "alice", "status": "online"}

: heartbeat comment (ignored by client)

SSE message fields: | Field | Purpose | |---|---| | data: | The message payload (can span multiple lines) | | event: | Custom event type (client listens via addEventListener) | | id: | Message ID. Sent as Last-Event-ID header on reconnect | | : (comment) | Ignored by client. Used for keepalive pings |

Auto-Reconnection

The key SSE feature: if the connection drops, the browser automatically reconnects and sends the Last-Event-ID header. The server can resume from where it stopped. No client code is required.

const source = new EventSource('/events');

source.addEventListener('message', e => console.log(e.data));
source.addEventListener('update', e => handleUpdate(JSON.parse(e.data)));
source.onerror = e => console.error('SSE error', e);
// Reconnection happens automatically — no manual retry logic needed

HTTP/2 SSE

Under HTTP/1.1, browsers limit each domain to 6 connections. With 7 tabs open, SSE connections compete with XHR/fetch requests. Under HTTP/2, all SSE streams multiplex over a single TCP connection — this limit disappears entirely.

AI Streaming

SSE is the standard for LLM token streaming. OpenAI, Anthropic, and nearly all LLM APIs stream completions via SSE. Data flows in one direction (server → client), SSE is simpler than WebSocket, and auto-reconnect handles transient failures.


Webhooks

Webhooks are HTTP POST callbacks — the server pushes events to client-registered URLs instead of the client polling for changes. "Do not call us, we will call you."

Flow

sequenceDiagram
    participant Client
    participant YourServer
    participant WebhookConsumer

    Client->>YourServer: Register webhook URL
    Note over YourServer: Event occurs (payment, commit, signup)
    YourServer->>WebhookConsumer: POST /webhook {"event": "payment.succeeded", ...}
    WebhookConsumer-->>YourServer: 200 OK (within 5s)
    Note over WebhookConsumer: Queue event for async processing

Production Webhook Pattern

Respond immediately, process asynchronously:

@app.post("/webhook")
async def webhook_handler(request: Request):
    payload = await request.json()
    # 1. Validate signature FIRST
    verify_signature(request.headers, payload)
    # 2. Return 200 immediately — before any processing
    background_tasks.add_task(process_event, payload)
    return {"status": "accepted"}

Never do slow work (DB queries, API calls) in the webhook handler. Return 200 within 5 seconds or the sender will retry.

Security: Signature Verification

Every webhook provider must sign payloads. Verify the signature before processing:

import hmac, hashlib

def verify_signature(headers: dict, body: bytes, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    received = headers.get("X-Signature-256", "").removeprefix("sha256=")
    return hmac.compare_digest(expected, received)

Reliability Patterns

Pattern Purpose
Idempotency key Deduplicate retried deliveries — store processed event IDs
Exponential backoff retries Sender retries on non-2xx: immediately, 5s, 30s, 5m, 30m, 2h
Dead letter queue After N retries, move to DLQ for manual inspection
Event replay Allow consumers to re-request past events by ID
CloudEvents format Standard envelope: id, source, type, time, data

tRPC

tRPC lets TypeScript full-stack teams build APIs where type safety flows automatically from server to client — no code generation, no schema files, no out-of-sync types.

How It Works

  1. Define procedures on the server (TypeScript functions)
  2. Export the router type
  3. Import and use that type on the client
  4. TypeScript infers input/output types automatically

The client never imports server implementation code — only the type. At runtime, tRPC serializes calls over HTTP (queries → GET/POST, mutations → POST, subscriptions → WebSocket).

Routers and Procedures

// server/routers/users.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';

export const userRouter = router({
  // Query — GET /trpc/users.getById
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input, ctx }) => {
      return ctx.db.user.findUnique({ where: { id: input.id } });
    }),

  // Mutation — POST /trpc/users.create
  create: protectedProcedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.user.create({ data: input });
    }),
});

// server/routers/_app.ts
export const appRouter = router({
  users: userRouter,
  posts: postRouter,
  comments: commentRouter,
});

export type AppRouter = typeof appRouter;  // ← this is all the client needs

Client Usage

// client/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/routers/_app';

export const trpc = createTRPCReact<AppRouter>();

// In a React component:
function UserProfile({ userId }: { userId: string }) {
  // Fully typed: input, output, error — all inferred from server code
  const { data, isLoading } = trpc.users.getById.useQuery({ id: userId });
  // data is typed as: User | null | undefined
  // Change server return type → TypeScript error here immediately
}

Context and Middleware

// Context: per-request shared state (auth user, DB, etc.)
export const createContext = async ({ req, res }: CreateNextContextOptions) => ({
  db: prisma,
  session: await getSession({ req }),
});

// Middleware: wraps procedures with reusable logic
const isAuthenticated = middleware(({ ctx, next }) => {
  if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});

// Protected procedure: any procedure using this is automatically auth-gated
const protectedProcedure = publicProcedure.use(isAuthenticated);

tRPC vs Alternatives

Dimension tRPC REST + OpenAPI GraphQL
Type safety ✅ Automatic, zero-gen ⚠️ Code generation required ⚠️ Code generation required
Language support TypeScript/JS only Universal Universal
Schema file ❌ None (types are the schema) OpenAPI YAML/JSON .graphql SDL
Learning curve Low (just TypeScript) Low High
Client flexibility ❌ Must use tRPC client ✅ Any HTTP client ✅ Any GraphQL client
Over/under-fetching Field selection not built-in Full response always ✅ Client specifies fields
Best for TypeScript monorepos (T3 stack, Next.js) Public APIs, polyglot Complex multi-client frontends

Choosing the Right API Paradigm

Is this a public API consumed by external developers or third parties?
→ REST (universal, familiar, broad tooling)

Is the frontend complex with multiple clients fetching different data shapes?
→ GraphQL (eliminates over/under-fetching, empowers frontend teams)

Is this internal service-to-service communication with high throughput?
→ gRPC (fastest, binary, streaming support, code-gen clients)

Does the data need to flow in real time in both directions?
→ WebSocket (full-duplex, persistent)

Does the server push updates to passive clients (feeds, notifications)?
→ SSE (simpler than WebSocket, HTTP-native, auto-reconnect)

Is the entire stack TypeScript and owned by one team?
→ tRPC (zero boilerplate, type-safe end-to-end)

Does an external system need to notify you when events occur?
→ Webhooks (event-driven push, polling eliminated)

Is this a legacy enterprise or regulated domain (banking, healthcare)?
→ SOAP (accept the complexity; interoperability with existing systems)

It Is Not Either-Or

Real systems commonly use multiple paradigms together: a public REST API for external consumers, gRPC internally between microservices, GraphQL for the customer-facing frontend, WebSocket for real-time features, and webhooks for third-party integrations.


HTTP/2 and HTTP/3 (QUIC)

All modern API protocols ride on top of HTTP — understanding transport evolution is essential.

HTTP/2 (2015, RFC 7540)

HTTP/2 is the minimum transport for gRPC and significantly improves REST/GraphQL performance.

Feature HTTP/1.1 HTTP/2
Framing Text-based Binary frames
Multiplexing ❌ (one request per TCP connection) ✅ Multiple streams per connection
Header compression ❌ ✅ HPACK
Server push ❌ ✅ (rarely used in practice)
Connection limit 6 per origin (browser) 1 TCP connection, unlimited streams
Head-of-line blocking ✅ At HTTP layer ❌ At HTTP layer — but YES at TCP layer

The TCP head-of-line blocking problem: if a single TCP packet is lost, ALL HTTP/2 streams on that connection stall until retransmission completes. This is the fundamental limitation HTTP/3 solves.

HTTP/3 (2022, RFC 9114)

HTTP/3 replaces TCP with QUIC (UDP-based transport with built-in TLS 1.3).

graph TB
    subgraph "HTTP/2 Stack"
        H2[HTTP/2] --> TLS2[TLS 1.2/1.3]
        TLS2 --> TCP[TCP]
        TCP --> IP1[IP]
    end
    subgraph "HTTP/3 Stack"
        H3[HTTP/3] --> QUIC[QUIC\nbuilt-in TLS 1.3]
        QUIC --> UDP[UDP]
        UDP --> IP2[IP]
    end

Key improvements:

Feature HTTP/2 (TCP) HTTP/3 (QUIC)
Head-of-line blocking ✅ TCP-level HOL ❌ Independent streams per QUIC stream
Connection setup TCP handshake + TLS handshake (2–3 RTT) 0-RTT or 1-RTT (TLS built into QUIC)
Connection migration ❌ New connection on network change ✅ Connection ID survives IP change
Packet loss recovery Entire connection stalls Only affected stream pauses
Congestion control Kernel TCP (cubic/bbr) User-space (pluggable, per-connection)

Connection migration matters most for mobile APIs. When a phone switches from WiFi to cellular, HTTP/2 drops the TCP connection and must re-handshake. The HTTP/3 connection ID persists across network changes, so the connection continues.

0-RTT resumption: returning clients can send data in the very first packet. They reuse a previously negotiated TLS session. This matters for latency-sensitive API calls on mobile networks.

0-RTT Replay Risk

0-RTT data can be replayed by a network attacker. Use 0-RTT only for idempotent operations (GET). Non-idempotent operations (POST) must wait for the full handshake.

gRPC and HTTP/3: gRPC currently requires HTTP/2. Experimental gRPC-over-QUIC implementations exist (for example, quic-go), but the gRPC specification does not officially support HTTP/3 yet. When it does, the independent QUIC streams will eliminate the head-of-line blocking that affects multiplexed gRPC connections today.

Content Negotiation

Content negotiation lets client and server agree on response format:

# Client requests JSON, can accept XML as fallback
GET /v2/orders/42 HTTP/1.1
Accept: application/json, application/xml;q=0.9, */*;q=0.1
Accept-Language: en-US, fr;q=0.5
Accept-Encoding: gzip, br

# Server responds with chosen representation
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: en-US
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding

The Vary header tells caches which request headers affect the response — critical for correct caching behavior.

API versioning via content negotiation:

Accept: application/vnd.example.v2+json

This is the most RESTful versioning approach (no URL pollution) but less discoverable than URI versioning.


Architectural Patterns

Backend for Frontend (BFF)

The BFF pattern creates a dedicated API gateway per client type — each frontend gets an API layer optimized for its specific data needs.

flowchart LR
    subgraph Clients
        M[Mobile App]
        W[Web App]
        TV[Smart TV]
    end
    subgraph BFF Layer
        MB[Mobile BFF\nGo / Node.js]
        WB[Web BFF\nNode.js]
        TB[TV BFF\nNode.js]
    end
    subgraph Backend Services
        US[User Service]
        PS[Product Service]
        OS[Order Service]
    end

    M --> MB
    W --> WB
    TV --> TB
    MB --> US & PS & OS
    WB --> US & PS & OS
    TB --> US & PS

Why BFF over a single gateway: - Mobile needs minimal payloads. Web needs rich data. One API cannot optimize for both - Each BFF aggregates multiple backend calls into one client-optimized response - Teams can deploy BFFs independently. Breaking a mobile BFF does not affect web - Authentication/session management can differ per client type

BFF vs GraphQL: GraphQL solves the over/under-fetching problem with client-specified queries. It can remove the need for separate BFFs. But BFF is still valuable in these cases: - Clients need significantly different business logic (not just different fields) - The team wants to contain complexity behind a simple REST API per client - Backend services expose gRPC — the BFF translates to REST/JSON for browser clients

GraphQL Persisted Queries

Persisted queries replace arbitrary client-sent GraphQL strings with pre-registered query IDs. This improves security, performance, and bandwidth.

# Without persisted queries — client sends full query string
POST /graphql
{"query": "query GetUser($id: ID!) { user(id: $id) { name email posts { title } } }", "variables": {"id": "42"}}

# With persisted queries — client sends only the hash
POST /graphql
{"extensions": {"persistedQuery": {"version": 1, "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4062f09b5bad56a6585"}}, "variables": {"id": "42"}}

Automatic persisted queries (APQ) flow (Apollo): 1. Client sends query hash only 2. If server does not recognize the hash → returns PersistedQueryNotFound 3. Client retries with full query string + hash 4. Server stores the mapping. Subsequent requests use hash only

Benefits: - Security: in locked-down mode, server rejects any query not in the allowlist — prevents arbitrary query attacks - Bandwidth: hash (64 chars) replaces potentially multi-KB query strings - CDN caching: hash-based GET requests are cacheable at edge (GET /graphql?extensions={...}&variables={...})

gRPC Health Checking Protocol

gRPC defines a standardized health checking protocol (grpc.health.v1) for load balancers and orchestrators:

syntax = "proto3";
package grpc.health.v1;

service Health {
  rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
  rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}

message HealthCheckRequest {
  string service = 1;  // empty string = overall server health
}

message HealthCheckResponse {
  enum ServingStatus {
    UNKNOWN = 0;
    SERVING = 1;
    NOT_SERVING = 2;
    SERVICE_UNKNOWN = 3;
  }
  ServingStatus status = 1;
}
# Check health with grpcurl
grpcurl -plaintext localhost:50051 grpc.health.v1.Health/Check

# Check specific service
grpcurl -plaintext -d '{"service": "orders.OrderService"}' \
  localhost:50051 grpc.health.v1.Health/Check

# Kubernetes gRPC health probe (k8s 1.24+)
# In pod spec:
# livenessProbe:
#   grpc:
#     port: 50051
#     service: ""

gRPC Server Reflection

Server reflection lets tools like grpcurl discover services without .proto files. It is the gRPC equivalent of the OpenAPI /swagger.json:

// Enable reflection in Go gRPC server
import "google.golang.org/grpc/reflection"

s := grpc.NewServer()
pb.RegisterOrderServiceServer(s, &server{})
reflection.Register(s)  // enables runtime schema discovery
# Discover all services (requires reflection)
grpcurl -plaintext localhost:50051 list

# Describe a specific service
grpcurl -plaintext localhost:50051 describe orders.OrderService

# Describe a message type
grpcurl -plaintext localhost:50051 describe orders.Order

Disable Reflection in Production

Like GraphQL introspection, gRPC reflection exposes your entire API surface. Disable it in production or restrict to authorized callers only.


API Performance Patterns

Request Compression

# Client sends compressed body
POST /v2/orders HTTP/1.1
Content-Encoding: gzip
Content-Type: application/json

# Client requests compressed response
GET /v2/orders HTTP/1.1
Accept-Encoding: gzip, br

Brotli (br) achieves 15–25% better compression than gzip for JSON/text payloads, but requires more CPU for compression. Most CDNs pre-compress static assets with Brotli. For dynamic API responses, gzip is usually the better trade-off (faster compression, slightly larger output).

Connection Pooling

HTTP/1.1 clients must maintain a connection pool. A pool prevents the overhead of TCP+TLS handshakes per request:

Setting Typical Value Notes
Pool size (per host) 20–100 Match to expected concurrency
Idle timeout 30–90s Close idle connections to free resources
Max lifetime 5–10 min Prevent sticky connections to a single backend
Health check interval 10s Detect dead connections proactively

HTTP/2 clients typically use a single connection per host with unlimited streams — connection pooling is less critical but still relevant for fault tolerance (maintain 2–3 connections).

ETag-Based Conditional Requests

First request:
  GET /v2/orders/42 → 200 OK, ETag: "abc123"

Subsequent request:
  GET /v2/orders/42
  If-None-Match: "abc123"
  → 304 Not Modified (no body, use cached copy)
  → 200 OK + new ETag (resource changed, here is new version)

ETags reduce bandwidth and server load. For mutable resources, use strong ETags (exact byte-for-byte match). For semantic equivalence, use weak ETags (W/"abc123").

Async Request Collapsing (Request Deduplication)

When multiple clients request the same resource simultaneously, collapse them into a single backend request:

Time T=0:  Client A → GET /products/42
Time T=1ms: Client B → GET /products/42  (same key, collapse)
Time T=2ms: Client C → GET /products/42  (same key, collapse)
Time T=50ms: Backend returns → fan out to A, B, C

Result: 1 backend call instead of 3

Implemented in: Nginx (proxy_cache_lock), Varnish (grace mode), CloudFlare, Envoy.


Benchmarks: Protocol Performance

These are approximate comparisons under controlled conditions. Real-world performance depends heavily on payload, network, and implementation.

Metric REST (JSON/HTTP2) GraphQL (JSON/HTTP2) gRPC (Protobuf/HTTP2)
Serialization size (1KB logical payload) ~1.2 KB ~1.0 KB (no over-fetching) ~0.4 KB
Serialization time ~1x baseline ~1x ~0.1–0.3x (binary)
Latency (unary, same DC) ~1–5ms ~2–8ms (resolver overhead) ~0.5–2ms
Throughput (single connection) Limited by HTTP/1.1 HOL Same as REST Higher (multiplexed, binary)
Browser support ✅ Native ✅ Native ⚠️ grpc-web proxy required
Streaming ❌ (SSE for server-push) ✅ Subscriptions (WS) ✅ 4 streaming types

When Performance Matters Less

For most CRUD APIs, the difference between REST and gRPC latency is negligible compared to database query time. Choose the paradigm based on developer experience and client requirements, not raw protocol speed. Raw protocol speed matters only for a low-latency trading system or millions of internal RPCs per second.


Security

Security reference for web APIs: OWASP API Security Top 10, authentication/authorization threat models, protocol-specific attack surfaces, transport security, and defensive patterns.


OWASP API Security Top 10 (2023)

The OWASP API Security Top 10 is the authoritative classification of the most critical API vulnerabilities. The 2023 edition reflects the modern API threat landscape.

API1:2023 — Broken Object Level Authorization (BOLA)

BOLA is the most prevalent API vulnerability. The attacker manipulates resource IDs in the request to access objects that belong to other users.

# Attacker changes orderId to access another user's order
GET /api/v2/orders/order_OTHER_USER_123
Authorization: Bearer attacker_token

# Server returns the order without verifying ownership → BOLA

Root cause: Authorization checks run at the endpoint level but not at the object level. The code retrieves the object by ID without a check that it belongs to the authenticated user.

# VULNERABLE — fetches any order by ID
@app.get("/orders/{order_id}")
async def get_order(order_id: str, db: DB):
    return db.orders.find_by_id(order_id)  # no ownership check

# SECURE — scopes query to authenticated user
@app.get("/orders/{order_id}")
async def get_order(order_id: str, user: User = Depends(get_current_user), db: DB):
    order = db.orders.find_one({"_id": order_id, "userId": user.id})
    if not order:
        raise HTTPException(404)
    return order

Mitigations: - Enforce object-level authorization in every data access function - Use random, non-sequential IDs (UUIDs/ULIDs) — does NOT replace authorization but reduces enumeration - Write integration tests that specifically verify cross-user access is denied

API2:2023 — Broken Authentication

Weak or missing authentication mechanisms allow attackers to impersonate legitimate users.

Common weaknesses: - No rate limiting on login/token endpoints → brute force - Credentials in query strings (?api_key=secret) → logged by proxies, browsers, CDN - No token expiration or excessively long TTL - JWT alg: none accepted → forged tokens - Password reset tokens that do not expire or are not single-use

Mitigations: - Rate limit authentication endpoints aggressively (for example, 5 failures per minute per IP) - Use Authorization header only — never query params for secrets - Short-lived access tokens (15 min) + refresh tokens (httpOnly, secure cookies) - Explicitly validate JWT algorithm on the server — never trust the alg header

API3:2023 — Broken Object Property Level Authorization

This category combines the former "Excessive Data Exposure" and "Mass Assignment." The API exposes object properties that the user must not see, or it lets the user modify properties that they must not control.

// API response includes internal fields the client must not see
{
  "id": "user_123",
  "name": "Alice",
  "email": "[email protected]",
  "role": "user",
  "passwordHash": "$2b$12$...",        // excessive data exposure
  "internalCreditScore": 780,          // excessive data exposure
  "isAdmin": false                     // modifiable via mass assignment
}
# Mass assignment — attacker sends field they must not control
PATCH /api/v2/users/me
{"name": "Alice", "role": "admin", "isAdmin": true}

Mitigations: - Explicit response schemas — allowlist fields per role, never return raw DB objects - Input DTOs with strict field allowlists — reject unknown fields - In Django REST Framework: use fields = (...) never fields = '__all__' - Separate read/write schemas (GraphQL input types already enforce this)

API4:2023 — Unrestricted Resource Consumption

The API does not limit the size or number of resources that a client can request. This enables denial-of-service.

Attack vectors: - No pagination limits → GET /users?limit=999999999 - Unbounded file uploads → 10 GB payload - Expensive operations without rate limiting → repeated POST /reports - Batch operations without bounds → POST /batch with 100K items - GraphQL query depth/complexity bombs

Mitigations: - Enforce max_page_size (for example, 100 items) - Set maximum request body size (nginx: client_max_body_size 10m) - Rate limit per user, per endpoint, and per expensive operation - GraphQL: depth limiting + complexity scoring + persisted queries - Set server-side timeouts for all operations

API5:2023 — Broken Function Level Authorization (BFLA)

Regular users can invoke administrative or privileged functions by calling the endpoint directly.

# Regular user discovers admin endpoint
DELETE /api/v2/admin/users/user_456
Authorization: Bearer regular_user_token

# Server processes it without checking role → BFLA

Mitigations: - Deny by default — every endpoint requires explicit role mapping - Separate admin routes with dedicated middleware: /admin/... with admin-only middleware - Do not rely on client-side hiding of admin features - Automated testing: enumerate all endpoints and verify each returns 403 for non-admin users

API6:2023 — Unrestricted Access to Sensitive Business Flows

Attackers automate legitimate business flows at scale (ticket scalping, coupon abuse, mass account creation, inventory hoarding).

Mitigations: - CAPTCHA / proof-of-work for sensitive flows - Device fingerprinting for anomaly detection - Rate limiting by business context (for example, max 3 coupons per user per day) - Bot detection (behavioral analysis, honeypot fields)

API7:2023 — Server-Side Request Forgery (SSRF)

The API accepts a URL from the user and fetches it server-side without validating the target.

// User-supplied webhook URL points to internal infrastructure
POST /api/v2/webhooks
{
  "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
// Server fetches AWS IMDS credentials → full cloud account compromise

SSRF deny-list (must block):

Target IP/Domain
AWS IMDS 169.254.169.254, metadata.amazonaws.com
GCP Metadata metadata.google.internal, 169.254.169.254
Azure IMDS 169.254.169.254
Localhost 127.0.0.0/8, 0.0.0.0/8, ::1/128
Private networks (RFC 1918) 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Link-local 169.254.0.0/16

Mitigations: - Validate and sanitize all user-supplied URLs - Block requests to private/reserved IP ranges (deny-list in the table) - Use an allowlist of permitted domains when possible - Disable HTTP redirects in outbound requests, or re-validate after each redirect - Run outbound requests from an isolated network zone (no access to IMDS or internal services)

API8:2023 — Security Misconfiguration

This is a broad category: missing security headers, verbose error messages, unnecessary HTTP methods, default credentials, and CORS misconfiguration.

Checklist:

Configuration Secure Setting
TLS TLS 1.2+ only, disable SSLv3/TLS 1.0/1.1
CORS Explicit origin allowlist, never * with credentials
Error responses Generic messages. Never expose stack traces, SQL errors, or internal paths
HTTP methods Disable unused methods (TRACE, TRACK)
Security headers X-Content-Type-Options: nosniff, Strict-Transport-Security, X-Frame-Options
Default credentials Remove all defaults. Rotate all secrets on deployment
Debug endpoints Remove /debug, /metrics, /health from public-facing routes (or protect them)
API documentation Disable Swagger UI / GraphiQL in production unless intentionally public

API9:2023 — Improper Inventory Management

Organizations lose track of which API versions, endpoints, and environments are exposed. Shadow APIs, deprecated endpoints, and forgotten dev/staging environments become attack surfaces.

Mitigations: - Maintain a complete API inventory (every endpoint, version, environment) - Automate endpoint discovery from code (OpenAPI spec generation) - Sunset deprecated API versions with Deprecation and Sunset headers (RFC 8594) - Network segmentation: dev/staging APIs must not be reachable from the internet - Regular API surface audit: compare actual traffic to documented endpoints

API10:2023 — Unsafe Consumption of APIs

The API trusts data received from third-party APIs/services without validating it — the third party becomes an attack vector.

# VULNERABLE — trusts third-party response blindly
def enrich_user(user):
    third_party_data = requests.get(f"https://partner-api.com/users/{user.id}").json()
    user.name = third_party_data["name"]       # could contain XSS payload
    user.credit_limit = third_party_data["credit_limit"]  # could be manipulated
    user.save()

# SECURE — validate and sanitize
def enrich_user(user):
    resp = requests.get(
        f"https://partner-api.com/users/{user.id}",
        timeout=5
    )
    resp.raise_for_status()
    data = ThirdPartyUserSchema.model_validate(resp.json())  # Pydantic validation
    user.name = bleach.clean(data.name)
    user.save()

Mitigations: - Validate all third-party responses against a strict schema - Sanitize data before storing or rendering - Use timeouts and circuit breakers for all outbound calls - Apply the same security standards to consumed APIs as you apply to your own inputs


JWT Attack Vectors

Algorithm Confusion (None / HMAC → RSA)

Attack 1: alg:none
  Attacker changes JWT header to {"alg": "none"}
  Strips signature → server accepts unsigned token

Attack 2: RS256 → HS256
  Server uses RS256 (public/private key pair)
  Attacker sets alg to HS256 and signs with the PUBLIC key
  Vulnerable server uses the public key as HMAC secret → signature validates

Defense: Never read the algorithm from the JWT header. Hardcode the expected algorithm on the server:

// SECURE — explicitly specify expected algorithm
JWTVerifier verifier = JWT.require(Algorithm.HMAC256(keyHMAC)).build();
DecodedJWT decodedToken = verifier.verify(token);

Token Sidejacking

If the JWT is stored in localStorage, XSS can steal it. If it is stored in a regular cookie, CSRF can use it.

Defense — Fingerprint binding: 1. On login, generate a random fingerprint 2. Store fingerprint hash in the JWT claims 3. Store fingerprint plaintext in a __Secure-Fgp httpOnly, secure, sameSite cookie 4. On each request, hash the cookie fingerprint and compare to the claim

This binds the token to the browser session — even if the JWT is stolen via XSS, the attacker cannot supply the httpOnly cookie.

JWK/JKU Injection

The attacker sets the jku (JWK Set URL) header to their own server, which hosts a crafted public key. Then they sign with the matching private key. The server fetches the key of the attacker, and the signature validates.

Defense: Never fetch keys from URLs in the JWT header. Use a static JWKS endpoint configured on the server.


Protocol-Specific Security

REST Security

Security headers that every REST API must set:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

Input validation rules: - Validate length, range, format, and type for all parameters - Use strong types (numbers, booleans, dates) — do not accept strings for everything - Constrain string inputs with regex - Reject request bodies exceeding size limits (HTTP 413) - Parse XML with XXE protections (disable external entities, DTD processing) - Log input validation failures — a spike indicates probing

GraphQL Security

GraphQL has a unique attack surface because of its flexibility:

Threat Attack Defense
Introspection abuse Attacker queries __schema to map the entire API Disable introspection in production
Depth bomb { user { friends { friends { friends { ... } } } } } Depth limiting (for example, max 10 levels)
Width bomb Request all fields on hundreds of objects Complexity scoring per field
Batch attack Send array of mutations in one request Limit batch size
BOLA via node field { node(id: "OTHER_USER_ID") { ... on User { email } } } Remove node/nodes relay fields or enforce authorization
Field suggestion leak Typo returns "Did you mean X?" → reveals schema Disable field suggestions in production

graphql-shield authorization example:

import { rule, shield, and, or, not } from "graphql-shield";

const isAuthenticated = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => ctx.user !== null
);

const isAdmin = rule({ cache: "contextual" })(
  async (parent, args, ctx, info) => ctx.user.role === "admin"
);

const permissions = shield({
  Query: {
    users: and(isAuthenticated, isAdmin),
    me: isAuthenticated,
  },
  Mutation: {
    deleteUser: and(isAuthenticated, isAdmin),
    updateProfile: isAuthenticated,
  },
  User: {
    email: isAuthenticated,
    passwordHash: isAdmin,  // only admins can see this field
  },
});

Check for relay node exposure:

cat schema.json | jq '.data.__schema.types[] | select(.name=="Query") | .fields[] | .name' | grep node

gRPC Security

Transport security: - Always use TLS in production — grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)) - For internal service mesh: mTLS via Istio/SPIFFE - Never use grpc.WithInsecure() outside of local development

Authentication interceptors:

// API key validation from metadata
func validateAPIKey(ctx context.Context) error {
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return status.Error(codes.Unauthenticated, "missing metadata")
    }
    keys := md["x-api-key"]
    if len(keys) == 0 || !isValidAPIKey(keys[0]) {
        return status.Error(codes.Unauthenticated, "invalid API key")
    }
    return nil
}

Protobuf input validation (protoc-gen-validate / buf validate):

syntax = "proto3";
import "validate/validate.proto";

message CreateUserRequest {
  string email = 1 [(validate.rules).string.email = true];
  string name  = 2 [(validate.rules).string = {min_len: 1, max_len: 100}];
  int32 age    = 3 [(validate.rules).int32 = {gte: 0, lte: 150}];
}

gRPC security testing:

# Test if endpoint requires auth
grpcurl -plaintext localhost:50051 myservice.MyService/GetUser

# Test with invalid token
grpcurl -plaintext \
  -H "authorization: Bearer invalid_token" \
  localhost:50051 myservice.MyService/GetUser

# Test with valid token
grpcurl -plaintext \
  -H "authorization: Bearer $(get_valid_token)" \
  -d '{"user_id": "42"}' \
  localhost:50051 myservice.MyService/GetUser

gRPC security assessment checklist: 1. All methods enforce authentication and authorization 2. Input validation is applied to all message fields 3. Rate limiting and resource exhaustion protections are active 4. TLS configuration and certificate handling are verified 5. Error messages do not disclose sensitive information (use gRPC status codes, not stack traces)

WebSocket Security

Threat Attack Defense
No origin check Cross-site WebSocket hijacking (CSWSH) Validate Origin header during handshake
Missing auth Unauthenticated connections Authenticate during handshake (token in query/header) or in first message
Injection Malicious payloads in messages Validate and sanitize all incoming messages
Data exfiltration Sensitive data over unencrypted WS Always use wss:// (WebSocket over TLS)
Resource exhaustion Opening thousands of connections Per-IP connection limits, idle timeout
// Server-side: validate origin during upgrade
server.on('upgrade', (request, socket, head) => {
  const origin = request.headers.origin;
  if (!ALLOWED_ORIGINS.includes(origin)) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(request, socket, head, (ws) => {
    wss.emit('connection', ws, request);
  });
});

Webhook Security

Threat Defense
Forged payloads HMAC-SHA256 signature verification (see apis/web-services/how-to-guides#payload-signing-hmac-sha256)
Replay attacks Include timestamp in signature, and reject if |now - timestamp| > 5 min
DDoS via webhook floods Rate limit incoming webhook requests. Queue for async processing
Sensitive data in transit HTTPS only. Verify the TLS certificate of the webhook consumer
SSRF from webhook URLs Validate registered URLs against deny-list (RFC 1918, cloud IMDS)

Authorization Patterns

RBAC (Role-Based Access Control)

Administrators assign roles to users. Roles map to permissions. RBAC is simple, well-understood, and widely adopted.

Roles:         admin, editor, viewer
Permissions:   orders:read, orders:write, orders:delete, users:manage
Mapping:
  admin  → orders:read, orders:write, orders:delete, users:manage
  editor → orders:read, orders:write
  viewer → orders:read
# Middleware check
def require_permission(permission: str):
    def decorator(func):
        @wraps(func)
        async def wrapper(request, *args, **kwargs):
            user = request.state.user
            if permission not in user.permissions:
                raise HTTPException(403, "Insufficient permissions")
            return await func(request, *args, **kwargs)
        return wrapper
    return decorator

@app.delete("/orders/{order_id}")
@require_permission("orders:delete")
async def delete_order(order_id: str):
    ...

Limitation: RBAC does not handle contextual decisions well (for example, "can edit only their own orders" or "can access only orders from their department").

ABAC (Attribute-Based Access Control)

Policies evaluate attributes of the user, resource, action, and environment at decision time.

Policy: ALLOW if
  user.department == resource.department AND
  action == "read" AND
  environment.time BETWEEN 09:00 AND 18:00

ABAC is more expressive than RBAC but more complex to implement and audit. AWS IAM, Google Cloud IAM, and Azure RBAC use it.

ReBAC (Relationship-Based Access Control)

ReBAC authorizes on the relationship between user and resource, not just roles. The model comes from Google Zanzibar (implementations: Carta, Warrant, OpenFGA, SpiceDB).

Tuples:
  document:budget-2026#viewer@user:alice
  document:budget-2026#editor@user:bob
  folder:finance#viewer@group:accounting

Check: can user:alice view document:budget-2026?
→ YES (direct viewer relationship)

ReBAC works best for document-sharing, multi-tenant SaaS, and social graph-based permissions.


Transport Security

TLS Configuration

# Nginx — modern TLS config
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_stapling on;
ssl_stapling_verify on;

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

TLS checklist: - TLS 1.2 minimum, TLS 1.3 preferred (faster handshake, forward secrecy built-in) - Disable SSLv3, TLS 1.0, TLS 1.1 - HSTS with long max-age, includeSubDomains, and preload - OCSP stapling for certificate verification performance - Certificate transparency (CT) logs — detect misissued certificates - Automate certificate rotation (Let's Encrypt / cert-manager in Kubernetes)

Certificate Pinning

Pin expected server certificate or public key hash in the client to prevent MITM attacks via compromised CAs.

# Get pin hash from certificate
openssl x509 -in server.crt -pubkey -noout | \
  openssl pkey -pubin -outform der | \
  openssl dgst -sha256 -binary | base64

Certificate Pinning Trade-offs

Pinning increases security against CA compromise but creates operational risk — certificate rotation requires synchronized client updates. Mobile apps with pinning must ship updates before certificate expiry. Use backup pins and a gradual rollout.


API Security Testing

Automated Security Scanning

Tool Type Target
OWASP ZAP DAST (dynamic) REST, GraphQL
Burp Suite DAST (proxy-based) REST, GraphQL, WebSocket
Nuclei Template-based scanner Any HTTP API
Semgrep SAST (static) Source code patterns
Snyk API Dependency + DAST REST APIs
GraphQL Cop GraphQL-specific Introspection, complexity, injection
grpc-audit gRPC-specific Auth, TLS, message validation

Security Testing Checklist

# 1. Test BOLA — access another user's resource with your token
curl -H "Authorization: Bearer USER_A_TOKEN" \
  https://api.example.com/v2/orders/USER_B_ORDER_ID

# 2. Test BFLA — call admin endpoint with regular user token
curl -X DELETE -H "Authorization: Bearer REGULAR_TOKEN" \
  https://api.example.com/v2/admin/users/user_456

# 3. Test mass assignment — send privileged fields
curl -X PATCH -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role": "admin", "isAdmin": true}' \
  https://api.example.com/v2/users/me

# 4. Test rate limiting — burst requests
for i in {1..100}; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    https://api.example.com/v2/auth/login \
    -d '{"email":"[email protected]","password":"wrong"}'
done

# 5. Test SSRF — webhook URL pointing to internal
curl -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "http://169.254.169.254/latest/meta-data/"}' \
  https://api.example.com/v2/webhooks

# 6. Test excessive data — request huge page
curl "https://api.example.com/v2/users?limit=999999"

# 7. Test GraphQL introspection in production
curl -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ __schema { types { name } } }"}'

# 8. Test JWT none algorithm
# Create token with alg:none, empty signature
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' > /tmp/jwt_header
echo -n '{"sub":"admin","role":"admin"}' | base64 | tr -d '=' > /tmp/jwt_payload
JWT="$(cat /tmp/jwt_header).$(cat /tmp/jwt_payload)."
curl -H "Authorization: Bearer $JWT" https://api.example.com/v2/users/me

Sources

OWASP

JWT & OAuth

GraphQL Security

Authorization Frameworks

Tools