Section B · Shape the surface

Choosing a Style

REST, gRPC, and GraphQL are three answers to one question: how does a caller ask for a thing? This chapter is what each is actually good at, where each hurts, the cost of choosing wrong, and why Snip's public API is REST over HTTP with webhooks.

The one question

Every API style answers the same question: how does a caller name a thing, and name an operation on it? The answers differ in where the name and the operation live on the wire. That choice sets what can cache, what a proxy can see, what a client generator can produce, and how much the caller must know before the first request.

This is the first shape decision and the hardest to undo. Resource names, error formats and pagination can change in a new version. The style cannot: switching from REST to GraphQL means a second API, a migration for every client, and a long overlap. Pick it once, on purpose.

This chapter covers the public control plane of Snip, the link shortener used through this guide. That is https://api.snip.ly/v1, about 1M writes a day. It does not cover the redirect GET snip.ly/{code}, which is the data plane: 100M requests a day, not programmable, and designed in Designing for Scale.

REST over HTTP/JSON

In REST, the URL names the thing and the HTTP method names the operation. A small fixed set of verbs applies to every resource. Snip's link is /v1/links/a3Xk9bQ, and the same verbs work on it as on any other resource:

POST   /v1/links              create      201 Created + Location
GET    /v1/links/a3Xk9bQ      read        200
PATCH  /v1/links/a3Xk9bQ      update      200
DELETE /v1/links/a3Xk9bQ      remove      204
GET    /v1/links?limit=50     list        200

The verbs carry meaning that software other than your server reads. GET is safe: a crawler, a prefetcher or a retry library may repeat it without asking you. PUT and DELETE are idempotent: a client may resend them after a timeout. A GET response with Cache-Control: max-age=60 and an ETag can sit in a browser, a CDN or a corporate proxy, and a later If-None-Match returns 304 with no body. Status codes tell intermediaries and clients what happened (404, 409, 429) without parsing the body. RFC 9110 defines all of this; you inherit it by using the protocol as written.

REST is also easy to discover. A person can paste a URL in a browser, run curl, and read the JSON. No toolchain stands between a new caller and a first success.

Where it hurts

  • Over-fetching. GET /v1/links/a3Xk9bQ returns every field of the link whether the caller wants one or six.
  • Under-fetching. A dashboard that shows a link, its clicks and its tags makes one call per resource: /links/{code}, then /links/{code}/clicks. On a mobile network at 150 ms per round trip, three sequential calls cost almost half a second before any rendering.
  • No streaming. A response is one message. Long polling, server-sent events and webhooks fill the gap, but none is part of the uniform interface.
  • Weak contract by default. JSON has no schema. You get one only if you write an OpenAPI document and keep it true.

Teams often name an API "REST" when it is only JSON over HTTP with verbs in the URL (POST /createLink). That gives up the caching and the retry rules above and keeps none of the benefit. If you use HTTP, use the verbs and status codes as RFC 9110 defines them.

gRPC

gRPC puts the operation in the contract, not the URL. You write a .proto file that defines messages and services. A generator then produces a typed client and a server stub in each language. On the wire, the body is Protocol Buffers, a binary format where each field is a numbered tag followed by its value, carried over HTTP/2.

syntax = "proto3";
package snip.v1;

service LinkService {
  rpc CreateLink(CreateLinkRequest) returns (Link);
  rpc GetLink(GetLinkRequest) returns (Link);
  rpc WatchClicks(WatchClicksRequest) returns (stream Click);
}

message Link {
  string code = 1;
  string url = 2;
  repeated string tags = 4;
}

Three properties follow from this design:

  • Small and fast. A field costs a tag byte plus its value, with no field names in the payload. Messages are commonly a third to a half the size of the same JSON, and parsing is cheaper. HTTP/2 multiplexes many calls on one connection.
  • Streaming is native. WatchClicks above returns a server stream. Client streams and bidirectional streams exist too, all on one connection.
  • The contract is code. Rename a field and the build breaks in every client that used it. Field numbers, not names, define compatibility, so you may rename freely and must never reuse a number.

Where it hurts

Browsers cannot speak native gRPC, because JavaScript has no control over HTTP/2 framing and trailers. You need gRPC-Web and a proxy, and you lose client streaming. The payload is binary, so curl and a browser's network tab show nothing readable without extra tools. HTTP caches key on method and URL, and every gRPC call is a POST to /snip.v1.LinkService/GetLink, so intermediaries cannot cache it. Every caller needs your .proto and a generator, which suits teams that share a repo and fails for strangers.

GraphQL

GraphQL has one endpoint, usually POST /graphql. The request body is a query that names the exact fields the caller wants, across related objects. The server returns a JSON document with the same shape.

query LinkCard($code: ID!) {
  link(code: $code) {
    shortUrl
    tags
    clicks(first: 5) { createdAt country }
  }
}

That one request replaces the three REST calls above, and returns only five fields. For a mobile app on a slow link, with screens that change weekly, this is the real gain: the client team changes a query and ships, and the server team ships nothing. The schema is typed and introspectable, so tooling can autocomplete and validate a query before it runs.

Where it hurts

  • HTTP caching is mostly gone. Every call is a POST to one URL, so the CDN sees no difference between queries. You can recover some with persisted queries sent by GET and a hash, or cache in the client library by object id, but that is work you now own.
  • Cost is hidden in the query. A caller can nest links { clicks { link { clicks … } } } to any depth. You must enforce depth and cost limits on the server, and rate limiting by request count no longer protects you, since one request may cost 1 row or 1M.
  • N+1 in resolvers. Each field has a resolver function. Resolving clicks for 50 links naively runs 50 queries. Batching loaders fix it, but you must write them.
  • Errors are partial. A response is 200 with an errors array and possibly some data. Status-code-based monitoring and retries stop working as designed.

RPC-style JSON

The fourth option is plain RPC over HTTP: POST /rpc with {"method": "createLink", "params": {…}}, or JSON-RPC 2.0, which standardises that envelope. It is the simplest thing that works, and many internal tools and some well-known public APIs use it. It also gives away what REST provides: every call is a POST, so no caching and no safe retries by default, and every error needs a home inside the body. Choose it when the domain is verbs, not nouns (reindex, rotateKey) and no cache or intermediary matters. Even in a REST API, a few operations fit this form, and Resource Modeling shows how to place them.

Side by side

DimensionREST/JSONgRPCGraphQL
PayloadJSON, text, self-describingProtobuf, binary, compactJSON, shaped by the query
TransportHTTP/1.1, 2 or 3HTTP/2 onlyUsually HTTP POST
Operation lives inMethod + URLService and method in the .protoQuery text in the body
CachingNative: Cache-Control, ETag, CDNNone at HTTP levelClient-side; persisted queries for HTTP
StreamingNot built inServer, client, bidirectionalSubscriptions (separate transport)
Browser supportNativegRPC-Web plus proxyNative
DiscoverabilityHigh: curl, OpenAPILow: needs .proto and toolsHigh: introspection, explorers
CouplingLoose; client reads what it needsTight; shared generated typesMedium; tied to the schema, not to endpoints
Typical homePublic, CRUD-shaped APIsInternal service-to-serviceOne rich client with changing screens

Deciding

Ask who the callers are before you ask what is fastest.

  • Unknown callers, public API: REST. You cannot hand strangers a toolchain, and you cannot change their code. Plain HTTP works from curl, a browser, a shell script and every language. Flips when: a single first-party client drives all traffic and its screens outrun your release cycle. Then GraphQL for that client is reasonable, next to REST for everyone else.
  • Known callers, internal mesh: gRPC. You control both ends, share a repo or a schema registry, and care about p99 latency and streaming. Generated clients remove a class of drift bugs. Flips when: the caller is a browser, or the team will not run a proxy and a code generator.
  • One client, aggregation-heavy, volatile: GraphQL. Typically a mobile or web front end that joins five resources per screen. Flips when: you lack the staff to run query-cost limits, loaders and schema governance. Those are real operating costs, not setup tasks.

These do not exclude each other. A common shape is REST or GraphQL at the edge and gRPC behind it. The edge contract is the one you cannot break; the internal ones you can migrate in one coordinated release.

The style decides who pays. REST makes the server do more work (extra fields, extra round trips) so any caller can succeed with no setup. gRPC moves that cost into shared tooling. GraphQL moves it into server-side query planning. Choose where you want the cost to land.

Snip's choice

Snip's public API is REST over HTTP/JSON, for four reasons. It is public, so callers are unknown and cannot be forced onto a toolchain. It is CRUD-shaped: a link is created, read, patched, deleted and listed, and a click is a read-only sub-resource at GET /v1/links/{code}/clicks. It is cacheable: GET /v1/links/{code} carries an ETag, and most integrations read far more than they write. And it must work from curl, a browser and every language, which a pasted example proves in one line:

curl https://api.snip.ly/v1/links/a3Xk9bQ \
  -H "Authorization: Bearer sk_live_…"

Push is not a reason to leave REST. Snip does not stream clicks over the API. It sends a link.clicked webhook, batched and sampled, and the receiver verifies the Snip-Signature HMAC. Chapter 9 covers it in Async, Webhooks and Streaming. The aggregation case that favours GraphQL is also thin here: a link has few related resources, and total_clicks is already on the link as an eventually consistent counter.

Inside Snip, services talk gRPC. That is a separate, private contract owned by the teams on both sides, with its own versioning rules. A LinkService.CreateLink call in the mesh and a POST /v1/links at the edge may share a data model, but they share no promise. The public contract is what this guide designs. Next, Resource Modeling decides what the nouns are.

✓ Check yourself

Each question is a style decision you will face. Answer before opening the fold.

  1. A team calls its API "REST" but exposes only POST /getLink and POST /deleteLink. Which properties of HTTP does it lose, and what would it need to restore them?

    Show answer

    It loses cacheability (only GET caches), safe retries (intermediaries and libraries cannot tell reads from writes) and meaningful status codes if every reply is 200. It must move reads to GET on resource URLs, use PUT/DELETE for idempotent changes, and return real statuses. See REST over HTTP/JSON.

  2. Your mobile dashboard makes four sequential REST calls at 150 ms round trip. What are two ways to cut that without adopting GraphQL, and when would GraphQL still win?

    Show answer

    Add an embed or expand parameter that returns related data in one response, or add a purpose-built aggregate endpoint for that screen. Parallel requests also help when the calls do not depend on each other. GraphQL wins when screens change often, many different shapes are needed, and you want the client team to change them without server releases. See GraphQL.

  3. Why can a CDN cache GET /v1/links/a3Xk9bQ but not a gRPC GetLink or a GraphQL query, even though all three are reads?

    Show answer

    HTTP caches key on method plus URL and read Cache-Control. REST puts the identity of the thing in the URL and uses GET. gRPC and standard GraphQL send a POST to one fixed path, with the identity inside the body, so the cache sees one undifferentiated URL. Persisted GraphQL queries by GET restore some of it. See Side by side.

  4. Snip's internal services use gRPC while its public API is REST. What makes that safe, and what mistake would make it costly?

    Show answer

    The two contracts are separate: the internal one can change in a coordinated release, while the public one is frozen by outside clients. The mistake is to expose the internal messages or field names directly at the edge, which binds the public API to internal refactors. See Snip's choice.

  5. A partner wants live click updates. Why is that not a reason to switch Snip's API to gRPC or GraphQL subscriptions?

    Show answer

    Streams need long-lived connections, and many callers (browsers, scripts, serverless functions) cannot hold them. A link.clicked webhook is push over plain HTTP, works with any receiver, and fits the batched, sampled, at-least-once design the volume needs. See Snip's choice.

Takeaway

  • Every style answers one question: how a caller names a thing and an operation. The answer fixes caching, tooling and coupling.
  • REST wins for public, CRUD-shaped APIs because it uses HTTP as defined: verbs, status codes, caches, and a first call from curl.
  • gRPC wins inside a mesh you control: compact payloads, generated clients, native streaming. It fits poorly at a public edge.
  • GraphQL wins for one client with volatile, aggregation-heavy screens, and costs you HTTP caching, query-cost limits and resolver batching.
  • Styles combine. Run REST at the edge and gRPC behind it, and keep the two contracts separate.

Next, the nouns. Resource Modeling →

Further reading