We shipped an endpoint that returned a list without pagination. It was a small internal API, the list had maybe thirty items in testing, and adding pagination felt like ceremony. Eighteen months later that endpoint served a customer with 62,000 records, the response took ninety seconds, and by then four different clients depended on the un-paginated shape — including a mobile app version we could not force anyone to update.
Fixing it took a new endpoint, a deprecation notice, six months of running both, and a spreadsheet tracking which clients had migrated. The original decision took about ten seconds.
That asymmetry is the thing about API design. Internal code can be refactored on a Tuesday. A published interface is a promise to people who are not in the room, and some of those promises are extremely expensive to take back.
REST or GraphQL: Ask Who the Consumer Is
The comparison is usually framed as a technology argument. In practice it is a question about who is calling you and how much you control them.
REST fits when you have a manageable number of known consumers, resources that map cleanly to nouns, and a desire for boring infrastructure. HTTP caching works out of the box. Every tool, proxy, gateway and monitoring system understands it. A new developer needs no training. Debugging is curl.
GraphQL fits when many different clients need different shapes of the same data, when mobile round trips are hurting you, or when frontend teams iterate faster than you can ship endpoints. Letting a client ask for exactly the fields it needs in one request is a genuine advantage, and it is largest when your clients are diverse and you cannot ship a new endpoint for each of them.
What GraphQL costs, which advocates undersell: HTTP caching mostly stops working, so you build caching at the resolver layer instead. Every nested field is an invitation to an N+1 query problem, so you need DataLoader-style batching from day one rather than as an optimisation. Rate limiting by request count becomes meaningless when one request can be arbitrarily expensive, so you need query complexity analysis. And a public GraphQL endpoint without depth limits is a denial-of-service vector you built yourself.
My default for a product with a web app and a mobile app is REST with a few screen-shaped endpoints — a couple of composite endpoints that return exactly what a specific screen renders. That captures most of GraphQL's practical benefit without the operational surface. I reach for GraphQL when the client count and variety genuinely justify it.
The Decisions You Cannot Undo
These are the ones I now treat as non-negotiable on day one, because retrofitting each of them has cost me weeks.
Pagination on every collection. Every single one. The endpoint that returns three items today returns thirty thousand for someone eventually. Prefer cursor-based pagination over offsets — offsets skip and duplicate records when the underlying data changes between pages, and they get slow at depth.
Versioning from the first release. A prefix in the path is the simplest thing that works. The cost is a few characters; the benefit is having somewhere to put a breaking change when you inevitably need one. This matters most if you have mobile clients, because a version of your app from a year ago is still out there making requests.
Idempotency on writes. Networks retry. Users double-click. Mobile clients on bad connections resend. Accept an idempotency key on anything that creates or charges, store the result against it, and return the stored result on a repeat.
app.post("/v1/payments", async (req, res) => {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json(err("idempotency_key_required"));
const existing = await idempotency.get(key);
if (existing) return res.status(existing.status).json(existing.body);
const result = await createPayment(PaymentSchema.parse(req.body));
await idempotency.put(key, { status: 201, body: result }, { ttl: "24h" });
return res.status(201).json(result);
});
A consistent error shape. Decide once and never deviate. A machine-readable code, a human-readable message, and a field pointing at what was wrong. Clients write logic against error codes; they cannot write logic against prose that changes.
{
"error": {
"code": "validation_failed",
"message": "guests must be between 1 and 12",
"field": "guests",
"request_id": "req_01J8XK2M"
}
}
That request_id is worth more than it looks. When a customer emails "your API returned an error yesterday," it is the difference between finding the exact request and guessing.
Additive change only. Add fields freely; never rename or remove one without a version bump and a deprecation period. Clients parse strictly, sometimes in ways you cannot see.
Consuming Someone Else's API
Half of API work is integration, and integrations fail in ways that are predictable enough to design for in advance.
Wrap every third-party API behind your own interface. Never let a vendor's SDK types leak into your domain code. When you switch payment providers or they change their response shape, the blast radius should be one adapter file. This is a small amount of work up front that has saved me entire sprints.
Assume it will be down. Timeouts on every call — a hung request holding a connection is worse than a fast failure. Retries with exponential backoff and jitter, but only on the operations that are safe to retry. A circuit breaker so that a struggling vendor does not take your service down alongside them.
Handle webhooks properly. Verify the signature, always. Respond immediately and process asynchronously, because slow webhook handlers get disabled by the sender. Expect duplicates, since most providers guarantee at-least-once delivery. Expect out-of-order arrival, so check timestamps before overwriting state.
Log the raw exchange. When a vendor insists they sent the correct data, having the request and response bodies stored is the only thing that ends the argument. Redact the secrets, keep the rest.
What an API Gateway Is Actually For
A gateway earns its place by moving cross-cutting concerns out of every service: authentication, rate limiting, TLS termination, routing, request logging. Do that work once at the edge rather than eleven times inconsistently.
Two cautions. First, do not put business logic in it. Transformations and routing rules configured in a gateway console are invisible to your test suite and your code review, and they become the thing nobody can explain during an incident. Second, a gateway does not make internal calls trustworthy. Services should still authenticate each other — a compromised container inside the network should not have free rein because it is past the gate.
On rate limiting: limit per API key rather than per IP, return the standard headers so clients can see their budget, and respond with 429 and a Retry-After. A well-behaved client that knows its limits generates far less load than one that discovers them by being blocked.
Documentation Is Part of the API
An undocumented endpoint effectively does not exist, and documentation that drifts from behaviour is worse than none — it teaches people wrong things confidently.
Generate the specification from the code where you can, so it cannot drift. If you write schemas at the boundary for validation, you are most of the way to an OpenAPI document already. And include the things reference docs usually omit: what the rate limits are, what happens on retry, which fields are nullable, and a working example for the most common flow.
The Habit Worth Building
Before publishing any endpoint, ask one question: what happens to every existing client if I need to change this in a year?
If the answer is "they break and I cannot make them update," design differently now. Ten seconds of that thought would have saved me six months of running two endpoints in parallel and maintaining a migration spreadsheet — which is, as far as I can tell, the universal tax on API decisions made in a hurry.



