Your API Is Slower Than It Needs to Be and Pagination Is Probably Why

by Eric Hanson, Backend Developer at Clean Systems Consulting

The endpoint that seemed fine in development

In development, your database has 200 records. GET /orders returns all of them in 12ms. The developer integrating your API fetches everything in one call and builds their UI. Simple.

Three years later, the database has 4 million records. GET /orders times out. The developer's code has no pagination logic because the endpoint never required it. You have a mutual problem.

This is not hypothetical — it is the standard trajectory for list endpoints that ship without pagination. The fix requires coordinated changes on both sides after the damage is done.

Offset vs. cursor pagination

Offset pagination:

GET /orders?offset=200&limit=50

Intuitive and easy to implement. The database query is LIMIT 50 OFFSET 200.

The problem: at large offsets, the database still scans all preceding rows before returning results. OFFSET 200000 LIMIT 50 on a table of 2 million rows performs a full index scan of 200,050 rows to return 50. On PostgreSQL with a btree index, expect this to degrade roughly linearly with offset size. At offset 1 million on a table of 5 million rows, you are waiting on a scan of 1 million index entries for every request.

Offset pagination also has a consistency problem: if records are inserted or deleted between page requests, offsets shift and you either skip or duplicate records.

Cursor pagination:

GET /orders?limit=50&after=01HZQK7P3WVXBN

The cursor is an opaque token that encodes the position in the result set — typically the ID or a timestamp of the last item returned. The query becomes:

SELECT * FROM orders
WHERE id > '01HZQK7P3WVXBN'
ORDER BY id ASC
LIMIT 50

This is an index seek, not a scan. Performance is constant regardless of cursor depth. At page 100,000, it is as fast as page 1.

The tradeoffs: no random access to page N, no total count without a separate query, and the cursor must encode the sort key. If the client needs "jump to page 47," cursor pagination cannot do it. If the client needs to stream through millions of records efficiently, offset cannot do it reasonably.

Making the cursor opaque

Never expose raw database IDs as cursors. If your cursor is after=42, clients will use it as an integer and build logic around the ID semantics. When you change the sort order or the ID format, you break clients.

Encode the cursor as a base64 string of the underlying value:

import base64
import json

def encode_cursor(last_id: str, last_created_at: str) -> str:
    payload = json.dumps({"id": last_id, "created_at": last_created_at})
    return base64.urlsafe_b64encode(payload.encode()).decode()

def decode_cursor(cursor: str) -> dict:
    payload = base64.urlsafe_b64decode(cursor.encode()).decode()
    return json.loads(payload)

The client treats the cursor string as opaque. You can change the internal representation without breaking the interface.

The response envelope

Include navigation context in every paginated response:

{
  "data": [...],
  "pagination": {
    "limit": 50,
    "has_more": true,
    "next_cursor": "eyJpZCI6IjAxSFpRS..."
  }
}

has_more lets the client know whether to continue fetching without needing a total count. next_cursor is null when there are no more results.

Avoid including a total_count unless you actually need it. COUNT(*) on a large table with filters is expensive. If you include it by default, you are running a count query on every page fetch. Clients rarely need the exact total — they need to know if there is more. has_more serves that need at zero extra cost.

Default limits matter

If your endpoint defaults to no limit, eventually a client will call it without specifying one and get all 4 million records. Add a sensible default limit at the framework level and enforce a maximum:

@app.get("/orders")
def list_orders(
    after: Optional[str] = None,
    limit: int = Query(default=50, ge=1, le=500)
):
    ...

le=500 means the client cannot ask for more than 500 records per page regardless of what they send. Default of 50 means a client that does not specify a limit gets a reasonable number, not everything.

The index you need

Cursor pagination is only fast if the column you are paginating on is indexed. For the query WHERE id > :cursor ORDER BY id LIMIT 50, you need an index on id. For WHERE created_at > :cursor AND account_id = :account ORDER BY created_at LIMIT 50, you need a composite index on (account_id, created_at).

Check EXPLAIN ANALYZE output to confirm your pagination queries are doing index seeks, not sequential scans. An unindexed cursor column gives you the syntax of cursor pagination with the performance of a full table scan.

Retrofit path for existing unbound endpoints

If you have live clients calling unbounded list endpoints today, you cannot suddenly enforce a limit and break them. Add the pagination parameters as optional, apply the limit only when they are present, and use the Deprecation header to signal that the unbounded behavior is going away:

Deprecation: true
Sunset: 2027-01-01
Link: https://api.example.com/docs/pagination; rel="deprecation"

Give clients 12 months. Monitor which clients are still not paginating. Contact them directly. Remove the unbounded path on the announced date.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

What Happens When Nobody Reviews Code

Code reviews are supposed to catch mistakes before they hit production. Skip them, and the consequences pile up quietly—and then suddenly.

Read more

When Your API Integration Explodes in Production

Everything worked fine in testing. Then production hits—and suddenly your API integration turns into a disaster you didn’t see coming.

Read more

When Contractors Are Expected to Work Like Full-Time Staff

“We’ll hire contractors—it’s more flexible and cost-efficient.” Then somehow, those same contractors are treated exactly like employees… just without the benefits.

Read more

Why Junior Contractors Learn the Hardest Lessons First

Starting out as a junior contractor can feel like being thrown into the deep end. The early mistakes sting, but they also teach lessons you won’t forget.

Read more