Why Pagination Matters in REST API Design
When I was building the backend for AudioBook AI — a platform that now serves 50K+ users — I made a rookie mistake. I loaded 10,000 records into a single API response because I hadn't thought about pagination. The first user complaint came within hours: the app crashed, network requests timed out, and the database melted.
That's when I realized: pagination isn't optional in REST API design. It's fundamental. Whether you're using Node.js backend or Laravel, how you paginate directly impacts your API performance, user experience, and infrastructure costs.
There are two main strategies: offset pagination and cursor-based pagination. Both work. But one scales, and the other doesn't. Let me show you the difference from 8+ years of production experience.
Offset Pagination: The Classic Approach
Offset pagination is what most developers learn first. You ask for a page number and a limit:
GET /api/v1/audiobooks?page=2&limit=20The API then returns:
- Items 21–40 (skipping the first 20)
- Total count
- Current page
Why it's intuitive: Users understand "page 2." It's familiar. You can jump directly to any page.
Why it breaks at scale: To get page 1000, the database must scan and skip the first 19,999 rows. With millions of records, this becomes prohibitively expensive. I learned this the hard way when our EmpSuite ERP platform's report endpoint started taking 30+ seconds.
⚠️ Performance Risk
Offset pagination performs a full table scan from the beginning every request. At 1M+ records, even with indexing, you'll feel the pain.
Cursor-Based Pagination: The Modern Standard
Cursor-based pagination doesn't skip rows. Instead, it remembers where you left off and starts from that point. You get a cursor (typically an encoded string or ID) instead of a page number:
GET /api/v1/audiobooks?limit=20&cursor=eyJpZCI6IDEwMjN9The response includes:
- 20 items
- A
next_cursorfor fetching the next batch - No total count (you don't need it for forward-only navigation)
Why it's superior at scale: The database seeks directly to where the cursor points. No scanning. No skipping. O(1) lookups instead of O(n).
This is what Stripe, GitHub, and Facebook use. At CodeBrew Labs, when we migrated Nova Cabs' ride history API from offset to cursor-based pagination, response times dropped from 2.3s to 140ms.
Implementing Cursor Pagination in Node.js
Here's how I implement cursor-based pagination in a Node.js backend with Express and a relational database:
const express = require('express');
const mysql = require('mysql2/promise');
const Buffer = require('buffer').Buffer;
const app = express();
// Encode cursor: base64(JSON)
const encodeCursor = (id) => {
return Buffer.from(JSON.stringify({ id })).toString('base64');
};
// Decode cursor
const decodeCursor = (cursor) => {
try {
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
return JSON.parse(decoded);
} catch (e) {
return null;
}
};
app.get('/api/v1/audiobooks', async (req, res) => {
const { limit = 20, cursor } = req.query;
const numLimit = Math.min(parseInt(limit), 100); // Cap at 100
try {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
let whereClause = '';
const params = [];
// If cursor exists, start after that ID
if (cursor) {
const decodedCursor = decodeCursor(cursor);
if (!decodedCursor) {
return res.status(400).json({ error: 'Invalid cursor' });
}
whereClause = 'WHERE id > ?';
params.push(decodedCursor.id);
}
// Fetch limit + 1 to check if there's a next page
const query = `
SELECT id, title, author, created_at
FROM audiobooks
${whereClause}
ORDER BY id ASC
LIMIT ?
`;
params.push(numLimit + 1);
const [rows] = await connection.execute(query, params);
const hasMore = rows.length > numLimit;
const items = rows.slice(0, numLimit);
const response = {
data: items,
pagination: {
limit: numLimit,
next_cursor: hasMore ? encodeCursor(items[items.length - 1].id) : null,
},
};
await connection.end();
return res.json(response);
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(3000);Key points in this Node.js implementation:
- We encode the cursor as base64 (safe for URLs)
- We fetch
limit + 1to detect if more pages exist - We use an indexed
ORDER BY idfor fast seeks - No expensive
COUNT(*)query — we just check if the next record exists
Building Cursor Pagination in Laravel
Laravel has built-in cursor pagination support, which I use heavily. Here's how:
<?php
namespace App\Http\Controllers;
use App\Models\Audiobook;
use Illuminate\Pagination\Cursor;
use Illuminate\Pagination\CursorPaginator;
class AudiobookController extends Controller
{
public function index()
{
// Laravel handles encoding/decoding automatically
$audiobooks = Audiobook::query()
->orderBy('id', 'asc')
->cursorPaginate(
perPage: request('limit', 20)
);
return response()->json([
'data' => $audiobooks->items(),
'pagination' => [
'limit' => $audiobooks->perPage(),
'next_cursor' => $audiobooks->nextCursor(),
'prev_cursor' => $audiobooks->previousCursor(),
],
]);
}
}
// routes/api.php
Route::get('/audiobooks', [AudiobookController::class, 'index']);Why Laravel's cursor pagination is elegant:
cursorPaginate()handles cursor encoding/decoding internally- Automatic support for
nextCursor()andpreviousCursor() - Works with Eloquent relationships seamlessly
- Built-in request binding — no manual parsing
If you need more control, you can build custom cursor logic like I did for EmpSuite's complex filtering. But for most REST API design scenarios, this is production-ready out of the box.
API Performance: When to Choose Each Strategy
Here's my decision matrix based on 8 years of production systems:
Use Offset Pagination When:
- Dataset is small (<100K records)
- Random access is critical (e.g., "jump to page 500")
- Users need a total count (e.g., search results: "Showing 1–20 of 4,532")
- You're building internal tools where performance isn't user-facing
Use Cursor Pagination When:
- Dataset is massive (1M+ records)
- Sequential browsing is the norm (feeds, timelines, logs)
- Data changes frequently (cursor prevents duplicates/gaps)
- You're optimizing for mobile (infinite scroll, lower bandwidth)
- Your business depends on response time (e-commerce, real-time systems)
At AudioBook AI, switching to cursor pagination reduced our server costs by 25% because we eliminated expensive
COUNT(*)queries and slow offset scans. That's real money saved.
Common Pitfalls & How to Avoid Them
Pitfall 1: Unordered Results Break Cursors
Your cursor strategy fails if results aren't consistently ordered. Always use a stable, indexed column (usually id or created_at). If you need secondary sort, include it in the cursor:
// Good: primary + secondary sort
const encodeCursor = (id, createdAt) => {
return Buffer.from(
JSON.stringify({ id, created_at: createdAt })
).toString('base64');
};Pitfall 2: Forgetting Index on Cursor Column
If your cursor column (id, created_at) isn't indexed, the database still scans. In your migration:
Schema::create('audiobooks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->index('id'); // Explicit index for cursor
$table->index('created_at');
$table->timestamps();
});Pitfall 3: Exposing Internal IDs in Cursors
If you encode raw IDs, users can infer your scale ("we're at ID 5M?"). Use opaque tokens instead. This is where encoding as base64 helps — it hides the underlying structure.
Pitfall 4: Not Validating Cursor Format
Always validate and sanitize cursors. I've seen APIs crash because a malformed cursor broke JSON parsing. The example code above handles this with try-catch.
📖 Pro Tip
Test your pagination with 10M+ records locally using a database seed. You'll catch performance issues before they hit production. I do this for every new endpoint.
Key Takeaways
- Cursor-based pagination is the modern standard for REST API design. It scales linearly regardless of dataset size, while offset pagination degrades exponentially.
- Use offset pagination only for small datasets (<100K records) or when random page access is critical. For most production REST APIs, cursor pagination is the right choice.
- Always index your cursor column. An indexed
idorcreated_atturns an O(n) scan into an O(1) seek. This is the difference between 2.3s and 140ms responses. - Encode cursors as opaque tokens. Base64-encoding JSON provides security through obscurity and prevents users from guessing internal IDs.
- Test pagination with realistic data volumes. Build cursor pagination into your Node.js backend and Laravel REST APIs from the start. It's not an afterthought — it's foundational to API performance.