Six API styles dominate real systems today, and they are not interchangeable. Choosing GraphQL because it's fashionable, then discovering you need per-field caching and rate limiting, is a very expensive mistake. Choosing REST for a service that fires 40,000 internal calls per second means you're paying for JSON parsing you didn't need.

What follows is what each style actually is, what it costs, and how to decide.

REST

REST is a set of conventions layered over HTTP: resources have URLs, HTTP verbs express intent, and status codes report outcomes. Most "REST" APIs in the wild are really HTTP+JSON APIs that borrow the conventions loosely, and that's fine — nobody checks your HATEOAS compliance.

A typical exchange:

curl -X POST https://api.example.com/v1/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"customer_id": "cus_912", "amount_cents": 4900, "currency": "usd"}'

The verbs carry real meaning, and getting them wrong breaks retries and caches:

Verb Meaning
GET Read a resource. Safe and idempotent, so proxies and browsers may cache the response.
POST Create a subordinate resource or run a non-idempotent action. Retrying can duplicate work unless you support an idempotency key.
PUT Replace a resource in full. Sending the same body twice leaves the same state.
PATCH Apply a partial update. Not idempotent in general, since {"count": "+1"}-style semantics compound.
DELETE Remove the resource. Idempotent — the second call should return 404 or 204, not an error.

Status codes are the part teams get sloppiest about:

Code Meaning
200 Success with a body.
201 Resource created; the Location header points to it.
204 Success, deliberately no body. Common for DELETE.
400 Malformed request the server could not parse or validate.
401 No credentials, or credentials the server could not verify.
403 Credentials verified, but this caller is not allowed to do this.
404 No such resource, or the caller isn't permitted to know it exists.
409 Conflict with current state — a duplicate create, or a failed optimistic-concurrency check.
422 Syntactically valid but semantically wrong, such as a negative amount.
429 Rate limited. Should carry Retry-After.
500 Unhandled server fault. The client can retry with backoff.
503 Temporarily unavailable, e.g. a dependency is down or the service is draining.

Cost: over-fetching and under-fetching. A mobile screen that needs a user's name plus their last three orders often makes three round trips. On a 200 ms mobile connection that's noticeable.

What you get in return is enormous: HTTP caching, CDN support, every debugging tool ever written, and engineers who need zero onboarding.

Webhooks

A webhook inverts the direction. Instead of you polling for changes, the provider makes an HTTP POST to a URL you registered when something happens. Stripe, GitHub, Shopify, and Twilio all work this way.

{
  "id": "evt_1P9xKz",
  "type": "payment_intent.succeeded",
  "created": 1719504322,
  "data": { "object": { "id": "pi_3P9x", "amount": 4900 } }
}

Webhooks are simple to describe and easy to get wrong. The failure modes are specific:

  • Duplicates. Providers retry on timeout, so you will receive the same event twice. Store the event id and make handlers idempotent.
  • Out-of-order delivery. subscription.updated can arrive before subscription.created. Compare timestamps or re-fetch current state from the API instead of trusting the payload.
  • Unverified senders. Anyone can POST to your public URL. Verify the HMAC signature (Stripe-Signature, X-Hub-Signature-256) before doing anything.
  • Slow handlers. Most providers time out at 5–10 seconds. Enqueue the event and return 200 immediately; do the work in a worker.

The operational cost is a queue plus a dead-letter path plus a way to replay. Budget a week of engineering, not an afternoon.

WebSockets

WebSockets upgrade an HTTP connection into a persistent, bidirectional TCP stream. After the handshake, either side sends frames whenever it wants, with no request/response pairing and no per-message HTTP header overhead.

This is what you use for chat, collaborative editing, live dashboards, multiplayer games, and trading feeds. Latency after the handshake is essentially network round-trip time.

The costs are real:

  • Stateful connections. Each open socket pins a client to a specific server process, which complicates deploys and autoscaling. Rolling a deploy means dropping thousands of connections and handling the reconnect stampede.
  • No built-in reconnect, no built-in retry, no built-in message ordering guarantees beyond TCP's. You implement heartbeats, sequence numbers, and resume-from-offset yourself.
  • Corporate proxies sometimes kill idle connections at 60 seconds. Ping every 30.

If you only need server-to-client updates, Server-Sent Events over HTTP is simpler, auto-reconnects, and works through more middleboxes. Reach for WebSockets when the client also needs to push at high frequency.

SOAP

SOAP is XML messaging with a formal contract, usually over HTTP but not necessarily. A WSDL file describes every operation and type, and tooling generates client stubs from it. It carries the WS-* extensions: WS-Security for message-level signing and encryption, WS-ReliableMessaging for guaranteed delivery, WS-AtomicTransaction for distributed transactions.

A request looks like this, and yes, this is the whole point of the criticism:

<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
  <soap:Body>
    <GetBalance xmlns="http://bank.example.org/">
      <AccountId>0091-4432</AccountId>
    </GetBalance>
  </soap:Body>
</soap:Envelope>

Do not start a new public API in SOAP. There is no upside for greenfield work — the payloads are bulky, the tooling is heavy, and no frontend developer wants to touch it.

You will still encounter it, because banking, insurance, healthcare, telco provisioning, and government systems are full of it, and those systems are not being rewritten. When you must integrate, use a generated client rather than hand-building XML, and put a thin REST facade in front so the rest of your codebase never sees an envelope. The one thing SOAP genuinely does better than REST is message-level security that survives being forwarded through intermediaries.

gRPC

gRPC uses Protocol Buffers for serialization and HTTP/2 for transport. You define services in a .proto file, and the compiler generates both server interfaces and client code in a dozen languages.

syntax = "proto3";

service Inventory {
  rpc Reserve(ReserveRequest) returns (ReserveResponse);
  rpc WatchStock(WatchRequest) returns (stream StockEvent);
}

message ReserveRequest {
  string sku = 1;
  int32 quantity = 2;
}

The wins are concrete. Binary Protobuf payloads typically run 3–10x smaller than the equivalent JSON, and parsing is far cheaper. HTTP/2 multiplexes many calls over one connection, so you avoid connection churn. The generated stubs mean a field rename becomes a compile error rather than a 3 a.m. incident. And streaming is first-class: unary, server-streaming, client-streaming, and bidirectional.

The costs: browsers can't speak gRPC natively, so public-facing use requires grpc-web plus a proxy like Envoy. curl won't debug it. You need grpcurl and the reflection service enabled. Every schema change requires a coordinated build, which is fine inside one org and painful across org boundaries.

gRPC uses its own status codes, not HTTP's:

Code Meaning
OK Call succeeded.
INVALID_ARGUMENT The client sent arguments the server rejected regardless of system state.
NOT_FOUND The requested entity does not exist.
ALREADY_EXISTS A create failed because the entity is already there.
PERMISSION_DENIED Authenticated caller lacks authorization for this operation.
UNAUTHENTICATED Missing or invalid credentials.
RESOURCE_EXHAUSTED Quota or rate limit hit.
FAILED_PRECONDITION System state makes the call invalid now; retrying without a state change won't help.
ABORTED Concurrency conflict such as a transaction abort. Retry at a higher level.
UNAVAILABLE Transient — server draining or unreachable. Safe to retry with backoff.
DEADLINE_EXCEEDED The client's deadline elapsed before a response arrived.
UNIMPLEMENTED The method isn't implemented on this server. Often a version skew.
INTERNAL Server-side invariant broken.

I've left out CANCELLED, UNKNOWN, OUT_OF_RANGE, and DATA_LOSS — you'll see them rarely and they mostly mean what they say.

GraphQL

GraphQL exposes a single endpoint and a typed schema. The client sends a query specifying exactly the fields it wants, and gets back a JSON object shaped like the query.

query {
  user(id: "912") {
    name
    orders(last: 3) {
      total
      items { sku quantity }
    }
  }
}

One round trip, no over-fetching, and the schema is introspectable so tooling can autocomplete against it. For a product with many clients — iOS, Android, web, a partner portal — each evolving on its own schedule, this removes a genuine bottleneck. Nobody files a ticket asking the backend team for a new endpoint variant.

The costs are all on the server side:

  • Caching. Everything is a POST to /graphql, so HTTP and CDN caching are gone. You replace them with persisted queries and a client-side normalized cache. That's real work.
  • N+1 queries. A naive resolver for orders { items } fires one database query per order. DataLoader-style batching is mandatory, not optional.
  • Query cost. A malicious client can request deeply nested relations and melt your database. You need depth limiting, complexity scoring, and ideally an allowlist of known queries in production.
  • Errors. GraphQL returns 200 with an errors array. Your monitoring will report 100% availability while every request fails. Instrument at the resolver level.

GraphQL is a good fit when the shape of client data needs varies a lot. It is the wrong choice for a small internal service with two consumers and one screen.

When to use which

Option Best for Avoid when
REST Public APIs, CRUD resources, cacheable reads Deeply nested client-driven queries
Webhooks Provider-to-consumer event notification You need a synchronous answer
WebSockets Bidirectional, low-latency, high-frequency updates One-directional or infrequent updates
SOAP Legacy enterprise integration, message-level security Any greenfield project
gRPC Internal service-to-service, high throughput, polyglot Browser clients without a proxy
GraphQL Many diverse clients, aggregating several backends Simple services, cache-heavy read paths

Decision rules that hold up in practice:

  • Public API with unknown consumers? REST with JSON. The ubiquity beats every technical advantage of the alternatives.
  • Internal calls between your own services, in a language you control, at meaningful volume? gRPC. The generated clients alone justify it.
  • Client polling an endpoint every few seconds and mostly getting nothing back? Replace it with webhooks or SSE. Polling at 5-second intervals across 10,000 clients is 2,000 requests per second of pure waste.
  • Data flows only server-to-client? SSE, not WebSockets. Fewer moving parts, and reconnection is free.
  • Three or more frontend teams filing tickets for endpoint variations? GraphQL is solving your actual problem.
  • Encountering SOAP? Wrap it, don't spread it. One adapter service, REST on the inside.

Mixing styles is normal and correct. A healthy architecture often runs REST at the public edge, gRPC between internal services, webhooks outbound to customers, and a WebSocket channel for the live parts of the UI. The mistake is picking one style as a company standard and forcing it everywhere.