Stop Writing Bad APIs: 10 REST API Mistakes Developers Keep Making

I walk through ten REST API mistakes that keep showing up in real codebases — wrong methods, missing pagination, no idempotency, leaking errors — and show the concrete fix for each.

Stop Writing Bad APIs: 10 REST API Mistakes Developers Keep Making

Most REST API mistakes aren’t complex. I’ve seen the same ten come up in code reviews, in API integrations, in debug sessions where someone spends an hour wondering why a retry doubled a payment or why an error response shape changed between versions. These aren’t edge cases — they’re the standard pattern when developers treat HTTP as a delivery pipe rather than what it actually is: a communication protocol with defined semantics.

This post goes through ten of them. Each one has a concrete fix. None require a new library or a major refactor. Some take five minutes to correct.

Series: This post builds on Building a REST API with Node.js and Express — the foundation for what we’re improving here.

On this page

The method mistakes: GET, POST, and naming

Mistake 1 — Using GET for operations that change state.

RFC 9110 defines GET as safe, meaning it should not modify server state. The spec is direct: “the client does not request, and does not expect, any state change on the origin server as a result of applying a safe method.” When a GET handler deletes a record, caches, bots, and browser prefetch tools can trigger it without intent. Googlebot crawls links. Prefetch preloads them. Both will fire your deletion.

The common form is routes like GET /delete-user?id=42 or GET /trigger-email. It’s convenient to wire up — no request body needed — but the method semantics say read-only.

Use GET to read, POST to create, PUT to replace, PATCH to update partially, DELETE to remove. That’s not convention for its own sake — it’s the contract clients, proxies, and intermediaries depend on.

Mistake 2 — Inconsistent naming conventions.

/getUsers, /user_list, /Users, and /fetchUserById/42 in the same API create a scavenger hunt for everyone who integrates with it. Pick one style and hold to it. Lowercase, hyphen-separated nouns are the most widely used and easiest to read:

GET /users
GET /users/42
GET /user-groups
POST /payment-methods
DELETE /payment-methods/9

Nouns, not verbs. Resources, not actions. The method already describes the action.

Status codes that mislead clients

Mistake 3 — Returning 200 for everything.

A pattern I’ve seen more than once: the server returns 200 OK with a body of { "success": false, "error": "Validation failed" }. The client has to inspect the body to know whether anything worked. SDKs, retry middleware, and caches all key off the status code, not the body — they’ll treat your error as a success.

RFC 9110 is specific: 400 Bad Request applies when “the server cannot or will not process the request due to something that is perceived to be a client error.” 422 Unprocessable Content — now part of the core HTTP spec, no longer WebDAV-only — applies when the syntax is valid but the semantics aren’t (a well-formed JSON body that fails business validation, for example).

The mapping worth keeping close:

Status When to use it
200 Request succeeded
201 Resource created — include a Location header
400 Request is malformed or missing required data
401 Not authenticated
403 Authenticated but not authorized
404 Resource not found
409 Conflict with current state
422 Valid format, semantically invalid
429 Rate limit exceeded
500 Server-side error

Return the code that describes what actually happened, not the one that’s easiest to set.

Missing contracts: versioning and error shapes

Mistake 4 — No versioning.

You ship /users, everything integrates, and six months later you rename a field, remove a property, or change how pagination works. Every client that integrated against the original response breaks silently or noisily depending on how they parse JSON.

Versioning isn’t about expecting failure — it’s about making change possible without breaking existing consumers.

URL path versioning is the most common approach and the easiest to reason about:

GET /v1/users
GET /v2/users

It’s explicit, shows up in logs and error reports, and doesn’t require clients to manage custom headers. The alternative — Accept: application/vnd.api; version=2 — works but is harder to test manually and less visible. Either is fine. The mistake is skipping it entirely before your first external consumer.

Mistake 5 — Inconsistent error shapes.

Some APIs return { "error": "not found" }. Others return { "message": "User 42 was not found", "code": "USER_NOT_FOUND" }. The client ends up writing different error-handling logic for every endpoint.

A consistent shape is all that’s needed:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Email is required",
    "path": "body.email"
  }
}

The exact field names don’t matter as much as using the same structure everywhere. If every error looks the same, clients can write error handling once and reuse it. This is also where a centralized error handler pays for itself — I covered that pattern in centralizing error handling in Node.js.

Collections and writes: pagination and idempotency

Mistake 6 — Returning all records for collections.

GET /users returning 80,000 rows is a performance incident waiting to happen. It also forces the client to receive everything when it only wants to render a page. Collections need pagination from day one.

A straightforward shape that works well:

{
  "data": [],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 80000,
    "hasMore": true
  }
}

Offset-based pagination (?page=2&limit=20) is simple and works well for stable datasets. Cursor-based pagination handles frequently updated collections better: offset shifts when records are inserted, so clients can see duplicates or skip rows between pages. For a simple CRUD API, offset is usually fine to start.

Mistake 7 — No idempotency on state-changing endpoints.

RFC 9110 defines idempotent methods as those where “multiple identical requests… have the same effect” as a single one. PUT and DELETE are idempotent. POST is deliberately not — that’s the point. The problem surfaces when a POST creates a payment or an order, the network drops before the client gets the response, and the client retries. Two records created. Two charges.

The standard fix is an idempotency key the client generates and sends with the request:

POST /payments
Idempotency-Key: 7f3a2b1c-84d0-4e5f-b6c7-8d9e0f1a2b3c
Content-Type: application/json

The server stores the key alongside the result. When the same key arrives again — a retry, a duplicate request — it returns the stored result instead of creating another record. This is the pattern payment processors use to prevent double-charges. It takes one database lookup and one extra index to implement.

Security shortcuts that cost you later

Mistake 8 — Unprotected sensitive endpoints.

An admin route without auth middleware because “the frontend doesn’t link to it” is not protected. Any tool that can send an HTTP request can reach it — and that’s every HTTP client ever written. Auth middleware should be on every route that needs it. Absence of a UI entry point is not a security control.

The production Express project structure post has a clean pattern for organizing middleware so auth can’t be accidentally omitted.

Mistake 9 — No rate limiting.

Without rate limiting, a single client can flood the API with requests, enumerate accounts by trying credential combinations, or trigger expensive operations as fast as the network allows. The 429 Too Many Requests status exists for this situation. Add rate limiting before you expose an API externally, not after the first incident.

For the implementation: rate limiting typically sits in middleware, close to the router layer. The pattern is the same whether you’re using a token bucket, a sliding window, or a fixed window counter.

Mistake 10 — Leaking internal details in error responses.

This one shows up in production more than it should:

{
  "error": "TypeError: Cannot read properties of undefined (reading 'id')\n    at getUserById (/app/src/routes/users.js:42:15)\n    at Layer.handle [as handle_request]..."
}

That response hands an attacker your file structure, line numbers, stack frames, and sometimes database query fragments. The full error belongs in your server logs, not in the response body.

The fix is a centralized error handler that logs the original error, then returns a sanitized version to the client:

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err); // full error goes to logs
  res.status(500).json({
    error: {
      code: "INTERNAL_ERROR",
      message: "Something went wrong. Please try again.",
    },
  });
});

In Next.js route handler design, I covered how the same principle applies to API routes in the Next.js App Router.

What most of these mistakes have in common

Ten mistakes in one post makes for a useful checklist. The underlying pattern is simpler: most of these come from treating HTTP as a transport layer — a way to move bytes — rather than a communication protocol with defined semantics.

HTTP gives you method semantics, a standardized status code vocabulary, and decades of agreed-upon contracts that clients, caches, and tools already know how to work with. Using them correctly costs almost nothing. Ignoring them costs debugging sessions, broken integrations, and the kind of silent failures that are hard to trace after the fact.

Build the API you’d want to integrate yourself. Use the right methods. Return the right status codes. Make errors consistent. Add auth and rate limiting before your first external consumer. Handle idempotency on anything that creates records. Keep internal errors in logs, not responses.

Pick the two or three that matter most to your current codebase and fix those first. The rest follow naturally.

Sources