DEV Community

Cover image for API Pagination - Offset vs Cursor Pagination Explained
Fazal Mansuri
Fazal Mansuri

Posted on

API Pagination - Offset vs Cursor Pagination Explained

Most APIs don’t return all records at once.

Imagine:

  • Millions of users
  • Thousands of orders
  • Endless social media posts

Returning everything in a single response would:

  • Consume huge memory
  • Slow down queries
  • Increase network payload
  • Crash clients

That’s why APIs use:

Pagination

But here’s where many developers make mistakes:

Not all pagination strategies are equal.

The two most common approaches are:

  • Offset Pagination
  • Cursor Pagination

And choosing the wrong one can create:

  • Slow APIs
  • Duplicate records
  • Missing data
  • Poor user experience

Let’s understand everything properly.


🧠 What is Pagination?

Pagination means:

Splitting large datasets into smaller chunks (pages).

Instead of:

GET /posts
Enter fullscreen mode Exit fullscreen mode

You request:

GET /posts?page=1&limit=10
Enter fullscreen mode Exit fullscreen mode

This improves:

  • Performance
  • Scalability
  • User experience

📦 Offset Pagination

Offset pagination is the most common approach.

Example:

GET /posts?offset=20&limit=10
Enter fullscreen mode Exit fullscreen mode

Meaning:

  • Skip first 20 rows
  • Return next 10 rows

⚙️ SQL Behind It

SELECT * FROM posts
ORDER BY id
LIMIT 10 OFFSET 20;
Enter fullscreen mode Exit fullscreen mode

Simple Go Example - Offset Pagination

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
)

type Post struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

var posts []Post

func init() {
    for i := 1; i <= 100; i++ {
        posts = append(posts, Post{
            ID:    i,
            Title: "Post " + strconv.Itoa(i),
        })
    }
}

func getPosts(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    offset, _ := strconv.Atoi(query.Get("offset"))
    limit, _ := strconv.Atoi(query.Get("limit"))

    if limit == 0 {
        limit = 10
    }

    end := offset + limit

    if offset > len(posts) {
        offset = len(posts)
    }

    if end > len(posts) {
        end = len(posts)
    }

    result := posts[offset:end]

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
}

func main() {
    http.HandleFunc("/posts", getPosts)

    log.Println("Server running on http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

▶️ Run It

go run offset-pagination.go
Enter fullscreen mode Exit fullscreen mode

📡 API Call

curl "http://localhost:8080/posts?offset=20&limit=5"
Enter fullscreen mode Exit fullscreen mode

📦 Response

[
  {
    "id": 21,
    "title": "Post 21"
  },
  {
    "id": 22,
    "title": "Post 22"
  },
  {
    "id": 23,
    "title": "Post 23"
  },
  {
    "id": 24,
    "title": "Post 24"
  },
  {
    "id": 25,
    "title": "Post 25"
  }
]
Enter fullscreen mode Exit fullscreen mode

✅ Advantages of Offset Pagination

  • Simple to implement
  • Easy to understand
  • Supports random page access

Example:

?page=50
Enter fullscreen mode Exit fullscreen mode

❌ Problems with Offset Pagination

This is where things become interesting.

⚠️ Problem 1 — Slow Queries at Scale

Imagine:

LIMIT 10 OFFSET 1000000
Enter fullscreen mode Exit fullscreen mode

Database still scans and skips 1 million rows.

💥 Very expensive.

⚠️ Problem 2 — Duplicate / Missing Records

Suppose user loads:

Page 1
Enter fullscreen mode Exit fullscreen mode

Meanwhile:

  • New rows inserted
  • Old rows deleted

Now user loads:

Page 2
Enter fullscreen mode Exit fullscreen mode

Result:

  • Duplicate rows
  • Missing rows

📉 Visual Problem

Before:

1 2 3 4 5 6 7 8 9 10

User fetches:
OFFSET 0 LIMIT 5

Gets:
1 2 3 4 5

Now new record inserted at top:

0 1 2 3 4 5 6 7 8 9 10

User fetches:
OFFSET 5 LIMIT 5

Gets:
5 6 7 8 9

⚠️ Duplicate "5"
Enter fullscreen mode Exit fullscreen mode

🚀 Cursor Pagination

Cursor pagination solves these problems.

Instead of:

  • Skipping rows

We say:

Give records AFTER this specific item.

Example:

GET /posts?cursor=20&limit=10
Enter fullscreen mode Exit fullscreen mode

Meaning:

  • Give next 10 posts after ID 20

⚙️ SQL Behind It

SELECT * FROM posts
WHERE id > 20
ORDER BY id
LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

Database uses index efficiently.


Simple Go Example — Cursor Pagination

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
)

type Post struct {
    ID    int    `json:"id"`
    Title string `json:"title"`
}

var posts []Post

func init() {
    for i := 1; i <= 100; i++ {
        posts = append(posts, Post{
            ID:    i,
            Title: "Post " + strconv.Itoa(i),
        })
    }
}

func getPosts(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    cursor, _ := strconv.Atoi(query.Get("cursor"))
    limit, _ := strconv.Atoi(query.Get("limit"))

    if limit == 0 {
        limit = 10
    }

    var result []Post

    for _, post := range posts {
        if post.ID > cursor {
            result = append(result, post)

            if len(result) == limit {
                break
            }
        }
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
}

func main() {
    http.HandleFunc("/posts", getPosts)

    log.Println("Server running on http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

▶️ Run It

go run cursor-pagination.go
Enter fullscreen mode Exit fullscreen mode

📡 API Call

curl "http://localhost:8080/posts?cursor=10&limit=5"
Enter fullscreen mode Exit fullscreen mode

📦 Response

[
  {
    "id": 11,
    "title": "Post 11"
  },
  {
    "id": 12,
    "title": "Post 12"
  },
  {
    "id": 13,
    "title": "Post 13"
  },
  {
    "id": 14,
    "title": "Post 14"
  },
  {
    "id": 15,
    "title": "Post 15"
  }
]
Enter fullscreen mode Exit fullscreen mode

✅ Advantages of Cursor Pagination

  • Fast for large datasets
  • Uses indexes efficiently
  • No duplicate/missing rows issue
  • Better for real-time systems

⚠️ Trade-Offs of Cursor Pagination

Not perfect either.

❌ No Random Page Access

You cannot easily jump to:

Page 50
Enter fullscreen mode Exit fullscreen mode

Need sequential navigation.

❌ More Complex Implementation

Need:

  • Stable sorting
  • Unique cursor
  • Cursor encoding sometimes

🧠 What Companies Usually Do


🟢 Offset Pagination

Used for:

  • Admin panels
  • Internal dashboards
  • Small datasets

🟣 Cursor Pagination

Used for:

  • Social media feeds
  • Infinite scroll
  • Messaging apps
  • Large-scale APIs

🔥 Real-World Example

Instagram / Twitter Feed

Do you think they use:

OFFSET 5000000
Enter fullscreen mode Exit fullscreen mode

❌ No chance.

They use cursor-based pagination.


⚡ Performance Difference

Offset Pagination

OFFSET 1000000
Enter fullscreen mode Exit fullscreen mode

DB scans huge rows ❌


Cursor Pagination

WHERE id > 1000000
Enter fullscreen mode Exit fullscreen mode

Direct index lookup ✅


🔐 Important Cursor Pagination Rule

Cursor field must be:

  • Indexed
  • Unique
  • Stable

Usually:

  • ID
  • Created timestamp

⚠️ Common Developer Mistakes

❌ Using OFFSET for infinite scroll

Bad at scale.

❌ Cursor based on non-unique column

Can create duplicates.

❌ Forgetting stable ordering

Pagination becomes inconsistent.


🧠 Key Mental Model

Offset Pagination

“Skip N rows”

Cursor Pagination

“Continue after this row”


📊 Quick Comparison

Feature Offset Pagination Cursor Pagination
Easy implementation
Random page access
Large dataset performance
Infinite scroll
Duplicate/missing record safety

🏁 Final Thoughts

Pagination seems simple.

But poor pagination strategy can:

  • Slow down APIs
  • Break user experience
  • Create inconsistent data

🎯 Key Takeaways

  • Offset pagination is simple but doesn’t scale well
  • Cursor pagination is efficient and production-friendly
  • Infinite scroll systems should prefer cursor pagination
  • Always use indexed & stable cursor fields

Good API design is not just about returning data.

It’s about returning data efficiently — even at massive scale.

Top comments (0)