Skip to content
All posts

The Six API Styles Nobody Explains Together Until You Have Already Picked the Wrong One

April 25, 2026·Read on Medium·

A practitioner’s breakdown of REST, GraphQL, gRPC, WebSockets, Webhooks and SOAP with real trade-offs instead of bullet points

Most articles about API architecture styles are written in the same order: REST, GraphQL, gRPC, WebSockets, Webhooks and SOAP, each with a paragraph describing what it is and a sentence telling you when to use it. You finish reading and feel informed. Then you sit down to design a system and realise you still do not know how to actually choose.

The problem is that each style is described in isolation, as if the only question is “what is this thing?” The harder question is “given my specific situation, which one will I regret least in two years?” That question requires understanding not just what each style does but what it costs you and where it quietly breaks down.

I have worked with all six of these in production systems. Here is what the bullet-point summaries leave out.

REST

REST is the default. If you are building an API and you have not made a deliberate choice, you are probably building REST. That is not a criticism. REST earned its position as the default because it is genuinely good at the thing most APIs need to do: expose resources over HTTP in a way that any client can consume without a special library or deep knowledge of your system.

The simplicity is the point. A REST API is just HTTP. Every developer already knows HTTP. Your endpoints have predictable shapes. Your responses are usually JSON. You can test an endpoint with nothing more than a browser or curl. You can cache GET responses at the CDN layer without touching your application. When something breaks, the stack trace is readable by humans.

What REST does not give you is a contract. There is no formal schema enforcement on the request or response by default. There is no specification that forces your /users endpoint to look the same as someone else's /users endpoint. The stateless, resource-oriented design is a set of conventions, not rules. Teams routinely build things that call themselves REST but violate half the constraints, and nothing stops them.

The other thing REST does not solve natively is the over-fetching problem. If a mobile client needs the user’s name and profile picture from a user object that contains 40 fields, your REST endpoint returns all 40 fields unless you build filtering yourself. At scale on mobile networks, that adds up.

Use REST when: you are building a public-facing API, integrating with third-party systems, building an MVP that needs to move fast or serving clients you do not control. REST is also the correct answer when your team is mixed-experience and you need everyone to be productive without a ramp-up period.

Do not use REST when: you have a mobile-first product with complex nested data requirements, or you have performance-critical internal service-to-service communication where you control both ends.

How it works in TypeScript (Express)

You define a router, point it to handler functions and return JSON. Express handles the HTTP plumbing.

// src/routes/orders.ts
import { Router, Request, Response } from 'express';

const router = Router();
// GET /api/orders
router.get('/', async (req: Request, res: Response) => {
const page = Number(req.query.page) || 1;
const limit = 20;
const orders = await db.order.findMany({
include: { items: true },
skip: (page - 1) * limit,
take: limit,
});
res.json({ data: orders, page, limit });
});
// GET /api/orders/:id
router.get('/:id', async (req: Request, res: Response) => {
const order = await db.order.findUnique({
where: { id: Number(req.params.id) },
include: { items: true, customer: true },
});
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
res.json(order);
});
// POST /api/orders
router.post('/', async (req: Request, res: Response) => {
const { customerId, items } = req.body as {
customerId: number;
items: { productId: number; quantity: number }[];
};
const order = await db.order.create({
data: { customerId, items: { create: items } },
});
res.status(201).json(order);
});
export default router;

Wire it up in your main app.ts:

// src/app.ts
import express from 'express';
import ordersRouter from './routes/orders';

const app = express();
app.use(express.json());
app.use('/api/orders', ordersRouter);
app.listen(3000);

The client sends standard HTTP requests and reads back JSON:

GET  /api/orders       # list with pagination
GET /api/orders/42 # single order with relationships
POST /api/orders # create a new order

Every developer on your team already knows how to read and write this. That is the point.

GraphQL

GraphQL was developed by Meta and released publicly in 2015. The problem it was solving was specific: Facebook’s mobile apps were fetching enormous amounts of data from REST endpoints, most of which was never displayed. The news feed on a mobile device needs a very different subset of user and post data compared to the desktop feed. Maintaining separate REST endpoints for each combination was becoming unmanageable.

GraphQL’s answer is to let the client define the shape of the response. Instead of the server deciding what data to return, the client sends a query that specifies exactly which fields it needs. The server resolves those fields and returns only what was asked for. One endpoint. Client-driven queries. No over-fetching.

In practice this is genuinely powerful for data-heavy products. GitHub migrated its API to GraphQL in 2017 for exactly this reason. If you are building a product where frontend teams are constantly asking for new endpoint variants, GraphQL removes that negotiation entirely.

The costs are real though. GraphQL breaks HTTP-level caching. GET requests with query parameters are cacheable by default. GraphQL queries typically use POST, and because each query is unique, intermediate caches have nothing to work with. You have to implement caching at the application layer, which is more complex and more expensive to get right.

The other cost is query complexity. Because clients can request any combination of fields, a poorly written query can trigger a deeply nested series of database calls that would never have been possible through a REST endpoint you controlled. Production GraphQL APIs require query depth limiting and complexity analysis to avoid clients accidentally bringing down your database.

There is also the N+1 problem. If you query a list of posts and each post has an author field, a naive resolver will issue one database query per post to fetch the author. A list of 100 posts becomes 101 queries. DataLoader and batching solve this but they are not automatic. You have to know to implement them.

Use GraphQL when: you have a complex data model with many entity relationships, you have multiple client types needing different data shapes or your frontend teams are bottlenecked waiting for new REST endpoints.

Do not use GraphQL when: your data requirements are simple and stable, your team is small with limited bandwidth to implement and maintain the schema and resolvers or you need straightforward HTTP-level caching.

How it works in TypeScript (Apollo Server)

You define a schema as a string, write resolver functions and start the server. Apollo handles the single /graphql endpoint.

// src/graphql/server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

interface Order {
id: number;
status: string;
total: number;
customerId: number;
}
interface Resolvers {
Query: {
order: (_: unknown, args: { id: string }) => Promise<Order | null>;
orders: () => Promise<Order[]>;
};
}
const typeDefs = `#graphql
type Order {
id: ID!
status: String!
total: Float!
customer: User!
items: [OrderItem!]!
}
type User {
id: ID!
name: String!
email: String!
}
type OrderItem {
productName: String!
quantity: Int!
price: Float!
}
type Query {
order(id: ID!): Order
orders: [Order!]!
}
`
;
const resolvers: Resolvers = {
Query: {
order: async (_, { id }) => {
return db.order.findUnique({
where: { id: Number(id) },
include: { customer: true, items: true },
});
},
orders: async () => {
return db.order.findMany({ include: { items: true } });
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`GraphQL server ready at ${url}`);

The client sends one POST request and specifies exactly what it needs:

query {
order(id: "42") {
id
status
total
customer {
name
email
}
items {
productName
quantity
price
}
}
}

The mobile client that only needs id, status and total asks for exactly those three fields and receives exactly those three fields. No over-fetching.

gRPC

gRPC was developed by Google and released in 2016. It is a framework for remote procedure calls: you define methods on a server and call them from a client as if they were local function calls. The underlying transport is HTTP/2. The serialization format is Protocol Buffers, a binary format that is significantly more compact than JSON.

The performance difference is real and measurable. gRPC uses HTTP/2 multiplexing, which allows multiple requests to share a single TCP connection. Protocol Buffers serialize and deserialize faster than JSON parsing and produce smaller payloads. Most benchmarks put gRPC at roughly 5 to 10 times faster than REST for the same operation on the same hardware.

For internal microservice communication where you control both the client and the server, this is a substantial advantage. If service A calls service B a million times a day, that latency difference compounds into real cost, either in infrastructure or in user-facing response time.

gRPC also enforces a strict contract through the .proto file. Both client and server generate code from the same schema. If the schema changes in a way that breaks the contract, the generated code will not compile. That strictness is painful during development and excellent in production.

The trade-offs are significant. gRPC payloads are binary and not human-readable. Debugging without tooling is unpleasant. Browser support is limited: native browser APIs do not expose enough HTTP/2 control for gRPC to work directly, so you need gRPC-Web and a proxy layer for browser clients. The schema and tooling setup is non-trivial compared to just writing a REST controller.

Use gRPC when: you are building internal service-to-service communication in a microservices architecture, you need bi-directional streaming for real-time data pipelines or you are working in a polyglot environment and need generated, type-safe clients across multiple languages.

Do not use gRPC when: you need browser clients without a proxy layer, your team is unfamiliar with Protocol Buffers and does not have bandwidth to learn or you are building a public API where external developers need to integrate easily.

How it works in TypeScript (@grpc/grpc-js)

You start with a .proto file. Both your server and every client generate types from it.

// protos/order.proto
syntax = "proto3";

package orders;
service OrderService {
rpc GetOrder (OrderRequest) returns (OrderResponse);
}
message OrderRequest {
int32 id = 1;
}
message OrderResponse {
int32 id = 1;
string status = 2;
float total = 3;
}

Generate TypeScript types from the proto file:

npx proto-loader-gen-types --longs=String --enums=String \
--defaults --oneofs --grpcLib=@grpc/grpc-js \
--outDir=src/proto protos/order.proto

Implement the server:

// src/server.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';
import { OrderServiceHandlers } from './proto/orders';

const packageDefinition = protoLoader.loadSync('protos/order.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition) as any;
const handlers: OrderServiceHandlers = {
GetOrder: async (call, callback) => {
const order = await db.order.findUnique({
where: { id: Number(call.request.id) },
});
if (!order) {
return callback({
code: grpc.status.NOT_FOUND,
message: `Order ${call.request.id} not found`,
});
}
callback(null, {
id: order.id,
status: order.status,
total: order.total,
});
},
};
const server = new grpc.Server();
server.addService(proto.orders.OrderService.service, handlers);
server.bindAsync(
'0.0.0.0:50051',
grpc.ServerCredentials.createInsecure(),
() => server.start()
);

Call it from another service as if it were a local function:

// src/client.ts
import * as grpc from '@grpc/grpc-js';
import * as protoLoader from '@grpc/proto-loader';

const packageDefinition = protoLoader.loadSync('protos/order.proto', {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const proto = grpc.loadPackageDefinition(packageDefinition) as any;
const client = new proto.orders.OrderService(
'order-service:50051',
grpc.credentials.createInsecure()
);
// Typed call: if you rename a field in the .proto,
// the generated types will catch it before this compiles
client.GetOrder({ id: 42 }, (err: grpc.ServiceError, response: any) => {
if (err) throw err;
console.log(response.status); // 'confirmed'
});

The generated types mean your IDE catches mismatches at compile time. If you rename a field in the .proto, the code using the old name will not build.

WebSockets

REST, GraphQL and gRPC are all request-response protocols. The client initiates, the server responds. WebSockets break that model entirely.

A WebSocket connection starts as an HTTP request, then upgrades to a persistent, full-duplex TCP connection. Once the connection is open, either side can send messages at any time without waiting for the other to initiate. The connection stays open until one side closes it.

This is the correct model for genuinely real-time applications where the server needs to push data to the client without the client asking. A chat application where messages arrive as they are sent. A trading dashboard where prices update the moment they change. A multiplayer game where the server needs to broadcast state changes to all connected clients simultaneously. Trying to simulate this with REST polling is technically possible but wasteful: you are making HTTP requests most of which return nothing new, burning bandwidth and server resources to simulate a capability that WebSockets provide natively.

The operational complexity is the trade-off. Persistent connections are stateful. Scaling a stateful service horizontally is harder than scaling a stateless REST API. If a client is connected to server A and a message needs to reach it from server B, you need a shared pub/sub layer such as Redis between your servers to broadcast correctly. You also need to handle reconnection logic, heartbeats and connection cleanup on both sides.

Use WebSockets when: your feature genuinely requires the server to push data to the client in real time without the client polling. Live notifications, collaborative editing, real-time dashboards, chat and multiplayer experiences are the canonical cases.

Do not use WebSockets when: your updates can tolerate a polling interval of even a few seconds, or you are trying to use them simply because real-time sounds better than it needs to be for your use case. The operational overhead is significant and not worth it for features that could work fine with a 5-second polling interval.

How it works in TypeScript (ws + Node.js)

The ws package is the standard WebSocket library for Node.js. You create a server, listen for connections and broadcast to all connected clients.

// src/websocket.ts
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';

interface OrderUpdateMessage {
type: 'order.status.updated';
orderId: number;
status: string;
}
// Map of orderId -> Set of connected clients subscribed to that order
const subscriptions = new Map<number, Set<WebSocket>>();
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
const orderId = Number(new URL(req.url!, 'ws://localhost').searchParams.get('orderId'));
// Register this client as interested in this order
if (!subscriptions.has(orderId)) {
subscriptions.set(orderId, new Set());
}
subscriptions.get(orderId)!.add(ws);
ws.on('close', () => {
subscriptions.get(orderId)?.delete(ws);
});
});
// Call this from anywhere in your application when an order changes
export function broadcastOrderUpdate(orderId: number, status: string): void {
const clients = subscriptions.get(orderId);
if (!clients) return;
const message: OrderUpdateMessage = {
type: 'order.status.updated',
orderId,
status,
};
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(message));
}
}
}

Trigger it from your order service when status changes:

// src/services/order.service.ts
import { broadcastOrderUpdate } from '../websocket';

export async function confirmOrder(orderId: number): Promise<void> {
await db.order.update({
where: { id: orderId },
data: { status: 'confirmed' },
});
// Pushes instantly to all subscribed clients, no polling
broadcastOrderUpdate(orderId, 'confirmed');
}

On the frontend, a plain browser WebSocket connection:

// In your browser / frontend code
const ws = new WebSocket(`ws://localhost:8080?orderId=42`)

ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'order.status.updated') {
console.log('Status changed to:', message.status);
// Update the UI without polling
}
};
ws.onclose = () => {
// Implement reconnection logic here
console.log('Connection closed, reconnecting...');
};

The browser receives the update the moment confirmOrder runs on the server. No polling. No refresh.

Webhooks

Webhooks are the inverse of a typical API call. Instead of your application calling an external service to ask what happened, the external service calls your application to tell you that something happened.

You register a URL with the service. When the event you subscribed to occurs, the service sends an HTTP POST to your URL with a payload describing the event. Your application receives the payload, processes it and returns a 2xx response to acknowledge receipt.

This is how payment gateways work in practice. When Stripe processes a payment, it does not wait for you to poll /payment-status. It sends a webhook to your endpoint with the result. This is how GitHub Actions triggers builds. This is how Slack notifies your application of new messages. Event-driven integration without polling is what webhooks provide.

The failure mode is delivery reliability. The external service sends the webhook and waits for your 2xx response. If your server is down, if the response times out or if your endpoint returns a 5xx, the service may retry, but retry policies vary by provider. If your endpoint is down for long enough, you can miss events permanently. This means your webhook handler needs to be highly available and needs to acknowledge receipt quickly. The convention is to accept the webhook, push the payload to a queue and process it asynchronously. Do not do the work inside the webhook handler itself.

You also need to verify that incoming requests are actually from the service you subscribed to. Stripe signs its webhook payloads with a secret. You verify the signature before trusting the payload. If you skip this, anyone who knows your endpoint URL can send arbitrary payloads to your application.

Use Webhooks when: you need to react to events in external systems, the external system supports webhooks and you want to avoid polling an API on an interval.

Do not use Webhooks when: you need guaranteed exactly-once delivery or you need to query historical state. Webhooks are fire-and-forget from the sender’s perspective. They are not a message queue.

How it works in TypeScript (Express + Stripe)

You expose a route, verify the signature and queue the work. Never do the actual processing inside the handler.

// src/routes/webhooks.ts
import { Router, Request, Response } from 'express';
import Stripe from 'stripe';
import { processStripePayment } from '../jobs/payment';

const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// IMPORTANT: raw body required for signature verification
// Use express.raw() on this route, not express.json()
router.post(
'/stripe',
express.raw({ type: 'application/json' }),
async (req: Request, res: Response) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// Verify the payload came from Stripe, not an attacker
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return res.status(400).json({ error: 'Invalid signature' });
}
// Acknowledge immediately, do the work asynchronously
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
// Push to a queue, do not await here
processStripePayment(paymentIntent).catch(console.error);
}
res.json({ received: true });
}
);
export default router;

The actual work happens in a separate async function (or a proper job queue in production):

// src/jobs/payment.ts
import Stripe from 'stripe';

export async function processStripePayment(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const order = await db.order.findFirst({
where: { stripePaymentIntentId: paymentIntent.id },
});
if (!order) {
throw new Error(`No order found for payment intent ${paymentIntent.id}`);
}
await db.order.update({
where: { id: order.id },
data: { status: 'paid' },
});
// Send confirmation email, update inventory, etc.
}

The handler does two things: verify the signature and acknowledge receipt. Everything else is the job’s responsibility. If your handler times out, Stripe retries. If your job fails, your queue retries. Both failure modes are recoverable.

SOAP

SOAP has a reputation problem in developer culture. It is associated with XML verbosity, enterprise complexity and the early 2000s. That reputation is partially deserved. Building a new system with SOAP in 2026 would be a strange choice.

And yet SOAP is still running a significant portion of the world’s banking transactions, healthcare record systems and government integrations. That is not nostalgia. It is inertia combined with genuine requirements that SOAP was designed to meet.

SOAP uses XML for messaging and WSDL for service description. The contract between client and server is explicit and machine-readable. WS-Security provides message-level encryption and digital signatures, meaning the security is embedded in the message itself rather than relying solely on transport-level TLS. SOAP supports ACID transactions natively. For a core banking system processing transfers where partial failures must be rolled back, these are not nice-to-have features.

The cost of that rigidity is everything else. XML payloads are verbose compared to JSON. Parsing XML is slower than parsing JSON. The learning curve is steep. Debugging is painful. Changing a SOAP service requires updating the WSDL and regenerating client code across every consumer of that service. Agility is the sacrifice you make for auditability and strict contracts.

If you are building anything new today, you are almost certainly not building SOAP. If you are maintaining a system that uses SOAP, you will probably be maintaining it for longer than you expect, because the systems it integrates with are not going away.

Use SOAP when: you are integrating with legacy enterprise systems that expose SOAP endpoints, you are operating in a regulated industry where message-level security and ACID transactions are required or you have no choice because the vendor only speaks SOAP.

Do not use SOAP when: you are building anything from scratch.

How it works in TypeScript (soap npm package)

The soap npm package provides a SOAP client. You point it at a WSDL URL and call methods as if they were async functions. The library handles XML serialisation and deserialisation.

// src/services/government-soap.ts
import * as soap from 'soap';

interface RoadTaxRequest {
VehicleNo: string;
ApiKey: string;
}
interface RoadTaxResponse {
ExpiryDate: string;
Status: string;
}
export async function checkRoadTax(vehicleNumber: string): Promise<{
expiryDate: string;
status: string;
}> {
const wsdlUrl = process.env.GOVERNMENT_WSDL_URL!;
// The client is created once per WSDL, cache this in production
const client = await soap.createClientAsync(wsdlUrl);
const request: RoadTaxRequest = {
VehicleNo: vehicleNumber,
ApiKey: process.env.GOVERNMENT_API_KEY!,
};
const [result]: [RoadTaxResponse] = await client.CheckRoadTaxAsync(request);
return {
expiryDate: result.ExpiryDate,
status: result.Status,
};
}

What travels over the wire looks like this. The soap library builds and parses it for you:

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<CheckRoadTax>
<VehicleNo>WXX1234</VehicleNo>
<ApiKey>your-api-key</ApiKey>
</CheckRoadTax>
</soap:Body>
</soap:Envelope>

You will encounter SOAP when integrating with government portals, legacy banking systems and older enterprise software that has not been updated in a decade. You do not choose SOAP. It chooses you.

The real answer is that you will use more than one

The pattern in production systems is hybrid. You use REST for your public API because external developers expect it and it is easy to document. You use gRPC between internal services where performance matters and you control both ends. You use GraphQL on your mobile and web frontend where the data requirements are complex and the frontend teams are moving fast. You use WebSockets for the live features. You use webhooks to integrate with payment providers and third-party services.

Netflix uses multiple API styles internally. Large e-commerce platforms commonly use GraphQL for product catalogs and REST for payment flows where PCI compliance keeps the integration surface simple and auditable. The teams that struggle are the ones who pick one style early, apply it everywhere and then discover two years later that they are polling a REST endpoint five hundred times a second to simulate real-time behaviour, or maintaining a GraphQL schema for a simple CRUD service that would have been three REST endpoints.

The decision is not “which API style is best?” The decision is “what does this specific part of my system actually need, and what will my team be able to maintain six months from now?”

Answer that honestly and the choice usually becomes obvious.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
The Six API Styles Nobody Explains Together Until You Have Already Picked the Wrong One — Hafiq Iqmal — Hafiq Iqmal