The Deep Pagination Problem in Elasticsearch
Why asking for page 5,000 of your search results is far more expensive than asking for page 1, what Elasticsearch does to protect itself, and how to move through large result sets safely using scroll, point in time, and search after.
Imagine a huge library with ten branches, each holding a different part of the collection. A visitor walks up to the main desk and says, “Please give me the books ranked 99,991 to 100,000 in your list of most popular titles.” To answer, the head librarian cannot simply look at one shelf. She must ask every branch for its top 100,000 books, gather all those lists on her desk, sort the giant pile, throw away 99,990 books, and hand over ten. The visitor gets ten books, but the librarian did a thousand times more work than for the first ten. This is exactly the deep pagination problem in Elasticsearch.
1What Is Deep Pagination?
Deep pagination means asking for a page of results that lies very far from the beginning of the sorted list.
When a search matches thousands or millions of documents, you almost never want all of them at once. You want a small window, such as ten or twenty results at a time. Moving from one window to the next is called pagination. Page one shows results one to ten, page two shows eleven to twenty, and so on.
Shallow pagination, such as pages one to five, is cheap and works beautifully in Elasticsearch. Deep pagination is when the requested page is far down the list, for example page ten thousand. The problem is that the cost of a page in Elasticsearch does not depend only on how many results you want. It also depends on how far down the list the page starts. The deeper you go, the more work the cluster must do to give you the same ten results.
Think of a very long queue at a railway ticket counter. Serving the first person is instant. But if you ask, “Show me person number 50,000,” someone must first walk the whole queue and count 49,999 people. The person you asked for is one person, yet the effort is almost the whole queue.
In Elasticsearch, the price of a page grows with the position where the page starts, not just with the size of the page.
This guide explains why this happens, what safety rules Elasticsearch uses to stop you from hurting yourself, and which better tools exist when you truly need to walk through a big result set.
2The Basics: From and Size
Ordinary pagination in Elasticsearch uses two simple settings in a search request.
The size setting says how many results to return in this response. Its default is ten. The from setting says how many results to skip before starting to return. Its default is zero. So a request with from zero and size ten returns results one to ten. A request with from ten and size ten returns results eleven to twenty.
This is called offset pagination, because you give an offset, which is how many results to skip. Web developers love it because it maps directly to page numbers. Page number times page size, minus one page, gives the offset. It is also how most databases with an offset clause behave.
| Page | Page Size | From Value | Results Shown |
|---|---|---|---|
| 1 | 10 | 0 | 1 to 10 |
| 2 | 10 | 10 | 11 to 20 |
| 10 | 10 | 90 | 91 to 100 |
| 1,000 | 10 | 9,990 | 9,991 to 10,000 |
| 10,000 | 10 | 99,990 | 99,991 to 100,000 |
The simplicity is the trap. From the outside, page one thousand looks identical to page one: same size, just a different number. But inside the cluster the two requests are very different in cost, as the next chapters will show.
Asking for “the tenth chapter of a book” is easy because chapters have bookmarks. Asking for “the ten-thousandth word” is different, because nobody has a bookmark there, and someone must count. Offset pagination is counting words.
3Search Is Distributed: Shards and the Coordinating Node
To see why deep pages hurt, we must first remember how a search travels through a cluster.
An Elasticsearch index is divided into shards. Each shard holds a slice of the documents and works like a small independent search engine. Because documents are spread over shards, any search must ask every relevant shard for its best results, because the true top ten could be hiding in any shard.
The request first lands on one node, which becomes the coordinating node for this request. Its job is to send the query to all shards, collect their answers, merge them into one final ordered list, and reply to the client. The coordinating node does not hold the data for this. It only organizes the merge.
flowchart TD
CL["Client asks for page of results"] --> CO["Coordinating node"]
CO --> S1["Shard 1: its own best results"]
CO --> S2["Shard 2: its own best results"]
CO --> S3["Shard 3: its own best results"]
CO --> S4["Shard 4: its own best results"]
CO --> S5["Shard 5: its own best results"]
S1 --> M["Merge into one sorted list"]
S2 --> M
S3 --> M
S4 --> M
S5 --> M
M --> R["Return the requested page"]
A newspaper wants the ten best photos from a contest that was judged by five separate panels. Each panel only saw its own entries. The editor must collect each panel’s finalists and choose the overall best ten. The editor is the coordinating node, and each panel is a shard.
This distributed design is what makes Elasticsearch scale. But it also means that no single shard knows what the global position of any document is. Only the merge step can decide that, and that is where deep pagination gets expensive.
4Query Then Fetch: The Two Phases of a Search
A search runs in two stages. Understanding them shows exactly where the extra work appears.
The normal search type is called query then fetch. In the first stage, the query phase, each shard runs the query and builds a small ranked list of the best matches. Each entry in this list is light: it holds only the document identifier and the values needed for sorting, such as the score. The heavy content of the document is not included.
The shard sends this light list to the coordinating node. The coordinating node merges the lists from all shards and works out the final order. Then comes the second stage, the fetch phase. The coordinating node asks only the shards that own the winning documents to send their full content, such as the source. Only the documents of the final page are fetched.
sequenceDiagram
participant C as Client
participant Co as Coordinating Node
participant S as Each Shard
C->>Co: Search with from and size
Co->>S: Query phase: send me your top from plus size entries
S->>S: Build ranked list of ids and sort values
S->>Co: Return light list of entries
Co->>Co: Merge all lists, skip from, keep size
Co->>S: Fetch phase: send full documents for the winners
S->>Co: Return the documents
Co->>C: Return the page
Notice a very important detail. In the query phase, each shard is asked for from plus size entries, not just size. That is the key to the whole problem, and we look at it closely next.
The fetch phase is cheap even for deep pages, because only the final ten or twenty documents are fetched. The expensive part is always the query phase.
5Why Deeper Pages Cost More: The Priority Queue
Every shard must keep a sorted waiting list that is as long as the offset plus the page size.
Suppose you ask for the page that starts at position ten thousand with size ten. A shard cannot know whether its own best result is close to the global top or far away. The only safe way is to provide its best ten thousand and ten results. Otherwise, if all the global results ranked 10,001 to 10,010 happened to live on one shard, that shard would fail to supply them.
So each shard builds a priority queue, which is a sorted list that always keeps the best entries seen so far, with a length of from plus size. The shard scans all matching documents, and for each one it decides whether it belongs among the current best entries. A longer queue means more comparisons and more memory.
Then all shards send their full lists to the coordinating node. The coordinating node builds its own priority queue with the combined entries and picks out the requested slice. It must hold entries from all shards at the same time.
flowchart LR
subgraph SH["Each of the shards"]
Q1["Queue of size from plus size"]
end
SH -->|"sends from plus size entries"| CQ["Coordinating queue: shards times from plus size entries"]
CQ -->|"skip first from entries"| OUT["Keep only size entries"]
Ten panels of judges must each send their best 100,000 photos, so the editor can be safe about which ones rank 99,991 to 100,000 overall. The editor receives a million photos, sorts them, and keeps ten. All the rest of the effort was needed for safety, but produced no output.
Deep pagination wastes work in proportion to the offset. The work is repeated in full for every new page, because the cluster does not remember anything from the previous request.
6A Worked Example With Real Numbers
Numbers make the problem impossible to ignore. Let us count how many entries the cluster must handle for different pages.
Take an index with five shards and a page size of ten. The table shows how many entries each shard must produce and how many entries the coordinating node must merge for a few different offsets.
| Offset (from) | Entries per Shard | Entries Merged at Coordinator (5 shards) | Entries Returned |
|---|---|---|---|
| 0 | 10 | 50 | 10 |
| 100 | 110 | 550 | 10 |
| 1,000 | 1,010 | 5,050 | 10 |
| 5,000 | 5,010 | 25,050 | 10 |
| 9,990 | 10,000 | 50,000 | 10 |
Now see what happens when the number of shards grows. The offset is the same, at 9,990, and the page size is ten.
| Number of Shards | Entries per Shard | Entries Merged at Coordinator |
|---|---|---|
| 5 | 10,000 | 50,000 |
| 20 | 10,000 | 200,000 |
| 50 | 10,000 | 500,000 |
| 100 | 10,000 | 1,000,000 |
The cost has two multipliers. One is the depth, which is the offset. The other is the number of shards. A cluster with many shards suffers more from deep pagination, since every shard must send a full list.
Work at the coordinating node is roughly the number of shards times the offset plus the page size. Double either factor, and the work doubles.
7Where the Cost Lands: CPU, Memory, Network, and Pauses
The extra work is not abstract. It shows up as real pressure on several resources at once.
Processor Time
Each shard compares every matching document against a long queue, and the coordinating node sorts a very large merged list. Both use processor cycles that other searches could have used.
Heap Memory
Priority queues live on the Java heap of the data nodes and of the coordinating node. Large queues from many concurrent deep requests can fill the heap quickly.
Network
Every shard sends a long list of entries to the coordinating node. Even though each entry is small, hundreds of thousands of them add up to real traffic.
Garbage Collection
Big short-lived structures create heavy garbage. The Java process must pause to clean it, and long pauses make the node slow or even seem dead to the cluster.
The worst part is that the pressure spreads. A single expensive deep request on a coordinating node can slow all other requests on the same node. A few such requests at once can trip the memory protection circuits of the node, which then start refusing other requests too. Users who only wanted page one suddenly get errors, even though the guilty request was for page ten thousand.
Imagine one customer at a bank counter who asks the clerk to count all coins in the vault to find the ten-thousandth coin. The clerk is busy for an hour, and everyone else waiting in line for a simple deposit gets delayed as well.
Deep pagination is a classic noisy neighbor problem. One heavy user or one crawler can reduce the quality of service for every other user of the cluster.
8The Safety Fence: max_result_window
Elasticsearch does not let you walk off the cliff. It puts up a fence at ten thousand results.
Every index has a setting called index.max_result_window. Its default is 10,000. The rule is simple: the sum of from and size must not be larger than this value. A request for from 9,990 and size 10 is allowed, because the sum is exactly 10,000. A request for from 10,000 and size 10 is refused with an error that says the result window is too large.
The error message also tells you what to do. It points you to the scroll API for large data sets and to the search after feature for deep pagination, and it mentions that you could raise the setting. This message is the product’s way of teaching you the correct approach at the moment you hit the problem.
| Request | From plus Size | Result |
|---|---|---|
| from 0, size 10 | 10 | Allowed |
| from 9,990, size 10 | 10,000 | Allowed, at the limit |
| from 10,000, size 10 | 10,010 | Rejected with a result window error |
| from 0, size 20,000 | 20,000 | Rejected, size alone is too large |
Related windows
The same idea protects other features. There is a limit for how many top documents a rescoring step may work on, and a limit for the number of inner hits returned inside a parent result. Each limit exists to keep the amount of hidden work per request within reason.
The fence at the edge of a cliff does not stop you from enjoying the view. It stops you from a fall that hurts everybody who has to rescue you. Ten thousand is a generous safe distance for almost every human use.
Real people almost never look beyond the first few pages of results. A search that needs users to click through ten thousand results is usually a search that needs better filters or better ranking.
9Why Not Just Raise the Limit?
It is possible to increase the window. It is rarely a good idea.
The setting can be changed per index, and many developers do so the moment they see the error. It feels like a quick fix. But raising the limit only removes the fence. It does not remove the cliff. The cost still grows with the offset, and now there is nothing to stop a single request from asking for a million entries per shard.
Consider what happens if you set the window to one million. A single request with from 999,990 on an index of twenty shards asks the cluster to produce twenty million entries, hold them in memory at the coordinating node, and sort them. A handful of such requests, whether from a buggy client, a curious user, or an automated crawler, can consume the whole heap and take the node down.
When a Small Increase Can Be Acceptable
- The index is small, with few shards and small documents
- Only a trusted internal tool uses the deep pages
- Traffic is very low and predictable
- You have measured the memory and latency effect
Why It Is Usually Harmful
- Every user or bot can trigger very expensive requests
- Memory use grows with shards times the window
- Latency of every page grows, not just the deep ones
- It hides a design problem instead of solving it
Problem
The team sets the maximum result window to a very large number so that the application can show any page number without errors.
Why It’s Harmful
A single request for a deep page can use enormous memory. Automated crawlers that walk through page numbers can then bring the cluster down.
Correct Approach
Keep the default limit, and use a cursor-based approach for deep traversal. Limit the visible depth in the application.
10Sorting, Ties, and Bouncing Results
Pagination has a second, quieter problem. The pages themselves may not agree with each other.
Offset pagination assumes that the sorted list stays the same between requests. Page one and page two come from two separate searches. If anything changes between them, results can be shown twice or skipped. Three things commonly cause this.
New or Changed Documents
If a new highly ranked document is indexed between the request for page one and page two, everything shifts down by one. The last result of page one now appears again as the first result of page two. If a document is deleted, one result may be skipped entirely.
Ties in the Sort Value
When many documents share the same sort value, for example the same score or the same date, their relative order is not guaranteed to be the same in each request. A document could appear at the end of one page and again at the start of the next.
Different Copies of the Same Shard
Each shard usually has replica copies. Two copies can have slightly different internal segments because they were merged at different times, which can shift scores slightly. If request one is answered by the primary and request two by a replica, the results may differ. Users see this as results bouncing around from one page load to the next.
Imagine reading a long list on a notice board while someone keeps adding names at the top. When you look at the second half of the board, the names you had just read shift down and you read some of them a second time.
Reducing the bounce
For ties, always add a unique tiebreaker field to the sort, so that the order is fully decided. For different shard copies, use the preference option with a value tied to the user’s session, so the same user is routed to the same shard copies for every page. For changing data, use a frozen view of the index, which is what the point in time feature offers, and which we study soon.
Fixing the cost of deep pagination does not automatically fix the consistency problem. A good pagination design solves both.
11Total Hits: The Hidden Second Cost
Page counters like “page 3 of 8,421” look harmless, but the number behind them can be costly to compute.
To show “page 3 of 8,421”, the application needs to know how many documents matched in total. Counting exactly requires visiting every matching document on every shard, and the search cannot stop early once it has found enough top results. On a big index, this counting can cost more than finding the top ten.
To avoid this, Elasticsearch by default tracks the total only up to ten thousand hits. If more than ten thousand documents match, the response says the total is at least ten thousand, not the exact number. This lets the engine skip work through smart shortcuts. You can ask for exact counts using the track_total_hits setting, or for a different threshold, but you pay for it in speed.
| Setting Value | What You Get | Cost |
|---|---|---|
| Default (threshold of 10,000) | Exact count up to 10,000, then “at least 10,000” | Low |
| Custom number | Exact count up to your number | Grows with the number |
| true | Exact count always | High on large result sets |
| false | No total at all | Lowest, allows early stopping |
Answering “how many people live in this city?” exactly needs a full census. Answering “more than ten thousand” needs only a glance at a few streets. Most users are happy with the glance.
Show friendly messages such as “more than 10,000 results” instead of exact page counts. This is honest, cheap, and encourages users to refine their search instead of scrolling forever.
12The Scroll API: The Old Answer
Scroll was the first tool built for reading very large result sets.
The scroll API works in two steps. First you run a normal search and add a scroll parameter with a time, such as one minute. Elasticsearch then takes a snapshot of the index as it looks at that moment and keeps a search context alive on every shard. The response includes a scroll identifier. Then you repeatedly send this identifier to get the next batch. Each batch continues exactly where the last one ended, and the data does not change during the scroll, even if documents are added or deleted, because you are reading the frozen snapshot.
Scroll avoids the deep pagination cost, because each shard remembers where it stopped and simply continues. Nobody needs to rebuild a big queue for every batch.
sequenceDiagram
participant C as Client
participant Co as Coordinating Node
participant S as Shards
C->>Co: Search with scroll time and size
Co->>S: Run query and keep search contexts open
S->>Co: First batch and cursor positions
Co->>C: Batch 1 with scroll id
C->>Co: Send scroll id for next batch
Co->>S: Continue from saved position
S->>Co: Next batch
Co->>C: Batch 2 with scroll id
C->>Co: Clear scroll when finished
Co->>S: Release contexts
Scroll is like putting a bookmark in a frozen copy of the book. Every time you come back, you continue from the bookmark, and the book never changes under your hands. The price is that the frozen copy must be kept safe on the shelf while you read.
For bulk exports where ranking does not matter, sorting by the natural document order makes scroll fastest, because the engine does not need to score or compare documents.
13Why Scroll Is Not for Users
Scroll solved the cost problem but introduced a resource problem. It is meant for machines, not for web pages.
Each open scroll pins a search context on every shard, and this context keeps the segments that existed at the moment of the search from being deleted. Elasticsearch normally merges small segments and removes the old ones to save disk and memory. While a scroll is open, the old segments must stay, so disk space and file handles are held longer than usual. A busy index with many long scrolls can accumulate a surprising amount of stale data.
Open Context Limit
There is a limit for the number of open scroll contexts per node. If many users each start a scroll, the limit is reached and new requests fail.
Time Limit
Each scroll has a keep alive time that is renewed with every batch. Users who pause for a few minutes lose their place.
Forward Only
You can only move forward. There is no way to jump to a page or move backwards.
Stale View
The snapshot never shows new data, so a user paging through it sees a frozen picture of the past.
Problem
A web application opens one scroll per visitor to power the next page button of a search results page.
Why It’s Harmful
Every visitor holds open search contexts on all shards, and abandoned scrolls stay alive until they time out. The node reaches its context limit and holds old segments in memory and on disk.
Correct Approach
Use scroll only for backend jobs such as exports, copies, and reindexing, and use search after for user-facing pagination.
Always clear a scroll when you finish reading it. Letting it expire on its own keeps resources locked for longer than necessary. Also use the most recent scroll identifier returned in each response.
14Point in Time: A Frozen View of the Index
Point in time separates the frozen view from the paging cursor. That separation is the secret of the modern approach.
A point in time, shortened to PIT, is a lightweight handle that freezes the state of an index, or a set of indices, at the moment you open it. Any search that uses this handle sees exactly the same documents, regardless of what is indexed, updated, or deleted afterwards. You open a PIT with a keep alive time, and you close it when finished.
Unlike scroll, a PIT does not carry a position or a result queue with it. It is only the frozen view. This makes it much lighter, and it means you can run any number of different searches against the same frozen view. Position tracking is left to another feature, the search after cursor, which we cover next.
flowchart TD
O["Open PIT with keep alive"] --> ID["Receive PIT id: frozen view of the index"]
ID --> S1["Search 1 using the PIT id"]
ID --> S2["Search 2 using the PIT id"]
ID --> S3["Search 3 using the PIT id"]
S1 --> CL["Close PIT when done"]
S2 --> CL
S3 --> CL
A PIT is like taking a photograph of the shop shelves at closing time. You can then examine the photograph slowly, from any angle, and ask many different questions of it. New stock arriving the next morning does not change the picture in your hands.
Each search that uses the PIT can extend its keep alive time. Also remember that the identifier of the PIT may change in a response, so use the latest one returned in each reply.
A PIT still holds old segments while it is open, so it should be closed when the job finishes. But because it does not carry a result queue on each shard, it is cheaper to keep open than a scroll.
15Search After: A Cursor Instead of an Offset
Search after replaces “skip this many” with “continue after this exact item”. That small change removes the depth cost.
Instead of telling Elasticsearch how many results to skip, you tell it the sort values of the last result you have seen. The request says, “give me the next ten results whose sort position comes after these values.” Because each shard can apply this as a filter on its own sorted data, it only needs to find the next ten results after that point, not the first ten thousand.
This is known as keyset pagination or cursor pagination. Every request uses from equal to zero, and the cursor carries the position. So the queue size at every shard is only the page size, no matter how deep you are in the list.
| Aspect | Offset Pagination (from and size) | Cursor Pagination (search after) |
|---|---|---|
| Entries per shard | From plus size, grows with depth | Only size, constant |
| Cost of page 1 versus page 10,000 | Very different | About the same |
| Jump to any page | Yes, within the window | No, must move step by step |
| Limit of depth | Ten thousand by default | None |
| State kept on servers | None | None, the client keeps the cursor |
| Needs a unique tiebreaker | Recommended | Required |
Offset pagination is like saying “start reading from the 5,000th line of the book”, which forces someone to count from the beginning. Cursor pagination is like saying “start reading right after the sentence that ends with these words”, which lets the reader open the book to the right spot directly.
You lose random access. Users cannot jump from page 1 to page 500 in one step. They can only go next or previous. This is fine for infinite scroll and load more buttons, but not for classic numbered page links.
16The Recommended Pair: PIT Plus Search After
Together, point in time and search after give cheap pages and a consistent view. This is the current recommended approach for deep pagination.
The PIT provides the frozen view, so documents do not shift while the user is paging. Search after provides the moving position, so each page costs the same. The two features complement each other, and neither alone solves both problems.
sequenceDiagram
participant C as Client
participant E as Elasticsearch
C->>E: Open point in time with keep alive
E->>C: PIT id
C->>E: Search with PIT id, sort, size, no search after
E->>C: Page 1 with sort values of each hit and PIT id
C->>E: Search with PIT id, same sort, search after last hit values
E->>C: Page 2 with sort values and PIT id
C->>E: Repeat with newest PIT id and last hit values
E->>C: Empty page means the end
C->>E: Close point in time
Step by step
Open the PIT
Choose a keep alive time long enough to cover the time between two page requests, not the whole job. Receive the PIT identifier.
Run the First Search
Send your query with the PIT identifier, a full sort definition, and the page size. Do not include an index name, since the PIT already knows it.
Store the Cursor
Every hit includes its sort values. Keep the sort values of the last hit, along with the latest PIT identifier from the response.
Ask for the Next Page
Repeat the same query and sort, and add the stored sort values as the search after position.
Detect the End
When a response contains no hits, you have reached the end of the result set.
Close the PIT
Release the frozen view as soon as you are finished so the cluster can clean up old segments.
Between pages, the query and the sort definition must stay the same. Only the search after values and the PIT identifier change.
17Tiebreakers: Making the Order Unique
A cursor only works if every document has a single, unambiguous place in the sort order.
Suppose you sort by date, and a hundred documents share exactly the same date. The last hit of page one has that date, and you ask for everything “after” that date. The engine cannot tell which of the hundred documents you already saw, so it may skip some or repeat some. To fix this, you add a second sort field that is unique for every document. This is the tiebreaker. With a tiebreaker, no two documents share the same full sort key, so the cursor points to exactly one position.
Shard Document Tiebreaker
When you use a PIT, Elasticsearch automatically adds a hidden tiebreaker that identifies each document uniquely within the frozen view. This is the easiest and safest choice.
A Unique Field of Your Own
Add an identifier that is unique across the index, such as an order number, stored as a keyword or a number with doc values enabled.
The Built-in Id Field
Sorting on the internal id field is discouraged because it uses a lot of memory. Copy the id into a separate field instead if you need it.
Non-unique Fields
Fields such as category, status, or score are shared by many documents and cannot break ties on their own.
If ten students have the same marks, a rank list needs a second rule, such as roll number, to place them in a fixed order. Otherwise a teacher who says “start from the student after Rahul with 90 marks” cannot say which of the ten Rahul is.
All fields in the sort must have doc values, so analyzed text fields cannot be part of the sort. Use keyword fields, numbers, or dates for the cursor.
18Choosing the Right Method
Four tools exist. The right one depends on who is reading and what they need.
flowchart TD
A["Need results beyond the first few pages?"] -->|"No"| B["Use from and size"]
A -->|"Yes"| C{"Who consumes the data?"}
C -->|"A person in a user interface"| D{"Do they need random page jumps?"}
D -->|"No: next and previous, load more"| E["PIT plus search after"]
D -->|"Yes: numbered pages"| F["Cap the depth, refine filters, or use windows by date"]
C -->|"A program: export, copy, reindex"| G["PIT plus search after, or scroll for simple jobs"]
| Method | Best For | Deep Cost | Random Jump | Consistent View |
|---|---|---|---|---|
| from and size | First few pages of a user search | Grows with depth | Yes | No |
| Scroll | Bulk export and reindexing jobs | Low | No | Yes, frozen snapshot |
| Search after alone | Simple deep paging where small inconsistencies are fine | Low | No | No |
| PIT plus search after | Deep paging with a consistent view, user or program | Low | No | Yes |
For most modern applications the answer is short: use offset pagination for the first pages, and PIT with search after when you need to go deeper or when the view must be consistent. Scroll remains available and is fine for simple one-time jobs, but the documentation now recommends the newer pair for deep pagination.
Some applications use offset pagination for the first five pages and switch to a cursor approach beyond that, hiding the difference from the user behind a load more button.
19Parallel Export: Slices
When you must read millions of documents fast, one cursor is too slow. Slicing lets several workers read at once.
A slice is a portion of the result set. If you ask for four slices, the result set is divided into four disjoint parts that together cover everything exactly once. Each worker reads one slice with its own cursor. Slicing is available for scroll, and it is also available for searches that use a PIT.
The split is decided by hashing a document identifier, so it is roughly even. Each worker sends the same query, the same PIT or scroll setup, and a slice number out of the total number of slices. The workers run in parallel and never overlap.
flowchart LR
Q["One query, one frozen view"] --> W1["Worker 1: slice 0 of 4"]
Q --> W2["Worker 2: slice 1 of 4"]
Q --> W3["Worker 3: slice 2 of 4"]
Q --> W4["Worker 4: slice 3 of 4"]
W1 --> OUT["Combined complete export"]
W2 --> OUT
W3 --> OUT
W4 --> OUT
Instead of one person counting all the books in the library, four people each count one quarter of the shelves. Together they finish faster, and no book is counted twice.
Use a number of slices that is not larger than the number of shards, and not more than the workers you can actually run. Too many slices only add overhead.
20Paginating Over Aggregation Buckets
Deep pagination does not only happen with documents. It also appears when you list very many groups.
A terms aggregation returns the top groups, for example the top customers by number of orders. It has a size but no offset. If you ask for a size of one million, each shard must build a huge table of counts, and the coordinating node must merge them, which is the same kind of problem in a different shape. The cluster also has a limit on the total number of buckets a single response may contain, to protect memory.
The tool for walking through many groups is the composite aggregation. It returns groups in a fixed order in small pages, and each response includes an after key, which is the cursor to give in the next request. This is keyset pagination for buckets. It also lets you group by several fields together.
| Need | Tool | Notes |
|---|---|---|
| Top few groups | Terms aggregation with small size | Fast and simple |
| All groups, page by page | Composite aggregation with after key | Stable order, constant cost per page |
| Very large numbers of groups | Composite, plus a filter on part of the key space | Split the work by ranges to run in parallel |
| Count of distinct values only | Cardinality aggregation | Approximate but memory friendly |
Asking for the ten biggest cities is easy. Asking for a list of all forty thousand towns is better done in pages of a hundred, with each page telling you which town to start from next. The composite aggregation is that paged list.
21Application-Level Strategies
The best fix for deep pagination is often in the application, not in the cluster.
Cap the Visible Depth
Show only a limited number of pages, such as the first fifty, and tell users to refine their search if they have not found what they need. Search engines used by the public do exactly this. Nobody browses to result 100,000.
Encourage Filters and Facets
Give users side filters such as brand, date, and price. Filtering reduces the result set so that the wanted item appears on an early page. Filters also make deep pages unnecessary.
Window by Time or Range
For logs and events, let users pick a time window. Paging inside a one-hour window is shallow, even if the total data is huge. To move on, shift the window.
Infinite Scroll and Load More
Change the interface from numbered pages to a load more button. This matches cursor pagination perfectly and feels natural on phones.
Cache Popular First Pages
The first pages of common searches are requested again and again. Caching them in the application or at the edge removes load from the cluster.
Separate Export From Browse
If users ask for all results as a file, run an export as an asynchronous background job with a cursor approach, and email or link the file when done. Do not build it from thousands of interactive page requests.
If users regularly need to go deep to find what they want, the ranking is probably wrong. Improving relevance is a better investment than making deep pages cheaper.
22Designing Cursors in Your API
If you use search after, your own API must decide how to hand the cursor to clients.
The raw cursor is the list of sort values of the last hit, together with the PIT identifier. Exposing this directly to browsers is not wise, since it reveals internal details, and clients could tamper with it. A better approach is to wrap it in an opaque token: a single encoded string that your server creates and later decodes. The client just passes the token back, without understanding it.
First Request
The client asks for a page of results with a query and no token. The server opens a frozen view and returns the first page.
Build the Token
The server combines the view identifier, the last sort values, and a hash of the query into one encoded string, and signs it so it cannot be forged.
Next Request
The client sends back the token. The server checks the signature, decodes it, and runs the next search with the same query.
Expiry
If the frozen view has expired, the server can either open a new view and continue from the same sort values, accepting small inconsistencies, or ask the client to start over.
Going backwards
Search after moves forward. To go to the previous page, one common trick is to reverse every sort direction and use the first hit of the current page as the cursor, then reverse the returned list before showing it. Another simple approach is to keep the cursors of pages you have already visited in the client and reuse them when the user goes back.
A token should only be valid together with the same query and sort. Including a hash of the query in the token prevents clients from mixing a cursor from one search with a different search.
23The Same Problem in Relational Databases
Deep pagination is not unique to Elasticsearch. Every database that supports offset paging has it.
In a relational database, an offset clause tells the engine to skip a number of rows. The engine cannot jump to that position. It must produce the rows in order and count past the skipped ones. On a big table, an offset of a million rows means reading and discarding a million rows before returning the ten you asked for. The well-known cure is keyset pagination, also called the seek method: filter by the last seen key using a condition such as “key greater than last key”, with an index on that key, and take the next ten.
| Concept | Relational Database | Elasticsearch |
|---|---|---|
| Skip-based paging | Limit with offset | from and size |
| Why it is slow deep | Reads and discards skipped rows | Every shard builds and sends from plus size entries |
| Cursor-based paging | Seek by last key value | search after with sort values |
| Need for unique ordering | Add primary key to order by | Add a tiebreaker to the sort |
| Consistent view | Transaction snapshot | Point in time |
| Extra distributed cost | Only with sharded databases | Always, since data lives on many shards |
The distributed nature of Elasticsearch adds a multiplier. In a single-node database, a deep offset wastes work in one place. In Elasticsearch, the waste is repeated on every shard and then again at the merge. That is why the ten-thousand limit exists by default.
Skipping pages in a paper phone directory by counting names is slow. Opening the directory at the letter where you last stopped, using the tabs on the edge, is fast. The tabs are the keys of keyset pagination.
24Where Pagination Lives in the Stack
Pagination decisions are made at several layers. Each layer can help or hurt.
flowchart TD
U["Browser or mobile app"] -->|"page number or cursor token"| G["Gateway or backend API"]
G -->|"validates depth, rate limits, builds query"| CO["Coordinating node"]
CO --> D["Data nodes with shards"]
G -.->|"caches popular first pages"| CA["Cache"]
User Interface
Decides whether users see numbered pages, next and previous, or infinite scroll. The choice determines whether you can use cursors.
Backend API
Validates page size and depth, translates page requests into safe queries, manages cursors and PIT lifetimes, and applies rate limits.
Coordinating Nodes
Carry the merge cost. It helps to have enough memory on these nodes, and dedicated coordinating nodes can isolate the effect.
Data Nodes
Build the per-shard queues. Their search thread pools and memory are the second place where deep pagination hurts.
A good rule is that clients should never talk to Elasticsearch directly for user-facing search. A backend in between can enforce the maximum page size and depth in one place, and it can change the pagination method later without changing the client.
25Security: Deep Pagination as an Attack
A request that is expensive to serve but cheap to send is an invitation to abuse.
Deep pagination fits this pattern perfectly. A single short request with a large offset can consume a lot of memory and processing on the cluster. Attackers can send many such requests to slow or crash a service. But harm does not need bad intent. A search engine crawler that follows the next page links of a public site can walk to page after page, and slowly push the deep cost higher and higher.
Validate Input
Reject page numbers and sizes beyond a sensible limit at the API layer, before the request ever reaches Elasticsearch.
Rate Limits
Limit the number of search requests per user or per address in a time window, especially for paged and export requests.
Crawler Rules
Use robots rules and page link design so that crawlers are not invited to walk thousands of result pages.
Least Privilege
The account used by the application should only have the rights it needs, and the maximum window should stay at its default so that a leaked account cannot ask for extreme sizes.
Cursor values contain real field values from your data. Do not expose raw sort values for sensitive fields to clients. Use signed opaque tokens and check that the user is still allowed to see the results each time.
A restaurant that allows any customer to order a thousand dishes with one sentence needs a rule at the door. The rule is cheap to enforce, and it protects the kitchen, so honest diners still get their meals.
26Monitoring, Logging, and Metrics
You can find deep pagination problems before users complain, if you watch the right signals.
| Signal | Where to Look | What It Suggests |
|---|---|---|
| Slow searches with large from values | Search slow log, which records the request body | Someone is paging too deep |
| High heap use on coordinating nodes | Node stats for JVM memory | Large merge queues are being built |
| Long garbage collection pauses | Node stats and logs | Large short-lived structures from deep requests |
| Circuit breaker trips | Node stats for breakers | Requests are being refused to protect memory |
| Search thread pool queue or rejections | Thread pool stats | Expensive searches are crowding out others |
| Result window errors in the application logs | Application logs and error rates | Clients or bots ask for pages beyond the fence |
| Open scroll or PIT contexts | Node stats for search open contexts | Cursors are not closed or jobs are stuck |
| Growing segments held after merges | Segment and disk metrics | Long-lived scrolls or PITs pin old segments |
Track at the application level too
Elasticsearch does not give you a ready-made chart of the offsets that users request. So log the requested page number and page size in your own backend. A simple chart of the ninety-fifth and ninety-ninth percentile of the requested depth quickly shows whether deep pagination is happening. Add an alert when a single client crosses a threshold of deep requests in a short time.
Set search slow log thresholds for both the query phase and the fetch phase. Deep pagination shows up in the query phase, while huge stored documents show up in the fetch phase. Seeing which one is slow tells you the cause.
27Troubleshooting Guide
Common symptoms, their likely causes, and what to do first.
| Symptom | Likely Cause | First Action |
|---|---|---|
| Error that the result window is too large | From plus size exceeds the limit | Switch to PIT and search after, or cap depth in the application |
| Page 1 is fast, later pages are slower each time | Offset cost growing with depth | Move to cursor pagination for deep pages |
| The same item appears on two pages | Data changed between requests, or tied sort values | Use a PIT and a unique tiebreaker |
| An item is missing from all pages | Item shifted between pages after a change | Use a PIT so the view is frozen |
| Results change order on refresh | Different shard copies answering | Use a stable preference value per user session |
| Error about too many scroll contexts | Scrolls opened but not cleared | Clear scrolls, shorten keep alive, move to PIT |
| Search after returns nothing or wrong items | Sort changed between pages or tiebreaker missing | Keep the same sort and add a unique tiebreaker |
| Cannot sort on a field for the cursor | Field is analyzed text without doc values | Use a keyword sub-field or a numeric field |
| Node heap spikes during exports | Very large page size or many parallel slices | Reduce the size and slices, and pace the workers |
| Disk usage rises during a long export | Old segments pinned by an open scroll or PIT | Speed up the job, and always close contexts |
Like a doctor’s chart, a good troubleshooting table links each visible symptom to a small number of causes. Checking them in order is faster than guessing.
28Shard Count, Sizing, and Deep Pagination
Your index design decides how painful deep pages are. Shard count is the biggest lever.
Recall that the merge cost is proportional to the number of shards. An index with one hundred small shards will suffer from deep pagination ten times more than the same data in ten well-sized shards. Over-sharding is a common mistake, because each shard is a full search engine with its own overhead, and every search touches all of them.
Right-Size Your Shards
Aim for shards that are large enough to be efficient, often tens of gigabytes, instead of many tiny ones. Fewer shards reduce the merge cost of every paged request and of every search in general.
Target Only the Indices You Need
If your data is split into daily or monthly indices, limit each search to the date range the user is interested in. Searching all indices multiplies the number of shards involved.
Route Related Data Together
Custom routing can send all data of one customer to a single shard. Searches for that customer then need to visit only one shard, which removes the multiplier entirely for paged results within that customer.
Give Coordinating Work Room
Deep or wide merges use heap on the coordinating node. Keep enough heap, or use dedicated coordinating nodes so that data nodes are not disturbed.
Cursor pagination removes the depth multiplier, and good shard design reduces the shard multiplier. The most robust systems use both.
29Design Patterns That Work
These habits keep search fast and pagination safe.
Hybrid Pagination
Use offset pagination for the first few pages, where it is cheap and gives friendly page numbers. Switch to cursors beyond a chosen depth.
PIT Plus Search After With a Signed Token
Hide the cursor and view identifier behind an opaque, signed token in your API, with clear expiry rules.
Hard Cap on Depth and Size
Validate page size and depth at the gateway, and keep the cluster window at its default as a second line of defense.
Asynchronous Exports
Turn large downloads into background jobs that use slices and cursors, write to a file store, and notify the user when finished.
Session Affinity for Results
Pass a stable preference value tied to the user’s session so all pages are answered by the same shard copies.
Stable Unique Sort Key
Design your documents with a unique, sortable field, such as an event identifier with a time component, so tiebreaking is natural and cheap.
30Anti-Patterns to Avoid
These mistakes appear again and again in real incidents.
Problem
Using offset pagination with a very large from value to export a whole index.
Why It’s Harmful
Each batch is slower than the one before, the total cost grows with the square of the number of batches, and the window limit blocks the job anyway.
Correct Approach
Use PIT with search after, with slices if speed matters, or a scroll for a simple one-time copy.
Problem
Search after used with a sort that has ties and no unique tiebreaker.
Why It’s Harmful
Documents with equal sort values may be skipped or repeated between pages, and nobody notices until data is compared later.
Correct Approach
Add a unique field to the sort, or use the automatic tiebreaker that comes with a PIT.
Problem
Opening PITs or scrolls and never closing them, or using a very long keep alive time as a convenience.
Why It’s Harmful
Old segments cannot be removed, disk usage grows, and the node may reach its limit for open contexts.
Correct Approach
Use a short keep alive that is renewed with each request, and close the context when finished, including on error paths.
Problem
Changing the query or the sort between two pages of a cursor.
Why It’s Harmful
The cursor values belong to the old sort. Results become meaningless, with skips and repeats.
Correct Approach
Bind the cursor to the query and sort, and start a new cursor when the user changes filters or sorting.
Problem
Requesting the exact total hit count on every page of a big search.
Why It’s Harmful
Exact counting needs a full visit of all matching documents on all shards for every request, which slows every page.
Correct Approach
Keep the default threshold, show approximate counts, or count once and reuse the value for the session.
Problem
Letting clients supply page size and depth without any limit, and exposing page number links to crawlers.
Why It’s Harmful
One request or one crawler can consume a large share of cluster memory and slow every user.
Correct Approach
Validate and cap page size and depth in the backend, rate limit, and control crawler access to result pages.
31Best Practices and Common Mistakes
A checklist to keep beside you when designing paged search.
Best Practices
- Keep the maximum result window at its default
- Use from and size only for shallow pages
- Use PIT plus search after for deep or consistent traversal
- Always add a unique tiebreaker to sorts used with a cursor
- Close every PIT and scroll, including when jobs fail
- Validate and cap depth and size in a backend layer
- Keep shard counts reasonable and search only needed indices
- Log requested depth and alert on unusual values
Common Mistakes
- Raising the window to hide the error
- Using scroll for user-facing pages
- Forgetting that sorted text fields cannot be used for cursors
- Ignoring result consistency between pages
- Counting exact totals on every request
- Letting crawlers walk through result pages
- Keeping very long keep alive times
Before building any paged interface, ask: who really needs page one thousand, and what would they do with it? The honest answers often remove the problem.
32Advantages and Trade-Offs of the Approaches
Every pagination method trades something for something else.
Offset pagination
Advantages
- Simple to understand and to implement
- Supports jumping to any page inside the window
- Matches numbered page interfaces
- No state to manage
Disadvantages / Trade-offs
- Cost grows with depth and with shard count
- Hard limit at the result window
- Pages can shift when data changes
- Vulnerable to abuse through large offsets
PIT plus search after
Advantages
- Constant cost per page at any depth
- Consistent view while paging
- No depth limit
- Works with slices for parallel export
Disadvantages / Trade-offs
- No random page jumps
- Needs a unique sort key and careful cursor handling
- Open views hold old segments until closed or expired
- More logic in the application
The summary is that cursor methods trade freedom of navigation for predictable cost. Since real users rarely jump around deeply, and machines never need to, that trade is nearly always a good one.
33Real-World Scenarios
These stories show the problem and its cure in familiar settings.
The Crawler Incident
An online marketplace shows numbered result pages with links to page after page. A search engine crawler starts following those links deeper and deeper on category listings. Response times slowly climb, and one afternoon the coordinating nodes run out of memory. The team caps the visible pages at fifty, adds rules for crawlers, and switches the load more button to a cursor. The incident does not return.
The Nightly Export
A reporting job exports all orders of the day using page after page of offset requests. The job gets slower and slower and finally fails at the ten thousand limit. The team rewrites it with a point in time and search after, with four slices running in parallel. The job now finishes in minutes and its memory use is flat.
The Duplicate Rows Mystery
Customer support notices that the audit log screen sometimes shows the same event twice and misses others. The cause is that many events share the same second in their timestamp, and new events arrive while agents click next. Adding a unique event identifier as a second sort field and using a point in time removes the confusion.
The Mobile Feed
A social application shows an endless feed sorted by time. Instead of page numbers, the app sends a cursor token with each request. Every request costs the same, whether the user has scrolled past ten posts or ten thousand, and nobody needs to see a total count.
The Log Search Console
Engineers search millions of log lines. The console asks for a time window first, then pages inside it with a cursor. To look further back, the engineer changes the window. Deep offsets never appear, and the cluster stays healthy.
34Myths About Deep Pagination
Some beliefs about pagination cause avoidable trouble. Let us test them.
The Belief
Asking for ten results always costs the same, whatever page it is.
The Reality
With offset pagination, each shard must produce from plus size entries, so a deep page costs far more than the first page even though both return ten results.
What To Do
Measure latency by page depth and use cursors for deep pages.
The Belief
The ten thousand limit is just a default that should always be raised in production.
The Reality
It is a safety feature that protects memory and latency. Raising it removes protection without removing the cost.
What To Do
Leave it alone and change the pagination method instead.
The Belief
Scroll is the right tool for paging through results in a web page.
The Reality
Scroll holds resources on the cluster for each reader and is meant for batch jobs. The current guidance for deep pagination is search after with a point in time.
What To Do
Keep scroll for simple exports and use the newer pair for interactive use.
The Belief
Search after removes the need for a consistent view, so a PIT is optional and pointless.
The Reality
Search after fixes the cost, not the consistency. Without a frozen view, documents added or removed between pages can still cause repeats and gaps.
What To Do
Add a PIT whenever the pages must agree with each other.
The Belief
Only huge clusters need to worry about deep pagination.
The Reality
Even a small cluster can be brought down by a few deep requests, especially when the index has many shards. Small clusters have less headroom.
What To Do
Apply limits and cursor design from the start, whatever the cluster size.
35A Tale of Two Requests: Page 1 Versus Page 1,000
Let us put two requests side by side and follow every step. The only difference is the offset, yet the journey is very different.
Both requests ask for ten results, sorted by relevance, on an index with ten shards. Request A asks for page one, with an offset of zero. Request B asks for page one thousand, with an offset of 9,990. Both are allowed, since request B just fits inside the default window.
flowchart LR
subgraph A["Request A: from 0, size 10"]
A1["Each shard builds a queue of 10"] --> A2["Coordinator merges 100 entries"] --> A3["Fetch 10 documents"]
end
subgraph B["Request B: from 9,990, size 10"]
B1["Each shard builds a queue of 10,000"] --> B2["Coordinator merges 100,000 entries"] --> B3["Fetch 10 documents"]
end
| Step | Request A (Page 1) | Request B (Page 1,000) |
|---|---|---|
| Queue length in each shard | 10 | 10,000 |
| Entries sent by each shard | 10 | 10,000 |
| Entries merged at the coordinator | 100 | 100,000 |
| Entries thrown away at the end | 90 | 99,990 |
| Documents fetched | 10 | 10 |
| Heap pressure | Tiny | Substantial |
| Typical latency | Milliseconds | Much longer, and it grows with load |
The fetch phase is identical, because only ten documents are needed. The query phase for request B does one thousand times more sorting work. And if fifty people request pages this deep at the same time, the shards and the coordinating node must build fifty such queues at the same time, in the same heap.
Two customers ask a shopkeeper for the ten best-selling items. The first asks for the top ten. The second asks for the ten items ranked between 9,991 and 10,000. In both cases the shopkeeper hands over ten items, but the second customer forced her to rank ten thousand products first.
Judge a search request by the work done inside the cluster, not by the size of the response. A tiny response can hide a very large amount of work.
36Thread Pools, Queues, and How Deep Requests Starve Others
Deep pagination hurts other users through shared queues. Understanding those queues explains the strange slowdowns.
Every data node has a fixed number of worker threads dedicated to search, and a waiting line called a queue for requests that arrive while all workers are busy. Each shard-level part of a search takes a thread for as long as it runs. A deep page makes each shard part run longer, because building a long queue takes time. That means threads are occupied for longer, and the waiting line grows.
When the waiting line is full, the node refuses new search work and returns a rejection to the caller. Ordinary, cheap searches are then rejected just because they arrived while a few deep requests were holding all the threads. The victims are innocent users of the search box, while the cause was somebody browsing page ten thousand.
Deep Requests Arrive
A few requests with large offsets reach the coordinating node and are fanned out to every shard.
Threads Stay Busy Longer
Each shard-level task takes much longer than usual, so worker threads are not freed quickly.
The Queue Fills
New cheap searches wait behind the long ones. Response times rise for everyone.
Rejections Begin
When the queue is full, new search work is refused, and clients see errors that say the cluster is too busy.
Built-in helpers
Elasticsearch has several features that soften the blow. The coordinating node merges shard results in batches instead of all at once, using a setting that controls the batch size, so that memory does not spike as much on wide searches. Searches can be given a timeout, and can return partial results if some shards are slow. Adaptive replica selection routes requests towards shard copies that respond faster. These help, but they do not remove the underlying cost of a deep page.
When clients receive rejections, they often retry immediately, which adds even more load. Use growing delays between retries and lower the request rate, or the overload will feed itself.
37Modeling Data for Painless Pagination
Good pagination starts in the mapping. A few small decisions make cursors reliable and cheap.
Cursor pagination depends on sorting, and sorting depends on doc values, the column-style structure that stores each field’s value per document. So every field that appears in your sort must be a type that has doc values, such as a keyword, a number, or a date. A field that is only analyzed text cannot be used.
A Unique Sortable Identifier
Give every document a field that is unique across the index and sortable, so that it can serve as the final tiebreaker. A time-ordered identifier is ideal.
A Timestamp for Natural Order
Most feeds, logs, and histories are read newest first. A date field with good precision is a natural primary sort key.
Keyword Copies of Text
If users need alphabetical order by a title, keep a keyword version of the title with a normalizer for case, so sorting is predictable.
Handle Missing Values
Decide whether documents without the sort value go first or last, and make it explicit, so the order does not surprise you.
Sorting by relevance score
Sorting by relevance is the default, and it works with cursors too. But scores can be almost equal for many documents, and they can differ slightly between shard copies. With a frozen view and a tiebreaker, relevance-sorted cursors work fine. Without them, expect some jitter. Also remember that users rarely need relevance beyond the first few pages, so a limit on depth for relevance-sorted searches is very reasonable.
If a search covers several indices, the sort fields must exist in all of them with compatible types, or you must tell Elasticsearch how to treat missing mappings. Mismatched types are a common source of errors in cursor-based searches over time-based indices.
38Async Search and Other Ways to Reduce Pain
Sometimes a search is heavy for reasons other than paging. Asynchronous search can make such requests friendlier.
The async search API lets you submit a search and get back an identifier immediately instead of waiting for the final result. You can then poll for progress, and even read partial results while shards are still working. This is useful for large searches over many indices, or over slow storage tiers, where waiting on one long open connection is awkward.
Async search does not remove the cost of deep pagination. It changes only how you wait. But it pairs well with the other tools. For example, a heavy exploratory search can run asynchronously with a timeout, and the user sees progress and partial data while the cluster works in the background.
| Tool | What It Helps With | What It Does Not Fix |
|---|---|---|
| Async search | Long-running searches and slow responses | The high cost of deep offsets |
| Search timeout | Stopping a search that takes too long | Wasted work already done |
| Partial results | Returning what is ready when some shards are slow | Consistency of pages |
| Caching first pages | Repeated shallow requests | Unique deep requests |
| Search after with PIT | Deep, consistent traversal | Random page jumps |
Async search is like leaving your name at a busy restaurant and getting a buzzer, instead of standing at the counter. The kitchen is just as busy, but you are not stuck in the line.
39Deep Pagination in Tools You Already Use
Many tools around Elasticsearch already handle this problem for you. Knowing how helps you use them correctly.
Kibana Discover
Discover does not let you page through millions of documents with numbered pages. It loads a limited sample of results within your chosen time range, and encourages you to narrow the time window or add filters. This is the time window strategy in action.
Reindexing
The reindex operation copies documents from one index to another. Internally it reads the source in batches using a scroll, and it can use slices to run in parallel. It does not use large offsets, which is why it can copy huge indices safely.
Client Library Helpers
Many official client libraries provide helper functions to scan or iterate through all results of a search. These helpers hide the scroll or cursor details and give you a simple loop. Prefer them over hand-written offset loops for exports.
Data Pipeline Inputs
Tools that read data out of Elasticsearch, such as pipeline input plugins and data movers, generally use scroll or cursor style reading with configurable batch sizes and slices. Check the documentation of your tool and tune the batch size so memory stays reasonable.
Home-made scripts and old integrations are the usual place where offset loops still live. Search your code for loops that keep increasing the offset, and replace them before they cause an incident.
40Testing and Benchmarking Your Pagination
Do not guess how your cluster behaves at depth. Measure it, and test correctness as well as speed.
Use Realistic Data and Shards
Test on an index with the same shard count, document size, and data volume as production. Deep pagination cost depends strongly on shard count, so a tiny test index will mislead you.
Measure by Depth
Time the same query at offsets such as zero, one hundred, one thousand, and near the limit. Record median and high percentile latency and the heap use of the coordinating node.
Add Concurrency
Run many requests at the same time, since the damage of deep pagination appears under concurrency, through queues and heap pressure.
Compare With Cursors
Repeat the same walk with a cursor. You should see roughly flat latency by depth. If not, check the sort and the tiebreaker.
Test Correctness
Walk through a whole result set with cursors while another process indexes and deletes documents. Count the documents you saw, check for repeats and gaps, and compare with the frozen view.
Test Failure Paths
Let a view expire in the middle of a walk, restart a node, and cancel a request. Verify that the application fails gracefully and closes what it opened.
Keep this test as an automated check in your pipeline. It will catch regressions such as a changed sort, a removed tiebreaker, or a new endpoint that accepts unlimited page numbers.
41Migrating From Offset to Cursor Pagination
If your application already uses deep offsets, you can move to cursors safely in steps.
Find Who Goes Deep
Analyze logs to see how often, and by whom, pages beyond ten or twenty are requested. Most teams discover that almost nobody does, except bots and internal jobs.
Put a Cap in Place
Add validation in your backend for page size and depth, with a friendly message. This stops the worst abuse at once and buys time.
Fix the Sort
Add a unique tiebreaker to sorts and make sure all sort fields have doc values. Do this first, since the cursor depends on it.
Build the Cursor Path
Implement PIT plus search after behind a signed token, with expiry rules. Test it as described in the previous chapter.
Change the Interface
Replace numbered links beyond the shallow range with a load more button or next and previous links. Keep numbered pages for the first few pages if users like them.
Move Batch Jobs
Convert exports and syncs from offset loops to cursor loops with slices where needed.
Watch and Clean Up
Monitor depth, latency, heap, and open contexts. If you had raised the result window earlier, restore the default once no caller depends on it.
If public API clients rely on page numbers, keep the old parameters working for shallow pages, and return a clear error with guidance for deeper ones. Introduce cursors as a new option, and give clients time to move.
Changing a road system while cars are driving is done one lane at a time, with signs telling drivers about the new route. A pagination migration works the same way: old and new paths live side by side for a while.
42A Launch Checklist for Any Paged Search Feature
Use this list in design reviews and before release. Each item prevents a real incident that teams have met in production.
Is the Depth Limited?
Confirm that the backend refuses page sizes and depths beyond a documented limit, and that the cluster window is still at its default.
Is the Sort Deterministic?
Check that every sort ends with a unique field or the automatic tiebreaker, and that every sort field has doc values.
Is the View Stable?
Decide whether users need a frozen view. If yes, use a point in time with a sensible keep alive. If no, document the trade-off.
Are Contexts Closed?
Verify that every opened view or scroll is closed on success, on error, and on cancellation, and that a safety net closes abandoned ones.
Are Counts Cheap?
Make sure the total hit count uses the default threshold or is approximated, and is not recalculated on every page.
Are Abuse Paths Covered?
Confirm rate limits, crawler rules, and authentication on every search endpoint, including export endpoints.
Is It Observable?
Check that requested depth, latency, and error rates are logged and charted, and that alerts exist for heap pressure, rejections, and open contexts.
A checklist takes five minutes and prevents weekends of firefighting. Add it to your pull request template for any change that touches search.
43A Short Glossary of Pagination Terms
Keep these plain-language definitions handy. They make conversations with teammates and documentation much easier to follow.
Offset
The number of results to skip before the page begins, set with the from parameter.
Page Size
The number of results returned in one response, set with the size parameter.
Result Window
The maximum value of offset plus page size that an index accepts, ten thousand by default.
Query Phase
The first stage of a search, in which each shard returns light entries with identifiers and sort values.
Fetch Phase
The second stage, in which full documents are retrieved only for the results that will be returned.
Priority Queue
A sorted list that keeps only the best entries seen so far, used by shards and by the coordinating node.
Cursor
A saved position, usually the sort values of the last seen result, from which the next page continues.
Tiebreaker
An extra sort field that is unique for every document, so that the order is always unambiguous.
Point in Time
A lightweight frozen view of an index that many searches can share to see the same data.
Search Context
State kept on a shard for an open scroll or view, which pins the segments that existed at that moment.
Slice
One disjoint portion of a result set, read by one worker so that several workers can read in parallel.
Keyset Pagination
Another name for cursor pagination, borrowed from databases, where the next page is found by a key greater than the last seen key.
When a teammate says “we hit the window”, they mean offset plus size went over the limit. When they say “we need a cursor”, they mean the team should stop skipping and start continuing. Sharing this vocabulary makes design discussions shorter and clearer.
44Frequently Asked Questions
It is the fact that the cost of a page grows with how far down the results it starts. Each shard must build a list of from plus size entries, and the coordinating node must merge all of those lists, only to throw away everything except the final page.
The default result window setting limits from plus size to ten thousand, to protect memory and latency. It is a safety limit, and the error tells you to use scroll or search after for deeper traversal.
Only in narrow cases with few shards, low traffic, and trusted users. In general it just removes the safety fence, while the cost still grows with depth and shard count.
Scroll keeps a snapshot and a position on the servers and is meant for batch jobs. Search after is stateless and uses the sort values of the last hit as the cursor. With a point in time, search after also gives a consistent view.
A lightweight frozen view of one or more indices. Searches that use its identifier all see the same data, even if documents change afterwards. It must be closed when finished.
If several documents share the same sort values, the cursor cannot point to one exact position, so documents may be skipped or repeated. A unique second sort field, or the automatic one that comes with a point in time, fixes this.
Not directly. You can only continue from a known position. If you need jumps, cap the depth and offer filters, or use offset pagination for the shallow pages.
Either store the cursors of pages already visited and reuse them, or run the query with the sort order reversed starting from the first hit of the current page, then reverse the results before showing them.
Because the data may have changed, sort values may tie, or different copies of a shard may answer. Use a point in time, a unique tiebreaker, and a stable preference value per session.
Not much. The fetch phase retrieves only the documents of the final page. The heavy cost is in the query phase, where every shard builds a long list of entries.
Yes. The coordinating node must merge from plus size entries from every shard, so the merge cost grows with the shard count. Right-sizing shards helps all searches.
Use a point in time with search after, and use slices to read in parallel. A simple scroll is also fine for a one-time copy. Do not use large offsets.
Use a composite aggregation. Each response includes an after key, which you send in the next request to continue from where you stopped.
Show an approximate value such as more than ten thousand. Exact counts require visiting every matching document, which is costly for large result sets and rarely valuable to users.
Validate and cap page depth and size in your backend, apply rate limits, and use crawler rules and interface design that do not invite bots to walk thousands of result pages.
Use the search slow log, watch coordinating node heap and garbage collection, track breaker trips and rejections, and log the requested depth in your own application so you can chart and alert on it.
45Summary and Key Takeaways
The deep pagination problem exists because offset pagination in a distributed search engine forces every shard to build a sorted list as long as the offset plus the page size, and forces the coordinating node to merge all of those lists, only to keep a handful of results. The cost grows with the depth of the page and with the number of shards, and it is paid again for every page. Elasticsearch protects itself with a default result window of ten thousand. The healthy way forward is to use offset pagination only for shallow pages and to use a point in time with search after for deep traversal, with a unique tiebreaker, closed contexts, and limits at your backend. Like the librarian in our opening story, the cluster works best when you give it a bookmark to continue from, instead of asking it to count from the beginning every time.
Key Takeaways
- Deep pages are expensive — each shard must return from plus size entries, so cost grows with the offset and with the number of shards.
- The fence is a feature — the default result window of ten thousand protects your cluster, so change your method instead of raising the limit.
- Use cursors for depth — search after keeps the cost per page constant by continuing from the last sort values.
- Freeze the view with a PIT — a point in time gives consistent pages and an automatic tiebreaker, and must always be closed.
- Scroll is for batch jobs — it holds resources for each reader, so keep it away from user-facing pages.
- Protect from the outside — cap page depth and size in your backend, rate limit, and control crawlers.
- Measure and design well — right-size shards, log requested depth, watch heap and slow logs, and remember that better ranking and filters remove most needs for deep pages.