Skip to main content

REST, gRPC, GraphQL and Webhooks

TL;DR
  • REST acts on nouns with HTTP methods and status codes, so caches, proxies and browsers understand it for free. gRPC calls a named function through a generated stub, with a .proto schema and binary protobuf over HTTP/2. GraphQL is one endpoint where the client names exactly the fields it wants. A webhook reverses the direction: the provider POSTs to your URL when something happens.
  • A common split is REST or GraphQL at the public edge and gRPC between internal services. It is a habit, not a law: pick by who the client is (a browser, a phone, a service) and what the call shapes are (one answer, a stream, a push).
  • gRPC is HTTP/2 underneath: POST /pkg.Service/Method, content-type: application/grpc, each message prefixed by 1 flag byte plus a 4-byte length, and the outcome carried in trailers as grpc-status. Browsers cannot control HTTP/2 trailers, so web clients use gRPC-Web or Connect.
  • gRPC connections are long-lived and multiplexed, so a layer 4 load balancer pins every call from one client to one backend. Use an L7 proxy, client-side balancing, or proxyless xDS. And set a deadline: gRPC sets none by default.
  • A remote call can time out after the work was done. Retry only idempotent calls or calls that carry an idempotency key. Webhooks are at-least-once: verify the signature, acknowledge fast, dedupe by event id, and never rely on order.

Mental model​

ANALOGYEveryday

Three ways to order food. RPC is ordering by menu number: "number 14, please". You call a named operation and the kitchen runs it. REST is describing the dish you want to a counter that only understands a few verbs: "show me the soup menu", "put this order in the queue", "cancel order 42". GraphQL is a build-your-own-plate form: you tick the exact items and get back one tray with only those. A webhook is the restaurant phoning you when your table is ready, instead of you walking in every five minutes to ask.

Where it breaks: a restaurant never tells you "your meal was cooked, but the waiter dropped the reply". A network does, all the time. The caller of a remote operation can see a timeout while the work completed, and no analogy about menus covers that.

ANALOGYFor engineers

REST is a resource model: URLs are the objects, methods are a tiny fixed interface (GET, PUT, DELETE). gRPC is a generated function stub: you write an interface definition, a compiler emits client and server code, and client.GetUser(req) looks like a local call. GraphQL is a typed query language over a graph, closer to SQL than to a function call: the caller writes the shape of the result. Webhooks are callbacks: you register a function pointer (a URL) and the provider invokes it later.

Where it breaks: "a remote call is a local call with a different transport" is the classic error. Waldo and colleagues argued in A Note on Distributed Computing (Sun Microsystems Laboratories, 1994) that latency, partial failure and concurrency make a remote call fundamentally unlike a local one. A generated stub hides the syntax of the network, not its failures: the call can be slow, can fail, or can succeed while the reply is lost.

How it works​

One operation, three styles​

Take one task: get user 42 together with their orders. The three styles answer it differently.

RESTgRPCGraphQLrequestrequestrequestresponseresponseresponse1. GET /users/422. GET /users/42/ordersPOST /app.UserService/GetUsertype: application/grpcbody: GetUserRequest id=42POST /graphql{ user(id: 42) { nameorders { id total } } }1. the whole user object2. the whole order listfields you never read, tooHTTP/2 200body: one User messagetrailer: grpc-status: 0{"data":{"user":{"name":"Ada","orders":[...]}}}2 round trips, full objects1 round trip, typed binary1 round trip, only chosen fields
Request shape on top, response shape below, one column per style. REST needs two calls and returns whole objects. gRPC makes one typed call. GraphQL makes one call and returns only the named fields. All values are illustrative.

The gRPC version starts from a schema. This is a protocol buffers definition (proto3 language guide):

syntax = "proto3";
package app;
 
service UserService {
rpc GetUser(GetUserRequest) returns (User);
}
 
message GetUserRequest { int32 id = 1; }
 
message User {
int32 id = 1;
string name = 2;
string email = 3;
repeated Order orders = 4;
}

The compiler turns this into a client stub and a server interface in each language, which is why gRPC is popular for polyglot service meshes. The GraphQL version starts from a schema too, written in its own language, and the client query is:

{ user(id: 42) { name orders { id total } } }

A REST API may also offer one call that embeds the orders (an include or expand parameter is a common convention), but that is a design choice of that API, not something the style gives you.

Protocol buffers versus JSON on the wire​

Take the user {id: 42, name: "Ada Lovelace", email: "ada@example.com"} and no orders (an empty repeated field is simply absent on the wire). Protobuf encodes each field as a tag (the varint (field_number << 3) | wire_type, per the encoding guide) followed by the value:

Bytes (hex)Meaning
08 2afield 1, wire type 0 (varint): 42
12 0c + 12 bytesfield 2, wire type 2 (length-delimited): the 12 characters of Ada Lovelace
1a 0f + 15 bytesfield 3, wire type 2: the 15 characters of ada@example.com

That is 2 + 14 + 17 = 33 bytes. The same data as compact JSON is 57 bytes (both counts checked with protoc and wc -c in Hands-on).

JSON (compact)ProtobufgRPC message on the wire
Bytes for this user5733 (about 58 percent of the JSON size)38: protobuf plus the 5-byte message prefix
Field names on the wireyes, every timeno, only numbersno
Readable with curl and your eyesyesno, needs the schema or protoc --decode_rawno
CPU to parsescan text, convert numbersread tags and lengths, no text scanthe same as protobuf

The sizes are computed for this one payload and are illustrative. The gain depends on payload shape: long strings shrink little, many small numeric fields shrink a lot. Whether it is faster end to end depends on language, library and network; the gRPC project keeps a benchmarking guide because the answer is "measure yours".

What a gRPC call looks like on the wire​

The framing is specified in gRPC over HTTP/2. A unary call is one HTTP/2 request and one HTTP/2 response (see HTTP/1.1 and HTTP/2 for streams, HEADERS and DATA frames):

  1. Request headers: :method POST, :scheme https, :path /app.UserService/GetUser (/ + service + / + method, case-sensitive), content-type: application/grpc, te: trailers, and optionally grpc-timeout.
  2. Request data: each message is a length-prefixed message: 1 byte compressed flag (0 or 1), 4 bytes message length (big endian), then the message bytes.
  3. Response headers: :status 200, content-type: application/grpc.
  4. Response data: the reply message, framed the same way.
  5. Trailers: a final HEADERS frame with grpc-status (a digit string, 0 is OK), optionally grpc-message.

Putting the outcome in trailers is what lets a server stream many messages and then report success or failure at the end, after the status line is long gone. It is also why an error is still HTTP 200: the spec says a gRPC error response uses status 200, and the real result is grpc-status. A server that gets a request whose content-type does not start with application/grpc should answer 415.

Four call shapes​

UnaryclientserverrequestresponseServer streamingclientserver1 requestmessage 1message 2message 3Client streamingclientservermessage 1message 2message 31 responseBidirectional streamingclientservermessage Amessage Xmessage Bmessage Y
Each panel is one RPC. Rows go downward in time. The first panel is a normal request and response; the other three keep the same HTTP/2 stream open and send several messages. In the bidirectional panel the two directions are independent, so the interleaving shown is only one possible order.

The shapes are defined in the gRPC core concepts. On the wire they differ only in how many length-prefixed messages each side sends before closing its half of the stream. Push and long-lived channels in general (WebSocket, SSE) are compared on real-time patterns; gRPC streaming is the RPC flavour of the same idea.

Webhooks: the provider calls you​

A webhook is an ordinary HTTP POST that the provider sends to a URL you registered, when an event happens (a payment cleared, a commit was pushed). It replaces polling. The receiver contract is the same everywhere:

provideryour serverqueue and workerPOST e1 + signature503 or timeoutPOST e1 (retry)enqueue e1200 OK, ack fastPOST e1 (duplicate)200 OK, already seenwait (backoff), retryverify signature, e1 unseenworker processes e1 later
The first delivery fails, so the provider waits and retries. The retry succeeds: the server checks the signature and whether it has seen the event id, enqueues the work, and answers 200 right away. If the provider delivers the same event again, the server recognises the id and answers 200 without doing the work twice.
  • Verify the signature before trusting anything. The provider computes an HMAC over the body with a secret you share; you recompute it over the raw body and compare in constant time. The mechanism is explained in Authentication in practice.
  • Acknowledge fast with a 2xx, then do the work asynchronously. Providers time out quickly (see the table in Deep dive).
  • Be idempotent. Delivery is at-least-once, so the same event id can arrive twice. Record handled ids.
  • Never assume order. Stripe states it does not guarantee events arrive in the order they were generated.

Deep dive​

Choosing: the comparison table​

RESTgRPCGraphQLWebhooks
TransportHTTP, any versionHTTP/2HTTP, usually POST with a JSON bodyHTTP POST from the provider to you
Schemaoptional; OpenAPI is a separate documentrequired .protorequired, typed, introspectabledefined by the provider's documentation
Payloadusually JSONprotobuf binary (JSON is allowed by the spec)JSON shaped by the queryusually JSON
Browser supportnative (fetch)needs gRPC-Web or Connectnative (fetch)not applicable: the receiver is a server
Streamingnot in the model; use SSE or WebSocketall four call shapesthe spec defines subscriptions; the transport is up to the implementationno: one event per request
HTTP cachingyes, GET is cacheable (HTTP caching)no: calls are POST; Connect adds GET for side-effect-free callsno by default: queries are POSTno
Best forpublic APIs, CRUD, anything that must work with caches and browsersservice to service, streaming, many languagesmany client types with different data needsbeing told that something happened

The "GraphQL transport" cell reflects that the GraphQL specification defines the language, execution and response format (data and errors), and does not mandate HTTP. REST is an architectural style from Fielding's dissertation; what REST means in practice (methods, status codes, idempotency, the Richardson maturity levels) is on HTTP status codes and semantics, and this page does not repeat it.

Deadlines and timeouts​

A deadline is a point in time past which the caller will not wait. A timeout is a duration. gRPC clients usually set a timeout and the library converts it to a deadline, as the gRPC deadlines guide explains. Three gRPC-specific facts:

  • There is no default deadline. The guide says a client can wait effectively forever and that you should always set a realistic one. A missed deadline fails the call with DEADLINE_EXCEEDED.
  • On the wire it is a duration, not a clock time. The grpc-timeout header holds up to 8 ASCII digits plus a unit letter (H, M, S, m for milliseconds, u, n), so grpc-timeout: 1500m means 1.5 seconds (spec). The library subtracts the time already spent before forwarding, which also shields you from clock skew between machines.
  • Propagation is automatic in some languages. Per the guide it is on by default in Java and Go and must be enabled in C++. A server that calls another server should forward the remaining budget.

Why every hop should share one budget, and how retries interact with it, is owned by timeouts, retries and backoff.

Load balancing gRPC​

gRPC runs over HTTP/2, which uses one long-lived TCP connection with all calls multiplexed on it. A layer 4 balancer chooses a backend once per connection, so every call from that client goes to the same backend. The Kubernetes blog post gRPC Load Balancing on Kubernetes without Tears describes exactly this: once the connection exists, "there's no more balancing to be done". HTTP/1.1 hides the problem because it needs many connections and recycles them.

L4 balancer: pinnedcopies bytes, sees one connectionclientbalancerbackend 1backend 2backend 31 connection6 requestsidleidleone connection, so one backend takes everythingL7 balancer: spreadparses HTTP/2, routes each streamclientbalancerbackend 1backend 2backend 31 connectionreq 1, 4req 2, 5req 3, 6streams from one connection fan out per request
Both clients send six calls over one HTTP/2 connection. The L4 balancer only sees bytes on a connection, so it pins the connection to backend 1 and the others idle. The L7 balancer terminates HTTP/2 and assigns each stream to a backend, so calls spread out. The call numbers are an example.

The gRPC load-balancing post lists the fixes:

  • Proxy (L7) balancing. The balancer terminates HTTP/2, inspects each request and opens its own connections to backends, so streams from one client can be spread. Envoy is a common choice. This is the right pick when "RPC load varies a lot among connections".
  • Client-side balancing. The client library knows the backend list (a thick client) or asks a lookaside balancer, and picks a backend per call. No extra hop, more logic in every client.
  • Proxyless xDS. The gRPC library itself takes balancing configuration from a service-mesh control plane over the xDS API, so there is no sidecar proxy in the path. Support differs by language, so check the gRPC docs for yours.

The same post still recommends L3/L4 when "latency is paramount" or proxy resource use matters more than features. Balancer mechanics (algorithms, health checks, draining) are owned by load balancing, which treats long-lived connections in its own section.

Browsers: gRPC-Web and Connect​

A browser script cannot send arbitrary HTTP/2 frames and cannot read HTTP/2 trailers through fetch, so a page cannot speak native gRPC. Two answers exist:

  • gRPC-Web is a variant of the protocol for browsers. It supports any HTTP version, does not depend on HTTP/2 framing, and encodes the trailers as a last length-prefixed message in the body (the high bit of the flag byte marks "trailers"), per PROTOCOL-WEB. The usual deployment puts a proxy such as Envoy in front that translates to native gRPC, as the gRPC-Web tutorial shows.
  • Connect, announced by Buf on June 1, 2022 (Connect: a better gRPC), is a family of libraries whose protocol makes unary calls plain HTTP requests that browsers and curl can send, while servers can also speak gRPC and gRPC-Web, so one handler serves all three.

Protobuf evolution rules​

The wire format is a sequence of tag-length-value records, so old parsers skip fields they do not know. That makes evolution safe if you follow the rules from the proto3 guide:

  • A field number identifies the field forever. Never reuse one: the wire format cannot tell an old meaning from a new one, and the guide lists parse errors, leaked data and corruption as consequences. When you delete a field, reserved its number (and name) so nobody reuses it.
  • Never renumber fields, even to tidy the file. Renumbering is a delete plus an add for every field involved.
  • Adding a field with a new number is safe. Old readers ignore it, new readers see a default when old writers omit it.
  • Numbers 1 to 15 cost one tag byte, 16 to 2047 cost two, so give the most frequently set fields the low numbers up front. You cannot move them later.

REST has no equivalent of field numbers, so the same discipline becomes "additive changes only": never remove or repurpose a field, add optional ones, and treat a breaking change as a new version.

GraphQL: resolver fan-out and query cost​

"One request" does not mean "one database call". Each field is produced by a resolver, and a naive list resolver runs the user query once and then the orders resolver once per user, the N+1 problem. The standard remedy is batching with a per-request loader such as DataLoader, which collects the ids requested in one tick and issues one query.

Because the client picks the shape, a client can also ask for a very large one. Public GraphQL APIs therefore bound query cost, not request count. GitHub's GraphQL API assigns points to each query and limits the points per time window, and also caps the number of nodes a query may request (GitHub GraphQL rate and node limits). And because a response may carry both data and errors (spec section 7.1), an HTTP 200 alone does not tell you whether the query fully worked.

REST pagination: offset versus cursor​

An offset page says "skip N rows, give me M". It breaks when the list changes between page requests. Newest-first list of ids 100, 99, 98, 97, 96, 95 with page size 3:

StepList at that momentRequestResult
1100, 99, 98, 97, 96, 95offset 0, limit 3100, 99, 98
2someone creates 101: 101, 100, 99, 98, 97, 96, 95offset 3, limit 398, 97, 96
2, with a cursorsame list"after 98", limit 397, 96, 95

The offset client sees 98 twice; a delete between pages would make it skip a row instead. A cursor names the last row seen, so inserts at the head cannot shift the window. Stripe's list endpoints are cursor-based: limit plus starting_after, and a has_more flag (Stripe pagination). GitHub's REST API returns a Link header with rel="next" and rel="last" (using pagination), the format from RFC 8288. Offsets stay fine for small, static, jump-to-page-N lists.

API versioning​

Three common places to put the version, all in production:

  • In the URL: https://api.stripe.com/v1/customers. Visible, easy to route, but a new version forks every URL.
  • In a header with a date: Stripe selects a version with the Stripe-Version header and otherwise uses your account's default (Stripe versioning). GitHub uses X-GitHub-Api-Version; a request without it gets 2022-11-28, and a previous version is supported for at least 24 more months after a new one ships (GitHub API versions).
  • No version, additive only: GraphQL and protobuf lean on adding fields and deprecating old ones, as above.

Retries and idempotency in RPC​

The caller of an RPC that times out cannot know if the work ran, so safe retry needs one of two things: an operation that is idempotent by nature, or an idempotency key that lets the server recognise a repeat. The semantics (which HTTP methods are idempotent, how the key is stored) are owned by HTTP status codes and semantics, and the retry mechanics (backoff, jitter, budgets) by timeouts, retries and backoff. What is specific to gRPC:

  • Retries are configured per method in the service config: maxAttempts, initialBackoff, maxBackoff, backoffMultiplier and a list of retryableStatusCodes, with plus or minus 20 percent jitter applied (gRPC retry guide). The guide's own example retries only UNAVAILABLE, the "the call did not run" code.
  • gRPC has no idempotency annotation that makes a payment safe. If the method creates something, the request message carries the key (for example string idempotency_key = 5;) and the server dedupes on it.

Exactly once is a delivery guarantee networks cannot give. What you can build is at-least-once delivery plus an idempotent handler, whose effect is "once".

Webhook receivers, vendor by vendor​

StripeGitHubSlack Events API
Signature headerStripe-Signature, signed with the endpoint secretX-Hub-Signature-256: sha256= plus the HMAC of the bodysigning secret; see the Slack verification guide
Replay protectiontimestamp tolerance, default 5 minutes in the official librariescompare in constant time; each delivery has an id in X-GitHub-Deliveryrequest timestamp in the signed data
Expects a reply withinquickly, before any complex logic10 seconds3 seconds
On failureretries for up to 3 days with exponential backoff (live mode)the delivery counts as failed; GitHub documents redelivering missed deliveriesretries 3 times: almost immediately, after 1 minute, after 5 minutes
Ordering and duplicatesno ordering guarantee; track event ids to dedupeuse the delivery idretries carry x-slack-retry-num

Sources: Stripe webhooks, GitHub: best practices and validating deliveries, Slack Events API and verifying requests from Slack. Policies change, so check the vendor page before you size a queue around a number. Signing details are the same HMAC idea as in Authentication in practice; the endpoint itself must be served over HTTPS (TLS).

Textbook vs real world​

The textbook saysProduction doesWhy
REST and RPC are rival religions; pick oneREST or GraphQL at the public edge, gRPC between internal services, often in the same companyEdge clients are browsers and third parties who need caches, curl-ability and stability. Internal callers share a codebase culture and benefit from generated stubs and streaming.
GraphQL replaces RESTGraphQL and REST side by side: GitHub runs a REST API and a GraphQL API (v4) over the same dataGraphQL moves complexity to the server: resolver batching, cost limits, and no free HTTP caching. REST stays simpler for plain resources.
gRPC is faster than JSON over HTTPOften yes, but it depends on payload, language and library, and the project itself publishes a benchmarking guideBinary framing saves bytes and parsing; a slow query or a mispinned connection costs far more than the encoding.
RPC makes a remote call look localEvery call carries a deadline, a retry policy and an error modelThe network can be slow, fail, or succeed with a lost reply (see the Waldo note above).
Webhooks deliver each event exactly once, in orderAt-least-once delivery, possible duplicates and reordering; receivers dedupe and fetch current state from the API when order mattersA sender cannot know if your 200 arrived; retrying is the only safe choice, and retries reorder.

In production​

  • Google used a single general-purpose RPC system called Stubby to connect its microservices for over a decade, and in March 2015 decided to build the next version in the open: that became gRPC (gRPC: About).
  • Facebook began GraphQL in 2012 to rebuild its native mobile apps and said in 2015 that it powered almost all data fetching in them, while starting to open-source it (GraphQL: A data query language).
  • GitHub documents a REST API and a GraphQL API (v4) side by side and rate limits the GraphQL one by points (GraphQL rate limits).
  • Stripe pairs a date-versioned REST API with signed webhooks that retry for up to three days (Stripe webhooks).
  • Slack delivers events to your URL with a 3-second reply window and three retries (Events API).

History and evolution​

HISTORY
  • 1984 Birrell and Nelson publish Implementing Remote Procedure Calls: make a call to another machine look like a procedure call. The idea of the stub is born.
  • 1988 Sun RPC is specified in RFC 1057 (revised as RFC 5531).
  • 1994 Waldo et al. argue in A Note on Distributed Computing that treating remote calls as local ones is a mistake, because of latency and partial failure.
  • 2000 SOAP 1.1 (a W3C Note) wraps calls in XML envelopes. The same year, Fielding's dissertation names REST and describes the web's own architecture as the model. What broke: verbose XML and tight coupling between caller and callee.
  • 2008 Google announces protocol buffers on its open source blog: a compact schema-first binary format.
  • 2012 Facebook starts GraphQL for its mobile apps. What broke: thin web views and many round trips over slow phone links (source).
  • 2015 Google open-sources gRPC, the next version of Stubby (about), and Facebook begins open-sourcing GraphQL.
  • 2022 Buf announces Connect to give browsers and curl a way to call gRPC-compatible services.
  • Now REST remains the public default; gRPC and GraphQL are established. Check the gRPC documentation for current language support, since adoption figures are not published as one number.
  • Next The gRPC protocol in the sources above is specified over HTTP/2. Whether HTTP/3 (see QUIC and HTTP/3) or WebTransport become gRPC transports is an open question, not a shipped default.

Hands-on​

REST over plain curl, with jq to pick fields (brew install jq on macOS, apt install jq on Debian or Ubuntu):

curl -s https://api.github.com/users/octocat | jq '{login,id,type}'
{
"login": "octocat",
"id": 583231,
"type": "User"
}

Pagination on GitHub's REST API arrives in the Link header:

curl -s -D - -o /dev/null "https://api.github.com/users/octocat/repos?per_page=2" | grep -i -E "^HTTP|^link|x-github-api-version-selected"
HTTP/2 200
link: <https://api.github.com/user/583231/repos?per_page=2&page=2>; rel="next", <https://api.github.com/user/583231/repos?per_page=2&page=4>; rel="last"
x-github-api-version-selected: 2022-11-28

Look at rel="next" and rel="last" (the cursor-like links), and at the version header that confirms the default. Output varies with the account's data and with time.

The same API's GraphQL endpoint needs a token; unauthenticated calls are rejected:

curl -s -X POST https://api.github.com/graphql -H "Authorization: bearer $TOKEN" -d '{"query":"{ viewer { login } }"}'

A success response has the GraphQL shape {"data":{"viewer":{"login":"<your login>"}}}: the data key mirrors the query.

Protobuf bytes, with protoc (brew install protobuf on macOS, apt install protobuf-compiler on Debian or Ubuntu). Save the User message (without the orders field) as user.proto, then:

echo 'id: 42 name: "Ada Lovelace" email: "ada@example.com"' | protoc --encode=User user.proto > u.bin
wc -c < u.bin
xxd u.bin
protoc --decode_raw < u.bin
33
00000000: 082a 120c 4164 6120 4c6f 7665 6c61 6365 .*..Ada Lovelace
00000010: 1a0f 6164 6140 6578 616d 706c 652e 636f ..ada@example.co
00000020: 6d m
1: 42
2: "Ada Lovelace"
3: "ada@example.com"

08 2a, 12 0c ..., 1a 0f ... are the tags from the table above. To see the gRPC frame, prepend the 5-byte prefix (flag 00, length 00000021 which is 33):

printf '\x00\x00\x00\x00\x21' > frame.bin && cat u.bin >> frame.bin && xxd frame.bin
00000000: 0000 0000 2108 2a12 0c41 6461 204c 6f76 ....!.*..Ada Lov
00000010: 656c 6163 651a 0f61 6461 4065 7861 6d70 elace..ada@examp
00000020: 6c65 2e63 6f6d le.com

38 bytes in total: the five prefix bytes, then the 33 from above. Count the JSON for comparison with printf '{"id":42,"name":"Ada Lovelace","email":"ada@example.com"}' | wc -c, which prints 57.

Talk to a running gRPC server with grpcurl (brew install grpcurl on macOS; Linux builds are on its releases page). These commands need your own server on port 50051 with server reflection enabled, or a -proto file, so no output is shown here because it depends on your services:

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext -d '{"id":42}' localhost:50051 app.UserService/GetUser

Verify a webhook signature yourself. GitHub publishes a test vector: secret It's a Secret to Everybody, payload Hello, World!, expected 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 (validating deliveries):

printf 'Hello, World!' | openssl dgst -sha256 -hmac "It's a Secret to Everybody"
SHA2-256(stdin)= 757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

The label before the digest differs by OpenSSL version ((stdin)= on older builds and LibreSSL); the hex is what matters. Use printf, not echo, so no trailing newline changes the body. To receive a webhook on your laptop, run ngrok http 3000 and register the public URL it prints as the webhook target (this needs an ngrok account and prints a different URL each time).

Gotchas​

GOTCHAS6
gRPC works straight from a browser
A layer 4 load balancer balances gRPC
GraphQL is one request, so it is fast
Webhooks are reliable
A gRPC error shows up as an HTTP 4xx or 5xx
A call with no deadline will eventually fail on its own

Interview questions​

INTERVIEWWhat is the difference between REST and RPC, and what makes an API RESTful?junior
Show answer

RPC calls a named operation (getUser(42), cancelOrder(7)), so the API is a list of functions. REST models resources with URLs and uses a small fixed set of methods on them (GET /users/42, DELETE /orders/7), with status codes for outcomes and stateless requests. In practice an API is called RESTful when it uses nouns, standard methods and status codes; Fielding's original definition also requires hypermedia, which few APIs do.

Follow-ups:

  • What does "stateless" buy you? Any server behind a load balancer can answer any request, so scaling out is easy.
  • Is POST /orders/7/cancel RESTful? It is a verb in a noun's clothing; it is pragmatic and common, and shows where pure REST strains.
  • Where is this covered in more depth? REST semantics are on the HTTP status codes and semantics page.
INTERVIEWWhen would you choose REST, gRPC or GraphQL? What are over-fetching and under-fetching?mid
Show answer

REST for public and browser-facing APIs where caching, curl and stability matter. gRPC for internal service-to-service calls, streaming, and many languages sharing one schema. GraphQL when many clients (a phone, a web app, a watch) need different slices of the same data. Over-fetching is receiving fields you do not use; under-fetching is needing several calls to assemble one screen. GraphQL fixes both by letting the client name the shape.

Follow-ups:

  • What does GraphQL cost the server? Resolver batching to avoid N+1, query cost limits, and no free HTTP caching.
  • Can one company use all three? Yes, and many do: REST or GraphQL outside, gRPC inside.
  • How would you expose gRPC to browsers? gRPC-Web or Connect.
INTERVIEWWhy does gRPC use HTTP/2 and Protocol Buffers, and what do trailers carry?mid
Show answer

HTTP/2 gives multiplexed streams on one connection, so many concurrent and long-lived calls (including streaming) share a connection without head-of-line blocking at the HTTP layer. Protocol buffers give a compact binary encoding generated from a schema, with field numbers that allow evolution. Trailers are headers sent after the body; gRPC uses them for grpc-status (and grpc-message), so a server can stream messages and then report the final outcome.

Follow-ups:

  • Why is the HTTP status 200 on failure? The status line is sent before the outcome is known; the real result is grpc-status in the trailers.
  • What is the message framing? A 1-byte compressed flag and a 4-byte big-endian length before each message.
  • Why can browsers not do this? They cannot read HTTP/2 trailers or control framing through fetch.
INTERVIEWHow do you load balance gRPC, and why does an L4 balancer fail?mid
Show answer

gRPC multiplexes all calls over one long-lived HTTP/2 connection. An L4 balancer picks a backend per connection, so everything from that client lands on one backend and the rest idle. Fix it by balancing per request: an L7 proxy such as Envoy that terminates HTTP/2, client-side balancing where the library picks a backend per call, or proxyless xDS where a control plane configures the client.

Follow-ups:

  • Why does HTTP/1.1 not have this problem? It needs several connections and recycles them, so connection-level balancing is good enough.
  • When is L4 still right? When latency or proxy cost dominates and load per connection is even.
  • What breaks with client-side balancing? Every client needs the backend list and policy, and rolling out changes means updating clients.
INTERVIEWWhat are deadlines, and why propagate them? What about a retry budget?mid
Show answer

A deadline is the point in time after which the caller stops waiting. Propagating it means each downstream call gets the remaining time, so servers stop work nobody is waiting for. gRPC sends the remaining time as a duration in grpc-timeout, which avoids clock skew, and has no default deadline, so you must set one. Retries must fit inside the deadline, and a retry budget caps extra load so retries do not multiply during an outage.

Follow-ups:

  • What status does a missed deadline give? DEADLINE_EXCEEDED.
  • What goes wrong without propagation? Zombie work: servers finish requests whose callers already gave up.
  • Where are retry budgets covered? On the timeouts, retries and backoff page.
INTERVIEWHow do you design a reliable webhook receiver?mid
Show answer

Verify the HMAC signature over the raw body (constant-time compare, and check the timestamp if the provider signs one). Return a 2xx quickly and push the work onto a queue, because providers time out in seconds. Dedupe by event id, because delivery is at-least-once. Do not rely on order: if order matters, fetch the current state from the provider's API.

Follow-ups:

  • What if your endpoint is down for hours? Providers retry on a schedule (Stripe for up to three days); others need you to redeliver, so keep an events page or a reconciliation job.
  • Why the raw body? Re-serialising parsed JSON can change bytes and break the signature.
  • How do you stop a replayed old request? Reject if the signed timestamp is outside a tolerance window.
INTERVIEWHow do you evolve a protobuf or REST API without breaking clients?senior
Show answer

For protobuf: add new fields with new numbers, never reuse or renumber a field, and reserved the numbers and names of deleted fields. Old readers skip unknown fields. For REST: change additively (new optional fields, new endpoints), never remove or repurpose a field, and ship breaking changes as a new version. Versioning goes in the URL (/v1) or a header (Stripe-Version, X-GitHub-Api-Version), with a published support window.

Follow-ups:

  • Why is reusing a field number dangerous? The wire format cannot tell old meaning from new, so decoding silently misreads data.
  • URL or header versioning? URL is visible and easy to route; headers keep URLs stable and allow date-based versions.
  • How do you know when to remove an old version? Telemetry per version and a published deprecation window.
INTERVIEWHow would you retry a payment safely across services?senior
Show answer

Make the operation idempotent. The client generates an idempotency key once per logical payment and sends it on every attempt; the server stores the key with the first outcome and returns the stored result for repeats. Retry only on errors that mean "may not have run" or are transient, with backoff and jitter, inside the caller's deadline. The key must live longer than the longest retry window.

Follow-ups:

  • Is exactly-once delivery possible? Not over an unreliable network; at-least-once plus an idempotent handler gives an exactly-once effect.
  • What if two requests with the same key run at once? The store must make claiming the key atomic, so one request does the work and the other waits or gets a conflict.
  • What does the key protect against? A client timeout after the server succeeded, which would otherwise double-charge.

Fun facts​

FACTSRPC and API trivia
  • The "g" in gRPC changes every release. The project keeps a list where release 1.1 is "good", 1.2 is "green", and so on.
  • GraphQL was three years old inside Facebook before it was public. It began in 2012 and the open-sourcing began in 2015 (source).
  • Numbers 1 to 15 are the cheap protobuf field numbers. They take one byte of tag; 16 to 2047 take two (proto3 guide).
  • gRPC's grpc-timeout is at most 8 digits. That is why the unit letter exists: 99999999n is under a tenth of a second, so long budgets use S, M or H.

Cheat sheet​

NUMBERSNumbers and rules to remember
  • 5 bytes - gRPC message prefix: 1 flag byte plus 4-byte big-endian length
  • 33 vs 57 bytes - the same sample user in protobuf vs compact JSON (illustrative, computed on this page)
  • grpc-status - lives in trailers; HTTP status stays 200
  • No default deadline - set one on every gRPC client
  • 1 to 15 - cheap protobuf field numbers (one tag byte)
  • 10 s / 3 s - GitHub / Slack webhook reply windows
  • 3 days - how long Stripe retries a live-mode webhook, exponential backoff
DECISION

Public, browser or third-party clients: REST (add GraphQL if many clients need different shapes). Internal service-to-service, streaming, polyglot: gRPC. Being notified of events in another system: a webhook, with a signature check, a fast ack and an idempotent handler.

Pick this when: you are choosing an API style for a new interface