Service Interactions
Services need to communicate. The protocol you choose shapes your system's performance characteristics, developer experience, and operational complexity. Each of the dominant patterns serves different needs.
REST
REST (Representational State Transfer) uses HTTP as its transport and JSON as its data format. It's the default for most web APIs — well understood, broadly supported, and easy to debug with standard tools.
Resources are identified by URLs. Operations map to HTTP methods: GET reads, POST creates, PUT replaces, PATCH modifies, DELETE removes. Status codes communicate outcomes — 200 for success, 404 for not found, 429 for rate limiting, 500 for server errors.
REST's strengths are simplicity and ubiquity. Its weaknesses emerge at scale: over-fetching (getting more data than you need), under-fetching (requiring multiple requests to assemble a response), and the lack of a built-in schema or type system. API versioning — via URL paths, headers, or content negotiation — adds complexity as your API evolves.
GraphQL
GraphQL answers REST's over- and under-fetching directly: the client sends a query describing exactly the fields it wants, and the server returns exactly that shape. The server publishes a typed schema defining every object, field, and operation; queries are validated against it before execution. Reads are query operations, writes are mutation operations, and both travel over a single endpoint — conventionally POST /graphql with a JSON body of {query, variables}.
We encountered GraphQL as a consumer building the TraggoMenuApp menu-bar client against Traggo's API. A representative operation:
query TimeSpans($fromInclusive: Time!, $toInclusive: Time!, $cursor: InputCursor) {
timeSpans(fromInclusive: $fromInclusive, toInclusive: $toInclusive, cursor: $cursor) {
timeSpans { id start end note tags { key value } }
cursor { hasMore offset startId pageSize }
}
}
The selection set — the braces after each field — is the contract. Add note and it appears in the response; omit it and it doesn't. One request assembles what REST might need several calls for, and the schema gives you the type safety REST lacks without gRPC's binary transport or code generation. Tooling leans on this: servers expose the schema via introspection, so IDEs and clients can validate queries offline.
Two transport details trip up newcomers, because GraphQL deliberately does not use HTTP semantics for application outcomes:
- Errors arrive in-band. A failed operation still returns HTTP
200; the response body carries anerrorsarray alongside (possibly partial)data. A client that only checks status codes will treat failures as successes. Checkerrorson every response. - The response mirrors the selection set, exactly. Deserialize only the fields you selected. In the Traggo client, decoding a full timespan object from a mutation that selected only
{ id }made every successful delete look like a failure — the decoder threw on the missing fields. The inverse bites too: selecting fewer fields than your response type requires makes every call "fail."
GraphQL's costs sit mostly server-side: resolvers can hide N+1 query patterns, arbitrary client queries make capacity planning harder than fixed REST endpoints, and everything being a POST to one URL defeats HTTP caching (and makes access logs less informative). Pagination is also convention rather than protocol — Traggo uses a cursor object; the Relay "connections" spec is the common alternative — so expect per-API quirks.
For clients, the ecosystem's default is a heavy framework (Apollo, Relay) with codegen from the schema. That's warranted for large schemas and cache-coordinated UIs, but it's not a floor: a GraphQL call is one HTTP POST, and a small API is comfortably served by a hand-rolled typed client — see swiftui for the ~250-line pattern the Traggo app uses.
gRPC
gRPC uses Protocol Buffers (protobuf) to define a strict schema for services and their messages. You write a .proto file describing your service, and code generation produces typed client and server implementations in your language of choice.
The result is faster serialization (binary, not text), built-in type safety, and automatic client library generation. gRPC supports four communication patterns:
- Unary — single request, single response (like REST)
- Server streaming — one request, a stream of responses
- Client streaming — a stream of requests, one response
- Bidirectional streaming — both sides stream simultaneously
gRPC excels for internal service-to-service communication where performance matters and both sides are under your control. It's less suited for public-facing APIs — browsers can't speak gRPC natively (gRPC-Web bridges the gap but adds complexity). Hands-on grounding: the learning-grpc exercise implements Go, Rust, and TypeScript programs against one shared .proto.
The part of protobuf that outlasts any single service — how a .proto contract evolves without breaking deployed readers, and how the same discipline applies to keyed formats like CloudKit records — is schema-evolution.
WebSockets
WebSockets provide a persistent, full-duplex connection between client and server. After an initial HTTP handshake, both sides can send messages at any time without the overhead of establishing new connections.
This makes WebSockets the right choice for real-time features: live dashboards, collaborative editing, chat, and notifications. The connection stays open, and data flows in both directions with minimal latency.
The tradeoffs: WebSocket connections are stateful, which complicates horizontal scaling (you need sticky sessions or a shared state layer). They also require explicit handling of reconnection, heartbeats, and connection lifecycle that HTTP handles implicitly.
Server-Sent Events (SSE) offer a simpler alternative when data flows in only one direction — server to client. SSE uses standard HTTP, supports automatic reconnection, and works through proxies and firewalls more reliably than WebSockets.
Cross-Cutting Concerns
Regardless of protocol, certain patterns apply everywhere:
Idempotency means that sending the same request multiple times produces the same result. This is essential for safe retries — if a network timeout occurs, the client can resend without fear of duplicate side effects. GET, PUT, and DELETE should always be idempotent. For POST, include an idempotency key.
Timeouts prevent one slow service from cascading failures through the system. Every outbound request should have a deadline.
Circuit breakers stop calling a failing service after a threshold of errors, giving it time to recover rather than overwhelming it with requests it can't handle.
Request tracing assigns a unique identifier to each request as it flows through multiple services. When something goes wrong, the trace ID connects logs across the entire call chain. See logging for how we propagate request context through structured log entries, and metrics for the standard HTTP instrumentation that measures these interactions.