HTTP RPC WebSockets and API Contract Design
Purpose: choosing the right interaction shape
HTTP, RPC, WebSockets, and API contracts are not competing buzzwords; they are different ways to shape communication between services and clients. In system design, the useful question is not “Which one is modern?” but “What behavior must the caller and provider agree on?” A product catalog lookup, a checkout command, a mobile notification stream, and a warehouse device feed each want different latency, caching, ordering, and evolution properties. This lesson teaches how these mechanisms work, what an API contract actually pins down, and how to choose among them without accidentally creating a brittle distributed system.
The outcome is practical: given a workflow, you should be able to describe the transport, message shape, state model, error semantics, and compatibility rules. You should also be able to spot when a design is using WebSockets for a request-response workload, HTTP resources for action-oriented commands, or RPC without a versioning plan.
How the mechanisms work internally
HTTP is a request-response protocol. A client opens or reuses a connection, sends a request line or pseudo-headers, headers, and an optional body. The server returns a status code, headers, and an optional body. HTTP methods express broad intent: GET retrieves, POST submits or creates subordinate work, PUT replaces, PATCH partially changes, and DELETE removes. Intermediaries such as browsers, gateways, load balancers, and CDNs understand these semantics. That is why HTTP APIs can use caching, conditional requests, redirects, authentication headers, compression, and standardized status codes.
RPC, or remote procedure call, makes a network operation look like invoking a named function. The contract centers on method names, request messages, response messages, and error codes. RPC can run over HTTP, HTTP/2, raw TCP, or another transport. gRPC is a common example: clients call methods defined in a service schema, messages are serialized in a compact binary format, and HTTP/2 streams carry calls concurrently over one connection. RPC fits command-heavy service-to-service APIs where the natural vocabulary is verbs: ReserveInventory, PriceCart, or StartTranscodingJob.
WebSockets start with an HTTP upgrade request and then switch the connection into a long-lived, full-duplex channel. After the handshake, either side can send frames without waiting for a new HTTP request. A frame may contain text, binary data, ping, pong, or close control information. WebSockets fit collaborative editing, live dashboards, games, trading views, chat presence, and device telemetry where server-to-client pushes must happen quickly and repeatedly. They also introduce connection lifecycle work: heartbeats, reconnects, backpressure, fanout, ordering, and routing a client to a server that holds its socket.
An API contract is the precise agreement that allows independently deployed code to communicate. For HTTP APIs it includes paths, methods, headers, query parameters, request and response schemas, status codes, idempotency rules, pagination, authentication, rate limits, and compatibility promises. For RPC it includes service and method names, message fields, field numbering or names, deadlines, retryability, and error details. For WebSockets it includes handshake authentication, message envelope, event names, sequencing, acknowledgements, reconnect behavior, and close codes.
API anatomy
A clear HTTP contract separates resource identity from representation. In GET /orders/ord_123, the path identifies an order, while the response body represents the order at that moment. Headers carry metadata such as ETag, Authorization, Content-Type, and Idempotency-Key. Status codes tell the client what class of outcome occurred. A 404 says the resource is absent or not visible, 409 signals a conflict with current state, 429 tells the client it is being throttled, and 503 says capacity or dependency health is preventing success.
POST /v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer token
Idempotency-Key: 7b5f6f2c-7c7b-4a2d-81e4-6e3c1d6a43a1
Content-Type: application/json
{"customer_id":"cus_42","sku":"chair-1","quantity":2}
RPC anatomy usually starts with a service definition. The method name carries the action, the request message names inputs, and the response message names outputs. The important design move is to keep the method business-specific while making errors machine-actionable. A generic FAILED_PRECONDITION is less useful than an error detail saying inventory was already reserved by a newer cart version.
WebSocket anatomy needs an envelope because a single connection carries many logical message types. A stable envelope normally includes type, request_id or event_id, a payload object, and sometimes a sequence number. The sequence number lets the client detect gaps after reconnecting; the request identifier lets it match asynchronous acknowledgements to commands it sent earlier.
Worked example 1: HTTP resource lookup
For a public catalog, HTTP resource design is usually the simplest fit. The client asks for a product representation, and the server can mark it cacheable for a short period. The expected behavior is deterministic: a missing product returns 404, an existing product returns 200 with JSON, and a matching conditional request can return 304 Not Modified without a body.
def catalog_response(product_id, catalog, client_etag=None):
product = catalog.get(product_id)
if product is None:
return 404, {}, {"error": "product_not_found"}
etag = f'W/"{product["version"]}"'
if client_etag == etag:
return 304, {"ETag": etag}, None
return 200, {"ETag": etag, "Cache-Control": "max-age=60"}, product
catalog = {"chair-1": {"sku": "chair-1", "price_cents": 4999, "version": 8}}
print(catalog_response("chair-1", catalog)[0])
print(catalog_response("chair-1", catalog, 'W/"8"')[0])
print(catalog_response("missing", catalog)[0])
The output is 200, then 304, then 404. This design benefits from HTTP semantics because caches and clients already know what those outcomes mean.
Worked example 2: RPC command with idempotency
Inventory reservation is action-oriented. Modeling it as POST /inventory/reservations is reasonable in HTTP, but inside a service mesh it may read more naturally as an RPC method called ReserveInventory. The key contract detail is idempotency: if the client retries after a timeout, the server must return the original reservation instead of double-reserving stock.
def reserve_inventory(request, reservations, stock):
key = request["idempotency_key"]
if key in reservations:
return {"status": "already_reserved", "reservation_id": reservations[key]}
sku = request["sku"]
qty = request["quantity"]
if stock.get(sku, 0) < qty:
return {"status": "rejected", "reason": "insufficient_stock"}
stock[sku] -= qty
reservation_id = f"res_{len(reservations) + 1}"
reservations[key] = reservation_id
return {"status": "reserved", "reservation_id": reservation_id}
stock = {"chair-1": 2}
reservations = {}
request = {"sku": "chair-1", "quantity": 2, "idempotency_key": "cart-99-attempt-1"}
print(reserve_inventory(request, reservations, stock))
print(reserve_inventory(request, reservations, stock))
print(stock)
The first call reserves inventory; the retry returns already_reserved; stock remains zero rather than negative. Whether exposed through gRPC or HTTP, the contract must state that the idempotency key scope is the reservation command and that the stored result is replayed for duplicate keys.
Worked example 3: WebSocket event stream
A delivery tracking screen should not poll every second if thousands of customers are watching orders move. A WebSocket lets the server push location changes. The contract below uses an envelope with a monotonically increasing sequence. The client can apply events in order and request a snapshot if it sees a gap.
{"type":"delivery.location.updated","event_id":"evt_101","order_id":"ord_123","sequence":41,"payload":{"lat":40.741,"lng":-73.989}}
Expected client behavior is simple: if the last sequence was 40, apply event 41. If the last sequence was 39, do not guess what happened to 40; fetch a fresh HTTP snapshot and resume from the returned sequence. This combines HTTP for state repair with WebSockets for low-latency incremental updates.
def apply_event(last_sequence, event):
if event["sequence"] == last_sequence + 1:
return "apply", event["sequence"]
if event["sequence"] <= last_sequence:
return "ignore_duplicate", last_sequence
return "resync_required", last_sequence
print(apply_event(40, {"sequence": 41}))
print(apply_event(39, {"sequence": 41}))
print(apply_event(41, {"sequence": 41}))
The output is ('apply', 41), ('resync_required', 39), and ('ignore_duplicate', 41). The design is reliable because it assumes long-lived connections can drop and gives the client a deterministic recovery path.
Design choices and trade-offs
Choose resource-oriented HTTP when the domain has stable nouns, cacheable reads, browser or third-party clients, and useful standard semantics. It is easier to inspect with ordinary tools, easier to put behind gateways, and easier to evolve with additive JSON fields. The trade-off is that action-heavy workflows can become awkward if every business operation is forced into a noun.
Choose RPC when the API is mostly internal, method calls map closely to domain operations, low-latency binary encoding matters, or strongly generated clients reduce mistakes. The trade-off is tighter coupling: renaming methods, changing field meanings, or adding required inputs can break callers quickly. RPC also requires discipline around deadlines and retries, because a procedure call can hide the fact that the network is unreliable.
Choose WebSockets when the server must push frequent events to the client with low latency. The trade-off is operational complexity. HTTP requests are brief and easy to load balance. WebSockets consume memory for connection state, need heartbeat policies, and often require sticky routing or a shared pub/sub layer so any backend can publish to any connected user.
Contracts should evolve additively whenever possible. Add optional response fields before requiring request fields. Keep old enum values meaningful, and define what clients should do with unknown enum values. Version only when behavior changes incompatibly; otherwise, versioning can create unnecessary parallel APIs that drift.
Failure modes and troubleshooting
A common HTTP symptom is clients retrying checkout and creating duplicate orders. The likely cause is a timeout after the server committed the order but before the client received the response. Diagnose by searching request logs for repeated payloads without an idempotency key and by comparing order creation timestamps. Correct it by requiring an idempotency key on create commands and storing the response by key for a bounded period.
An RPC symptom is rising latency and thread exhaustion during a dependency incident. The cause is often missing deadlines: callers wait too long, queues grow, and retries amplify load. Diagnose by checking per-method latency histograms, deadline metadata, and retry counts. Correct it with explicit deadlines, bounded retries with jitter, and error codes that distinguish retryable unavailability from business rejection.
A WebSocket symptom is users seeing stale dashboards after a deploy. Causes include dropped sockets without reconnect logic, a load balancer idle timeout shorter than the heartbeat interval, or missed events while reconnecting. Diagnose by inspecting close codes, heartbeat timing, reconnect attempts, and sequence gaps in client telemetry. Correct it by sending ping/pong or application heartbeats more frequently than the idle timeout, implementing exponential backoff reconnects, and using sequence-based resync.
A contract symptom is a mobile app crashing after a new enum value appears. The provider treated an additive change as safe, but the client used exhaustive parsing without an unknown fallback. Diagnose with client crash logs and response samples. Correct the client to tolerate unknown values and update the contract to state unknown enum handling explicitly.
Security, performance, and reliability implications
For HTTP and RPC, authenticate every request and authorize the specific resource or operation. Do not rely on path opacity; /orders/ord_123 must be checked against the caller. For WebSockets, authenticate during the handshake and re-check authorization for subscriptions, because one socket may request many channels. Rotate credentials carefully: a connection established with an expired token should either be closed with a clear code or required to refresh through an agreed message.
Performance depends on payload size, connection reuse, and fanout. HTTP caching can remove entire reads from the origin. RPC binary messages can reduce serialization cost, but only if client generation and schema governance are in place. WebSocket fanout needs backpressure; if one client cannot read fast enough, the server must buffer within a limit, drop low-priority events, or close the connection. Unlimited per-socket queues turn a slow client into a memory problem.
Hands-on lab: design and test a mixed contract
Prerequisites: a terminal with Python 3, the ability to run a local script, and a text editor. No external services are required.
- Create a small contract for delivery tracking with one HTTP snapshot operation:
GET /orders/{id}/tracking. - Define a WebSocket event envelope containing
type,order_id,sequence, andpayload. - Save the following verifier as a local Python file and run it.
def verify_tracking_contract(snapshot, events):
required_snapshot = {"order_id", "sequence", "location"}
if set(snapshot) < required_snapshot:
return "invalid_snapshot"
sequence = snapshot["sequence"]
for event in events:
if event["order_id"] != snapshot["order_id"]:
return "wrong_order"
if event["sequence"] != sequence + 1:
return "resync_required"
sequence = event["sequence"]
return "ok"
snapshot = {"order_id": "ord_123", "sequence": 7, "location": {"lat": 40.0, "lng": -73.0}}
events = [
{"type": "delivery.location.updated", "order_id": "ord_123", "sequence": 8, "payload": {"lat": 40.1, "lng": -73.1}},
{"type": "delivery.location.updated", "order_id": "ord_123", "sequence": 9, "payload": {"lat": 40.2, "lng": -73.2}},
]
print(verify_tracking_contract(snapshot, events))
Verification: the script should print ok. Change the second event sequence from 9 to 11; the verifier should print resync_required. Change an event order id to a different order; it should print wrong_order. Cleanup is simply deleting the temporary file. If this were a deployed API, rollback would mean continuing to serve the old envelope fields while adding new optional fields behind a compatibility test.
Assessment exercises
- A payment client times out after submitting
POST /payments. Design the idempotency behavior, including key scope, stored result, and what happens if the same key is reused with a different amount. - You are designing a live auction page. Which parts should use HTTP, which should use WebSockets, and what sequence or acknowledgement fields would you require?
- An internal service currently exposes
UpdateUserover RPC. A new caller wants partial updates and retries. Specify the request fields and error semantics that prevent lost updates. - A public mobile API needs to add a new shipment status. What contract language and client behavior make this change compatible?
- A WebSocket service drops connections every 60 seconds through a load balancer. List the diagnostics and the contract or infrastructure change you would make.
Summary
HTTP works best when standard request-response semantics, resource identity, and intermediaries are assets. RPC works best when service operations are action-oriented and generated contracts help internal callers move quickly. WebSockets work best when the server must push ordered, low-latency events over a long-lived connection. Strong API contract design makes the chosen mechanism explicit: message anatomy, errors, retryability, idempotency, compatibility, and recovery behavior are part of the design, not afterthoughts.
