DEV Community

Cover image for Designing Scalable APIs: 10 Things Most Developers Miss
Abiodun Paul Ogunnaike
Abiodun Paul Ogunnaike

Posted on

Designing Scalable APIs: 10 Things Most Developers Miss

Building a REST API is relatively straightforward.

You define routes, create controllers, validate requests, interact with a database and return JSON responses.

But designing an API that remains reliable as the application grows is a different challenge.

An API that works perfectly with 100 records might struggle with 100,000 records. An endpoint that works under normal network conditions might create duplicate transactions when clients retry failed requests.

The difference between an API that works and one that is production-ready often comes down to decisions made during its design.

In this article, I'll explore 10 important considerations developers should think about when building scalable APIs, using Laravel and PHP examples where appropriate.

1. Idempotency: What happens when a request is repeated?

Consider a payment API.

A client sends a payment request, but the network connection times out before receiving a response.

The client doesn't know whether the payment succeeded, so it retries the request.

What happens?

If your API processes the request again, the customer could be charged twice.

This is where idempotency becomes important.

An idempotent operation produces the same intended result when repeated with the same request.

For critical operations, an API can accept an idempotency key:

POST /api/payments
Idempotency-Key: 8f2a9c10-payment-001
Enter fullscreen mode Exit fullscreen mode

Your backend should:

  1. Check whether the key has already been processed.
  2. Return the previously stored result if it has.
  3. Otherwise, process the operation.
  4. Store the result against the key.

In Laravel, this can be implemented using database records and unique constraints.

Importantly, checking the key and reserving it must be concurrency-safe. A simple lookup followed by an insert is not sufficient when two identical requests arrive simultaneously.

Lesson: Don't assume clients will only send a request once.

2. Database query efficiency

An API can return the correct data and still be inefficient.

Consider this Laravel example:

$users = User::all();

foreach ($users as $user) {
    echo $user->posts;
}
Enter fullscreen mode Exit fullscreen mode

If there are 100 users, accessing their posts can result in 101 database queries.

This is the N+1 query problem.

A better approach:

$users = User::with('posts')->get();
Enter fullscreen mode Exit fullscreen mode

However, eager loading is only one part of database optimization.

You should also consider:

  • Appropriate database indexes.
  • Selecting only required columns.
  • Avoiding unnecessary joins.
  • Inspecting slow queries.
  • Understanding execution plans.
  • Avoiding loading more records than necessary.

Remember that database performance directly affects API response times.

3. Pagination: Don't return everything

Imagine building a products endpoint:

public function index()
{
    return Product::all();
}
Enter fullscreen mode Exit fullscreen mode

This might be fine with 20 products.

What happens when the application has 100,000?

You could end up with:

  • Excessive memory consumption.
  • Large response payloads.
  • Longer database execution times.
  • Increased network usage.
  • Poor client-side performance.

Use pagination instead:

public function index()
{
    return Product::query()
        ->select(['id', 'name', 'price'])
        ->paginate(20);
}
Enter fullscreen mode Exit fullscreen mode

For datasets where users frequently navigate through large numbers of records, cursor pagination may be more appropriate:

Product::query()
    ->orderBy('id')
    ->cursorPaginate(20);
Enter fullscreen mode Exit fullscreen mode

Choose the pagination strategy based on the access pattern and dataset.

4. Authentication is not authorization

This is one of the most important security considerations.

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to do?

Imagine a user accessing:

GET /api/orders/105
Enter fullscreen mode Exit fullscreen mode

The user is authenticated.

But does that mean they own order 105?

Not necessarily.

A common security vulnerability is Broken Object Level Authorization (BOLA).

In Laravel, you can use policies:

public function show(Order $order)
{
    $this->authorize('view', $order);

    return new OrderResource($order);
}
Enter fullscreen mode Exit fullscreen mode

The policy should verify that the authenticated user is permitted to access that particular order.

Never rely on authentication alone to protect resources.

5. Rate limiting and abuse prevention

An API should not accept unlimited requests without restrictions.

Consider an endpoint responsible for sending OTP codes.

Without rate limiting, an attacker could repeatedly request OTPs, potentially increasing costs and abusing the service.

Laravel provides rate-limiting capabilities:

Route::middleware('throttle:10,1')
    ->post('/otp/send', [OtpController::class, 'send']);
Enter fullscreen mode Exit fullscreen mode

This example limits requests to 10 per minute under the applicable limiter configuration.

For production systems, consider different limits for:

  • Authentication endpoints.
  • Public APIs.
  • Expensive operations.
  • Payment operations.
  • Administrative endpoints.

Rate limits should reflect the sensitivity and resource cost of each operation.

6. Consistent error responses

An API should communicate failures predictably.

Consider these responses:

{
    "message": "Something went wrong"
}
Enter fullscreen mode Exit fullscreen mode

And:

{
    "message": "Validation failed",
    "errors": {
        "email": [
            "The email field is required."
        ]
    }
}
Enter fullscreen mode Exit fullscreen mode

The second response provides more useful information to API consumers.

Use appropriate HTTP status codes:

Status Meaning
200 Successful request
201 Resource created
204 Successful request with no response body
400 Invalid request
401 Unauthenticated
403 Forbidden
404 Resource not found
422 Validation failed
429 Too many requests
500 Internal server error

Avoid exposing database credentials, stack traces, internal paths or sensitive implementation details.

Consistency makes frontend integration and debugging easier.

7. Timeouts, retries and external dependencies

Modern applications rarely operate in isolation.

Your API might communicate with:

  • Payment gateways.
  • Email providers.
  • SMS services.
  • External authentication systems.
  • Other internal services.

What happens when one of these services becomes unavailable?

A poorly designed application might wait indefinitely or repeatedly retry failed requests.

Use sensible connection and request timeouts.

For example:

$response = Http::connectTimeout(3)
    ->timeout(10)
    ->get($externalUrl);
Enter fullscreen mode Exit fullscreen mode

For retryable operations:

$response = Http::retry(3, 200)
    ->timeout(10)
    ->get($externalUrl);
Enter fullscreen mode Exit fullscreen mode

However, retries should be used carefully.

Retrying a non-idempotent payment request without appropriate safeguards can create duplicate transactions.

For long-running operations, consider queues instead of making the client wait for everything to finish.

8. Observability: Can you understand what happens in production?

Logging that an error occurred is useful.

Knowing which request caused it, which user was affected and which downstream service failed is much more useful.

Consider adding:

  • Structured application logs.
  • Request or correlation IDs.
  • Error monitoring.
  • Response-time metrics.
  • Database query monitoring.
  • Queue failure monitoring.

A correlation ID helps you follow a request across multiple components.

For example:

Request ID: req-8f2a9c

API received request
Payment service called
Database transaction started
Payment provider timeout
Request failed
Enter fullscreen mode Exit fullscreen mode

Without sufficient observability, debugging production issues becomes guesswork.

9. API versioning and backward compatibility

Your API will evolve.

You might rename fields, change response structures or introduce new business rules.

But what happens to existing clients?

Imagine changing:

{
    "first_name": "Paul"
}
Enter fullscreen mode Exit fullscreen mode

to:

{
    "name": "Paul"
}
Enter fullscreen mode Exit fullscreen mode

An existing mobile application expecting first_name might break.

API versioning provides a way to manage changes:

/api/v1/users
/api/v2/users
Enter fullscreen mode Exit fullscreen mode

Not every change requires a new version.

The important thing is to distinguish backward-compatible additions from breaking changes.

Think about API consumers before changing existing contracts.

10. Database transactions and concurrency

Imagine two users attempting to purchase the last available item simultaneously.

Both requests check the stock.

Both see one item available.

Both proceed with the purchase.

Now you've sold an item that only existed once.

This is a concurrency problem.

A database transaction alone does not automatically prevent every race condition.

You need appropriate locking or atomic database operations.

For example, in Laravel:

DB::transaction(function () use ($product) {
    $lockedProduct = Product::query()
        ->lockForUpdate()
        ->findOrFail($product->id);

    if ($lockedProduct->stock < 1) {
        throw new RuntimeException(
            'Product is out of stock.'
        );
    }

    $lockedProduct->decrement('stock');

    // Create the order here.
});
Enter fullscreen mode Exit fullscreen mode

The row lock prevents competing transactions from simultaneously modifying the same stock record without coordination.

For more complex workflows, consider unique constraints, optimistic locking and appropriate transaction boundaries.


Bringing everything together

A scalable API isn't created by adding microservices, Redis or Kubernetes simply because an application is growing.

It starts with sound engineering decisions.

Before considering infrastructure upgrades, ask yourself:

  • Can my API handle duplicate requests safely?
  • Are my database queries efficient?
  • Am I returning only the data clients need?
  • Are my resources properly protected?
  • Can I identify and debug production failures?
  • What happens when an external dependency becomes unavailable?
  • Can I evolve the API without breaking existing clients?
  • Can concurrent requests corrupt business data?

These questions are just as important as writing clean controllers and defining RESTful routes.

Final Thoughts

I've come to appreciate that backend engineering is about much more than making endpoints work.

It's about understanding how data moves through a system, anticipating failure scenarios and making decisions that keep the application reliable as complexity increases.

A good API serves a request. A well-engineered API also anticipates what can go wrong.

What is one API design consideration you think developers frequently overlook?

I'd be interested in hearing your experiences.

PHP #Laravel #BackendDevelopment #SoftwareArchitecture #RESTAPI

Top comments (0)