Skip to content
ExponaBLOG
ELASTICSEARCH & SEARCH ENGINEERING · DAY 026 / 100

Pagination in Elasticsearch: what developers should know

from/size, search_after and PIT.

Expona Admin6 min read

Pagination in Elasticsearch: what developers should know

Pagination is one of those features that seems straightforward until you're dealing with millions of documents and users clicking through results faster than your query performance allows. In Elasticsearch, the pagination strategy you choose directly affects latency, memory consumption, and whether your API will survive peak load. This guide walks through the three main pagination approaches—from/size, search_after, and Point in Time (PIT)—so you can pick the right one for your use case instead of discovering performance issues in production.

From and size — Simple, limited to 10,000 results by default

The from/size approach is the first pagination method most developers reach for because it mirrors SQL's OFFSET/LIMIT pattern. It's intuitive: skip the first N results, return the next M. Request page 5 with 20 items per page, and Elasticsearch skips 80 documents and retrieves 20.

Here's what that looks like in practice:

json
{
  "from": 80,
  "size": 20,
  "query": {
    "match": {
      "title": "nodejs performance"
    }
  },
  "sort": [{ "timestamp": "desc" }]
}

The appeal is real. The API is minimal, stateless on the server side, and works fine for small result sets. But Elasticsearch applies a hard limit: by default, from + size cannot exceed 10,000. This means you cannot request from: 10000, size: 20 without raising the index.max_result_window setting, which brings memory problems with it.

Why? Each shard must materialize and sort all documents up to from + size, then discard everything before from. On a five-shard index, requesting page 500 (from: 9980) forces each shard to evaluate 10,000 documents, consuming heap. Multiply that across concurrent requests, and you're looking at out-of-memory errors or significant GC pauses.

When to use from/size: Small result windows (pages 1–10), internal dashboards with low concurrency, or when you know users rarely paginate beyond the first few pages. E-commerce product listings typically stay within this sweet spot.

Search_after — Cursor pagination with sort values

search_after solves the deep pagination problem by using the sort values of the last document as a cursor. Instead of skipping rows, you tell Elasticsearch "give me the next 20 documents after this sort key." This is stateless for the client (the cursor is just data) yet avoids materializing large datasets.

Here's the flow: your first request includes no search_after, gets back 20 results, and captures the sort values from the last document. The next request includes those sort values in search_after, and Elasticsearch knows exactly where to resume.

A realistic example from a feed-like system:

json
{
  "size": 20,
  "query": {
    "bool": {
      "filter": [
        { "range": { "published_at": { "gte": "2024-01-01" } } }
      ]
    }
  },
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ]
}

First response includes a document with "published_at": "2024-03-15T14:22:10Z" and "_id": "doc-789" at the tail. Next request:

json
{
  "size": 20,
  "search_after": ["2024-03-15T14:22:10Z", "doc-789"],
  "query": {
    "bool": {
      "filter": [
        { "range": { "published_at": { "gte": "2024-01-01" } } }
      ]
    }
  },
  "sort": [
    { "published_at": "desc" },
    { "_id": "asc" }
  ]
}

Note the inclusion of _id in the sort. This is important: if two documents share the same sort value (common with timestamps), you need a tiebreaker to ensure consistent ordering across requests. Otherwise, a document might appear twice or be skipped.

search_after is memory-efficient because it doesn't skip documents—each request only evaluates what it needs to return. It scales to millions of results without hitting the 10,000 ceiling. The tradeoff is that the client must manage cursor state, and if your sort key has duplicates, you must design carefully to avoid gaps.

When to use search_after: Large or unbounded result sets, infinite scroll UIs, real-time feeds, or anywhere you need to paginate beyond 10,000 results without changing configuration.

Point in time — Consistent views across pages

Here's a subtle problem: between requests, documents might be added, deleted, or updated. A user paging through results on Monday might see the same document twice if a reindex happened mid-session. Or they might miss a document entirely. For most applications this is acceptable noise, but for compliance-sensitive exports or internal audits, consistency matters.

Point in Time (PIT) is Elasticsearch's solution. You open a PIT, which gives you a consistent view of the index at a specific point in time. All subsequent requests using that PIT read from the same underlying index state, even if new segments commit or documents change.

The workflow:

  1. Open a PIT with a keep-alive window (e.g., 5 minutes).
  2. Issue your first search using that PIT ID.
  3. Use search_after with the PIT for subsequent pages.
  4. Close the PIT when done.

Here's an example from a data export scenario:

json
POST /orders/_pit?keep_alive=5m
{}

Response:

json
{
  "id": "nBoWFW1BaFd6RThQbzV3"
}

Now issue your first search:

json
{
  "size": 1000,
  "query": {
    "match_all": {}
  },
  "pit": {
    "id": "nBoWFW1BaFd6RThQbzV3",
    "keep_alive": "5m"
  },
  "sort": [
    { "_shard_doc": "asc" }
  ]
}

For PIT requests, use _shard_doc as your primary sort. It's optimized for this use case and doesn't require you to manage custom sort fields. Continue with search_after using the PIT, and each page is guaranteed to read from the same logical index state.

When to use PIT: Exports, bulk operations, API pagination where consistency across multiple requests is required, or auditing scenarios where a user must see a frozen snapshot.

Avoid scroll for users — Scroll is for exports, not UI

Scroll is an older Elasticsearch feature that kept a search context alive on the server. For many years, developers used it for pagination. Don't. Scroll holds resources on every node in the cluster and degrades performance under load. Elasticsearch itself warns that scroll is for batch processing, not real-time pagination.

If you're using scroll in a user-facing application, migrate to search_after or PIT immediately. Scroll's only legitimate use case is bulk exports where you're exporting the entire index to a file or data warehouse—a one-time, batch operation, not a repeated API call.

The modern approach: use PIT for exports (which keeps a lightweight point-in-time reference, not a full search context) and search_after for everything else.

Common mistakes

  • Forgetting a tiebreaker in sort: If your primary sort has duplicates, add _id or another unique field. Without it, results can shift between requests and cause duplicate or skipped documents.
  • Raising max_result_window excessively: This doesn't fix the problem; it redistributes it to GC and memory pressure. Use search_after or PIT instead.
  • Keeping PITs open indefinitely: PITs consume memory. Set a reasonable keep_alive and close them explicitly when done. Monitor your cluster's open PIT count.
  • Using scroll in APIs: Scroll contexts live for minutes on the server. In a multi-tenant SaaS or high-concurrency system, they'll exhaust your cluster. Replace scroll with PIT.
  • Not testing with realistic data volume: from/size feels fast on a 1,000-document index. Test with your actual data size. Deep pagination performance can surprise you.

Key takeaways

  • from/size is simple and correct for shallow pagination (pages 1–10). Beyond that, performance degrades and you'll hit the 10,000 result window limit.
  • search_after with cursor-based pagination scales to unlimited result sets and is the default choice for user-facing APIs and feeds. Always include a tiebreaker in your sort clause.
  • Point in Time provides a consistent snapshot across paginated requests, essential for exports and compliance scenarios. Use it with search_after for guaranteed consistency.
  • Avoid scroll in production. It's for batch exports only. Use PIT instead, which achieves the same consistency with better resource efficiency.
  • Always profile your pagination strategy with realistic data volume. What works at 10,000 documents may fail at 100 million.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles