DEV Community

Sir Max
Sir Max

Posted on

5 REST API Mistakes That Cost Me Users — and How to Fix Them

I Used to Think REST APIs Were "Just JSON Over HTTP" — Then I Lost Users

Three years ago, I built my first public API. It was a simple CRUD backend for a task tracker. I shipped it in a weekend, called it "done," and waited for users.

They came. And then they left.

Not because the product was bad — because the API was painful to use. Here are the five mistakes I made, how I discovered them, and what I changed.


1. Returning 200 OK for Everything — Even Errors

If you've ever received this response, you know the pain:

HTTP 200 OK

{
  "success": false,
  "message": "Something went wrong. Try again."
}
Enter fullscreen mode Exit fullscreen mode

I did this because it was "simpler." My frontend team hated me for it. Every HTTP client in existence knows what 401 means. It retries 503 automatically. It caches 304. But 200 with success: false? That's custom logic for every integration.

The fix: Use proper HTTP status codes. 400 for bad input. 401 for auth failures. 404 for missing resources. 422 for validation errors. 429 for rate limits. Your API consumers will thank you — silently, because things will just work.

HTTP 422 Unprocessable Entity

{
  "error": "validation_error",
  "message": "Email is required",
  "details": [
    {"field": "email", "reason": "missing"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

2. No API Versioning (Because "I'll Never Change It")

Spoiler: I changed it. Two weeks after launch.

A customer built an integration that parsed our response format field-by-field. When I renamed user_name to username, their integration broke. They emailed me at 11 PM on a Friday. I learned about versioning the hard way.

The fix: Prefix your routes with /v1/. It costs nothing upfront and saves you from breaking existing clients when you need to evolve.

# Bad
@app.get("/users")

# Good
@app.get("/v1/users")
Enter fullscreen mode Exit fullscreen mode

You don't need to maintain ten versions. Keep two — current and one previous. Deprecate old ones with a Sunset header and a clear migration guide.


3. No Rate Limiting — "My Server Can Handle It"

It couldn't.

A user's buggy script hammered our search endpoint at 200 requests per second. Our database connection pool maxed out. Everyone else got timeouts. I didn't even know it was happening until users reported it.

The fix: Rate limiting is not optional. A simple token-bucket approach is enough for most APIs:

import time
from collections import defaultdict

class RateLimiter:
    def __init__(self, max_requests=100, window_seconds=60):
        self.max = max_requests
        self.window = window_seconds
        self.buckets = defaultdict(list)

    def is_allowed(self, client_id: str) -> bool:
        now = time.time()
        cutoff = now - self.window
        self.buckets[client_id] = [
            t for t in self.buckets[client_id] if t > cutoff
        ]
        if len(self.buckets[client_id]) >= self.max:
            return False
        self.buckets[client_id].append(now)
        return True
Enter fullscreen mode Exit fullscreen mode

Return 429 Too Many Requests with a Retry-After header when they hit the limit. Most HTTP clients handle this automatically.


4. Inconsistent Error Responses

My error format changed depending on which part of the code threw the exception:

// From the auth middleware:
{ "error": "Invalid token" }

// From the user controller:
{ "status": "error", "msg": "Email already exists" }

// From the database layer:
{ "code": 500, "detail": "Connection refused" }
Enter fullscreen mode Exit fullscreen mode

Consumers had to write three different error parsers. One for each layer.

The fix: Pick one error shape and use it everywhere. I settled on this:

{
  "error": {
    "code": "resource_not_found",
    "message": "User with id 'abc123' not found",
    "details": null
  }
}
Enter fullscreen mode Exit fullscreen mode

Every endpoint, every error type, same structure. One if (response.error) check in the client code handles everything.


5. No Pagination (or: "How I Returned 50MB of JSON")

My /users endpoint returned all users. Every single one. When we hit 10,000 users, the response was 8MB. Mobile clients literally crashed.

The fix: Cursor-based pagination is simpler than you think:

@app.get("/v1/users")
def list_users(limit: int = 50, after: str = None):
    query = "SELECT * FROM users"
    params = []
    if after:
        query += " WHERE id > %s"
        params.append(after)
    query += " ORDER BY id ASC LIMIT %s"
    params.append(limit + 1)

    rows = db.execute(query, params).fetchall()
    has_more = len(rows) > limit
    results = rows[:limit]

    return {
        "data": results,
        "pagination": {
            "has_more": has_more,
            "next_cursor": results[-1]["id"] if has_more else None
        }
    }
Enter fullscreen mode Exit fullscreen mode

Cursor-based beats offset-based every time: it's stable under inserts, faster for large datasets, and the cursor can be opaque (no leaking internal IDs unnecessarily).


What Changed After Fixing These

I didn't add new features. I didn't redesign the product. I just made the API less painful to use. Within a month:

  • Integration time for new partners dropped from 3 days to 4 hours
  • Support tickets about "the API doesn't work" dropped by 70%
  • One of those early users who left? They came back

The lesson isn't "use HTTP properly" — it's that your API is your product's real interface. A beautiful frontend means nothing if the API behind it fights your users at every turn.

What's the worst API design mistake you've encountered? I'd love to hear about it in the comments.

Top comments (0)