Compare REST, GraphQL and WebSockets. When would you use each?
TL;DR
- REST: one URL per resource, standard HTTP methods. Simple and easy to cache. Default choice.
- GraphQL: one endpoint. The client asks for exactly the fields it needs. Good for complex screens and many client types.
- WebSockets: a long-lived, two-way connection. Good for chat, live prices, multiplayer.
They are not exclusive. Many apps use REST or GraphQL for data and a WebSocket for live updates.
REST
GET /users/42 -> one user
GET /users/42/posts -> that user's posts
POST /posts -> create a post
DELETE /posts/7 -> delete a postvar request = URLRequest(url: baseURL.appending(path: "users/42"))
request.httpMethod = "GET"
let (data, _) = try await session.data(for: request)
let user = try decoder.decode(User.self, from: data)Pros: simple, works with HTTP caching (ETag, Cache-Control), easy to debug with any tool.
Cons: over-fetching (you get fields you do not need) and under-fetching (a screen needs three calls).
GraphQL
One POST /graphql endpoint. The query describes the shape of the answer.
query Profile($id: ID!) {
user(id: $id) {
name
avatarURL
posts(first: 3) { title }
}
}struct GraphQLRequest<V: Encodable>: Encodable {
let query: String
let variables: V
}
var request = URLRequest(url: baseURL.appending(path: "graphql"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(
GraphQLRequest(query: profileQuery, variables: ["id": "42"])
)In real apps, a client like Apollo iOS generates Swift types from your queries. You get type-safe models.
Pros: one request per screen, exact fields, a typed schema, easy to evolve without new versions.
Cons: HTTP caching is harder (everything is a POST to one URL), errors can come with a 200 status, and the server is more complex.
WebSockets
A WebSocket starts as HTTP and upgrades to a two-way connection that stays open.
let socket = URLSession.shared.webSocketTask(with: URL(string: "wss://chat.example.com")!)
socket.resume()
try await socket.send(.string(#"{"type":"join","room":"general"}"#))
func listen() async {
while true {
do {
let message = try await socket.receive()
if case .string(let text) = message { handle(text) }
} catch {
reconnectWithBackoff()
return
}
}
}Things you must handle yourself:
- Reconnection with exponential backoff.
- Heartbeats:
socket.sendPingto detect dead connections. - App lifecycle: iOS suspends your app in the background and the socket dies. Use push notifications for background messages.
- Missed messages: after reconnect, fetch what you missed with a normal request.
Other real-time options
| Option | Direction | Notes |
|---|---|---|
| Polling | Client asks every N seconds | Simple, wasteful |
| Long polling | Server holds the request until data | Works everywhere |
| Server-Sent Events | Server to client only | Plain HTTP. Read it with URLSession.bytes(for:) and lines |
| WebSocket | Both ways | Best for chat and games |
| Push notifications | Server to device | Works when the app is not running |
How to choose
| Need | Pick |
|---|---|
| CRUD app, public API, strong caching | REST |
| Many screens with nested data, several clients (iOS, Android, web) | GraphQL |
| Live two-way updates | WebSocket |
| Live one-way updates (scores, logs, AI token streams) | Server-Sent Events |
Common follow-up questions
- How does GraphQL cache on the client? Apollo has a normalized cache keyed by object ID.
- What is a GraphQL subscription? Real-time updates, usually sent over a WebSocket.
- REST versioning? Put the version in the path (
/v2/) or a header. - How do you stream an AI response? Usually SSE: read
session.bytes(for:)and handle eachdata:line.