search_after in Elasticsearch
A complete, friendly guide to the bookmark that lets you read millions of search results page after page without ever counting from the start, how it finds its place using sort values, why ties and moving data can trip it up, and how to combine it with a point in time for exact, dependable results.
Imagine you are reading a very thick book with ten thousand pages. You read a hundred pages, and then you must stop. What do you do when you come back tomorrow? One way is to say, “I stopped at page one hundred,” and then count one hundred pages from the front cover. The next day you would count two hundred pages, then three hundred. The deeper you read, the longer the counting takes. The other way is much simpler: you leave a bookmark in the book, and tomorrow you open it right where the bookmark sits.
Searching in Elasticsearch has the same two choices. The classic way of asking for the next page says “skip this many results and give me the next batch,” and the engine must count through all the skipped results again every single time. The search_after feature is the bookmark. You tell Elasticsearch exactly where you stopped, and it continues from that spot, no matter how deep into the results you already are.
In this guide you will learn what search_after is, why it exists, how the bookmark is built from sort values, what goes wrong when two documents look identical to the sort, how a point in time keeps the pages consistent, and how to design, operate, and troubleshoot real applications with it. You do not need any search engine background. Every new word is explained the first time it appears, and each big idea comes with an everyday comparison.
1What Is search_after?
The shortest possible answer: search_after is a search request option that continues a sorted search right after a position that you name, using the sort values of the last result you already received.
Let us build the definition slowly. Elasticsearch is a search and analytics engine. Companies use it to search products, explore application logs, count events, and draw charts. Data lives in indices, which are like big labeled cabinets of documents, and each index is cut into pieces called shards so that many servers can share the work.
When you search, Elasticsearch returns a list of matching documents, called hits. A search usually returns only a small batch of hits at a time, called a page, whose length is set by the size option. To read the next page, you need a way to say where the previous page ended.
The search_after option is that way. You must sort the search by one or more fields. Every hit that comes back carries its sort values, the values of the sort fields for that document. To ask for the next page, you send the same search again, with the sort values of the last hit of the previous page placed into search_after. Elasticsearch then returns the next size documents that come after that position in the sort order.
Think of a long queue of people sorted by their birth dates. You are handing out flyers, one hundred at a time. When you finish a group, you note the birth date of the last person served. Next time, you do not count people from the front of the queue. You simply say, “Start with the first person born after this date.” The birth date is your bookmark, and the queue’s sorted order guarantees that the bookmark points to exactly one place.
Why it is sometimes called a live cursor
A cursor is a marker that remembers a position in a list of results. Many databases keep a cursor open on the server. With search_after, the server keeps nothing between requests, and the marker lives entirely in the values that you send back. For this reason, the documentation describes it as a live cursor: it always looks at the current state of the index, and it holds no memory on the server unless you add a point in time to it.
People say “search after,” “keyset pagination,” “seek pagination,” and “cursor pagination.” They all describe the same idea: continue from the last seen key instead of skipping a count of rows. The name search_after is specific to Elasticsearch.
What it is not
The option does not save results on the server, it does not guarantee that the pages come from one frozen view of the data, and it does not let you jump to page fifty. It also does not work without a sort. It is a simple, efficient way to walk forward through a sorted result list. When you need a frozen view, you combine it with the point in time feature, which Chapters 12 and 13 explain.
Where you will meet it
You will meet search_after when you build an infinite scroll page that keeps loading more items, when you export millions of documents to another system, when a batch job must process every record exactly once, when a log viewer offers a “load older events” button, and when a search request fails because it asked for results beyond the ten thousandth. In each case, the bookmark idea gives a clean and cheap solution.
2Why It Exists: The Deep Pagination Problem
Asking for a page far from the start is expensive in a distributed search engine. The search_after option exists to make deep reading cheap and safe.
To see the problem, we must understand how a search works across shards. A search request lands on a coordinating node, which forwards it to one copy of every relevant shard. Each shard finds its best matching documents, and the coordinating node combines the partial lists into one final list.
The cost of skipping
Suppose you ask for the results from position 10,000 to 10,010, which means skipping ten thousand hits and returning ten. No shard knows where the global position 10,000 is, because the global order only exists after combining all shards. So, to be correct, each shard must return its top 10,010 hits. If the index has five shards, the coordinating node receives about fifty thousand candidate hits and sorts them all, just to throw away ten thousand and keep ten.
The cost grows with depth. Page one costs a little. Page one thousand costs a thousand times more. Memory use on the shards and on the coordinating node grows in the same way, and many users asking for deep pages at once can bring a cluster to its knees.
Imagine five librarians, each responsible for a different set of shelves, and you ask for “results 10,001 to 10,010 in alphabetical order.” Each librarian cannot know which of their books fall into that range, so each must prepare a list of their first ten thousand books in order and hand it to a head librarian. The head librarian merges fifty thousand entries, hands you ten, and throws the rest away. Now ask for the next page, and the whole exercise repeats. A bookmark saves this repeated work.
The safety valve: the result window
To protect clusters from this cost, Elasticsearch has a limit called the result window. The index setting index.max_result_window defaults to ten thousand. A request whose from plus size is larger than that limit is rejected with an error. This protects the cluster, but it also means that ordinary pagination cannot reach beyond the ten thousandth hit. If you need to read further, you need a different tool.
The earlier answer: scroll
The first tool built for this need was the scroll API. A scroll opens a search context on the server that remembers a frozen view of the index, and you fetch the results in batches by sending a scroll identifier back and forth. Scroll works well for exports, but it ties up server resources, it is designed for one fixed query, and it is not meant for live user-facing pagination. Today, the documentation no longer recommends it for deep pagination and points to search_after with a point in time instead.
The bookmark answer
With search_after, each shard is told the exact sort position after which to look, so each shard returns only its next size hits after that position. The coordinating node merges a few hundred entries, not tens of thousands. The cost of every page is about the same, whether it is the second page or the second millionth.
An illustrative story
PinBoardly, an imaginary photo-sharing site
PinBoardly lets users scroll through an endless feed of pictures, sorted from newest to oldest. The first version of the feed uses from and size, and it works nicely for the first few screens. But some visitors scroll for hours, and heavy users reach positions beyond the tenth thousand, where the search fails with a result window error. Even before that, the feed gets slower with every screen, and the cluster spends its evenings sorting and discarding tens of thousands of hits. The team switches to search_after, storing the sort values of the last picture on screen. Every screen now costs the same, and the error disappears. This story is invented, but the pattern is very common.
A short history
The search_after option arrived in Elasticsearch 5.0 as a lighter alternative to scroll for live pagination. Its main weakness was the lack of a consistent view, since the index could change between requests. Version 7.10 added the point in time API, which freezes the view without the drawbacks of scroll. Later versions added an automatic tiebreaker for such searches. Together, these pieces are today’s recommended way to read deeply and reliably.
Some teams “solve” the result window error by raising index.max_result_window to a million. That removes the safety valve without removing the cost. The cluster then risks running out of memory whenever someone asks for a deep page. Use search_after instead.
3The Building Blocks You Need to Know First
To follow the rest of this guide you need eight simple ideas: hit, page, sort, sort values, cursor, tiebreaker, segment, and point in time.
One matching document
A hit is a document that matches the search, returned with useful details such as its id, its score, and its source.
One batch of hits
A page is the group of hits returned by one request. Its length is set by the size option.
The order of the results
The sort option lists fields and directions, such as newest first. It is required for search_after because the bookmark is a position in that order.
The keys of each hit
Each hit reports the values of its sort fields. The sort values of the last hit on a page form the bookmark for the next page.
A remembered position
A cursor marks where you are in a list of results. In search_after, the cursor is the array of sort values that you carry between requests.
A field that makes every position unique
When two documents have the same sort values, a tiebreaker field, unique per document, decides their order so that no position is ambiguous.
A read-only bundle of index files
A shard stores its data in segments that never change. Searches read segments, and background merges replace them over time.
A frozen view of the index
A point in time, or PIT, keeps a consistent view of the segments alive for a while, so that several searches see the same data.
Sort values are not always what you expect
The sort values that come back with a hit are the internal keys used for ordering, and they can look different from the fields in the document. A date field, for example, shows its sort value as a number of milliseconds since a fixed starting date in 1970. A text label stored as a keyword shows up as the string itself. A number shows as a number. When you build the next request, you send these values back exactly as you received them, not the original document values.
Why sorting must be predictable
The bookmark works only if the sort order is stable and complete. Stable means that the same documents always sort in the same relative order. Complete means that no two documents share exactly the same sort values, so that the position of a bookmark is unique. Much of this guide is about making sorting stable and complete, because everything else builds on that.
Think of a street with houses numbered in order. You can say, “Continue from house 40,” and everyone knows what you mean, because every house has a different number. Now imagine that several houses share the number 40. If you say “continue after 40,” it is unclear whether you mean after the first house numbered 40 or after the last. The tiebreaker is like adding a flat number, so every address is unique.
The path of a search request
The diagram shows how a paged search travels. The client usually reaches the cluster through a load balancer or an API gateway, which passes the request to a coordinating node. That node asks the shards for their partial results, merges them, and returns one page.
flowchart LR
A["Client application"] --> B["Load balancer or API gateway"]
B --> C["Coordinating node"]
C --> D1["Shard 1: top hits after the bookmark"]
C --> D2["Shard 2: top hits after the bookmark"]
C --> D3["Shard 3: top hits after the bookmark"]
D1 --> E["Coordinating node merges partial lists by sort values"]
D2 --> E
D3 --> E
E --> F["One page of hits, each with its sort values"]
F --> A
Look closely at Figure 1. The client receives the sort values of the last hit and keeps them. On the next request, the client sends them back, and every shard starts from that same position. The whole state of the pagination lives in the client, in the form of a few values.
Because the state lives in the client, search_after scales well: the cluster does not need to remember thousands of open cursors for thousands of users. That is one of the biggest differences from scroll.
4How Ordinary Pagination Works, and Why It Slows Down
To appreciate the bookmark, look closely at the method it replaces: skipping a count of results with from and size.
The classic pagination options are from, the number of hits to skip, and size, the number of hits to return. To read the third page of ten results per page, you set from to twenty and size to ten. This is simple to understand, and it allows jumping to any page number directly. It is perfect for the first few pages of a result list.
What each shard must do
Every shard does not know how many of its hits fall before the global position of the requested page. So it must return at least from plus size hits. The shard keeps a sorted queue of that many candidates while it scans its matching documents, and every candidate takes memory and time.
| Page requested | Skip (from) | Size | Hits each shard must return | Hits merged with 5 shards |
|---|---|---|---|---|
| Page 1 | 0 | 10 | 10 | 50 |
| Page 10 | 90 | 10 | 100 | 500 |
| Page 100 | 990 | 10 | 1,000 | 5,000 |
| Page 1,000 | 9,990 | 10 | 10,000 | 50,000 |
| Page 1,001 | 10,000 | 10 | Rejected by the result window | Not allowed by default |
Picture a race with hundreds of runners split into five heats. To announce “the runners who finished from 91st to 100th overall,” the referee needs to know how many runners from each heat finished before that place. The only safe way is for each heat to hand over its top one hundred finishers, so the referee can compare them all. The deeper the requested place, the longer the lists that each heat must hand over.
Where from and size still shine
Ordinary pagination is the right tool when users look at the first few pages and want to jump between them. Product search pages often work this way, where hardly anyone goes beyond page five. Its cost at that depth is small, and its simplicity is valuable. You do not need a bookmark for a result list that people read shallowly.
Where it breaks down
- Deep reading. Cost and memory rise with every page, and the result window limit stops the reading at ten thousand hits.
- Full exports. Reading all documents of a large index page by page costs far more than the data itself.
- Endless scrolling. Feeds and timelines invite users to read far beyond the first pages.
- Concurrent users. Many deep requests at once multiply the pressure on memory and CPU.
Another quiet problem: shifting pages
Ordinary pagination also suffers from shifting. If a new document arrives at the top of the sorted list between two requests, every later document moves down by one position. The user who requests the next page then sees the last document of the previous page again, because it shifted into the next page’s range. A bookmark does not have this problem, since it names a place relative to a document, not a count from the start.
On an index that changes constantly, page-number pagination shows repeated or missing results while users move between pages. The bookmark approach is far more stable, though it has its own subtleties, which Chapter 11 explains.
5What search_after Does: The Bookmark Idea
The whole technique fits in one loop: ask for a page, keep the sort values of the last hit, and send them back to ask for the next page.
Let us walk through the loop from the point of view of an application. The steps are the same whether the application is a web feed or a nightly export job.
Send the first search
Ask for the first page with a query, a sort, and a page size. Do not include search_after yet, because there is no bookmark.
Read the hits and their sort values
Process the page. Each hit carries an array of sort values, one value for each field in the sort.
Keep the last hit’s sort values
The array of the last hit on the page is your bookmark. Store it in a variable, or in a token that you hand to the user.
Send the next search
Repeat the same query and the same sort, and add the bookmark in the search_after option. Keep the skip value at zero.
Repeat until a page comes back empty
When a page contains no hits, you have reached the end of the results. Stop the loop.
flowchart TD
A["Send first search with sort and size"] --> B["Receive a page of hits"]
B --> C{"Any hits in the page?"}
C -->|No| D["End of results: stop"]
C -->|Yes| E["Process the hits"]
E --> F["Copy the sort values of the last hit"]
F --> G["Send the same search with search_after set to those values"]
G --> B
Think of reading a long list of names on a wall in batches of twenty. After each batch, you write down the last name you read on a sticky note. The next time, you look for that name on the wall and continue with the name after it. You never count from the top of the wall. The sticky note is the sort values, and the alphabetical order of the wall is the sort.
What the engine does with the bookmark
When a shard receives a request with search_after, it treats the bookmark as a position in the sort order. It looks only for documents that sort strictly after that position and returns its best size among them. It does not need a long queue of skipped documents. The work per page depends on the page size, not on how many pages came before.
Rules that the request must follow
- A sort is required. Without a sort, there is no order in which to find a position.
- The skip value must be zero. The
fromoption must be zero or unset whensearch_afteris used, because the bookmark replaces skipping. - The bookmark must match the sort. It must contain one value for every sort field, in the same order, with values of the right types.
- The query and sort must stay the same. If you change the query or sort between pages, the bookmark loses its meaning.
A first taste of the problems ahead
The loop looks simple, and for many uses it works well. Two questions remain. What if several documents share the same sort values, so that the bookmark does not point to a single place? And what if documents are added, changed, or removed between pages? The next chapters answer these questions, and the answers determine whether your pagination is merely convenient or truly dependable.
If you remember only one thing, remember this: search_after is a forward-only bookmark, and it is only as reliable as the sort that defines it. Make the sort unique and stable, and the bookmark is dependable.
6Anatomy of a search_after Request and Response
A paged search has only a few moving parts. Knowing each one, and what the engine expects, prevents almost every beginner error.
A search request with search_after has the same shape as an ordinary search. It carries a query that decides which documents match, a sort that decides their order, a size that decides how many hits come back, and, from the second page on, the search_after bookmark.
| Part of the request | Role | Rule for search_after |
|---|---|---|
| Query | Chooses the matching documents | Must stay identical on every page of one walk |
| Sort | Defines the order and therefore the meaning of a position | Required, and must stay identical on every page |
| Size | Number of hits per page | Must not exceed the result window, and can differ between pages, though a constant value is simplest |
| From | Number of hits to skip | Must be zero or unset |
| Search after | The bookmark: sort values of the last hit already seen | Absent on the first page, then one value for every sort field |
| Track total hits | Controls counting of all matches | Often switched off for deep walks to save work |
| Source filtering | Limits which fields of each hit are returned | Recommended to keep pages light |
| Point in time | Pins the search to a frozen view | Optional but strongly recommended for exact walks, and then no index name is given |
Think of a library request slip. It has the topic you want, the order in which you want the books listed, how many books you want at once, and a line that says “start after this book.” If you change the topic or the order on your second slip, the line “start after this book” no longer means anything, because the librarian is now working from a different list.
What a hit looks like in the response
The response contains a list of hits. Each hit has its usual details, such as the index it lives in, its id, and its source. When a sort was used, each hit also contains a sort array. The array holds one value for every sort field, in the order in which you listed the fields. The array of the last hit on the page is your next bookmark.
| Sort definition you sent | Sort array of a hit | What each position means |
|---|---|---|
| Newest first by a date field, then by a unique id field | A large number, then a text value | The date as milliseconds since 1970, and the unique id |
| Price ascending, then a unique order number | A number, then a number | The price, and the order number |
| A keyword field ascending only | One text value | The keyword itself, which may not be unique |
| Date, with a point in time in use and no explicit tiebreaker | A number, then a second number | The date, and the automatic shard and document position added by the engine |
The first page and the last page
The first page is an ordinary sorted search with no bookmark. The last page is the one that returns no hits. Some applications also treat a page with fewer hits than the requested size as the last page, since the engine had nothing more to return. Be careful with this shortcut when partial shard failures can occur, because a page may be short for the wrong reason. Chapter 24 returns to this point.
Counting matches costs extra
By default, Elasticsearch counts the total number of matching documents, but it stops counting at ten thousand. For a deep walk, this counting is wasted effort, since you do not need the total on every page. You can switch it off with the track_total_hits option to save time. If your user interface needs a total, ask for it once, in a separate request, instead of on every page.
What errors look like
- A missing sort. The request is rejected, because a bookmark needs an order to refer to.
- A skip value above zero. The request is rejected, because skipping and bookmarking cannot be combined.
- A bookmark with the wrong number of values. The engine complains that the count of bookmark values differs from the count of sort fields.
- A bookmark value of the wrong type. For example, a text value where the sort field is a number.
- A page size larger than the result window. The size itself is limited, not only the sum with the skip value.
Copy the sort array from the response and paste it into the next request without converting anything. Reformatting dates or numbers is a common cause of subtle mistakes. The engine gave you these values in the exact form it wants to receive them.
7How It Works Inside: The Journey of a Paged Search
Follow one paged request from the coordinating node to the shards and back, and you will see why the bookmark makes deep pages cheap.
Elasticsearch runs a search in two phases, called query then fetch. In the query phase, every shard finds its best matching documents in the requested order and returns only small records: the sort values and the document position. In the fetch phase, the coordinating node asks the owners of the winning documents for their full contents.
The request arrives
A coordinating node receives the search with its sort, size, and bookmark, and it decides which shard copies to ask.
Each shard receives the bookmark
The coordinating node forwards the query, the sort, the size, and the bookmark to one copy of each shard.
Each shard finds its next hits
The shard scans its matching documents and compares each one’s sort values with the bookmark. Documents that do not sort after the bookmark are skipped. The shard keeps only the best few, as many as the page size.
Shards return small records
Each shard sends back at most a page size of records, each with its sort values and a reference to the document.
The coordinating node merges
It merges the partial lists by sort values, keeps the best page size, and drops the rest.
The fetch phase collects the documents
The coordinating node asks the shards that own the winners for their sources and other requested details.
The response carries the next bookmark
Each hit comes back with its sort values, so the client can use the last one for the next request.
sequenceDiagram
participant C as Client
participant N as Coordinating node
participant S1 as Shard 1
participant S2 as Shard 2
C->>N: Search with sort, size, and search_after
N->>S1: Query phase with the bookmark
N->>S2: Query phase with the bookmark
S1-->>N: Next size hits after the bookmark, sort values only
S2-->>N: Next size hits after the bookmark, sort values only
N->>N: Merge by sort values and keep the best size
N->>S1: Fetch phase for winning documents
N->>S2: Fetch phase for winning documents
S1-->>N: Document sources
S2-->>N: Document sources
N-->>C: One page of hits, each with its sort values
Consider three ticket counters serving one long queue that is divided by surname. You say, “Give me the next twenty people after Miller.” Each counter looks at its own part of the queue, finds the people who come after Miller, and writes down the next twenty names on a slip. The manager merges the three slips, picks the first twenty overall, and only then calls those people forward. Nobody had to list all the people before Miller.
Is the cost really flat?
The cost per page is bounded in memory, since each shard keeps only a small queue. It is not always constant in time. A shard still has to scan matching documents to find those after the bookmark, comparing sort values as it goes. For a query that matches a large share of the index, and a sort that is unrelated to how the index is stored, the shard may examine many documents that come before the bookmark before it finds those that come after. Still, this work is cheap compared with maintaining huge queues and merging huge lists, and it does not grow with the memory of deeper pages.
Index sorting can help
Elasticsearch offers a feature called index sorting, which stores the documents of each segment in a chosen order. When a search sorts by the same fields, the engine can stop scanning early once it has enough hits, and with a bookmark, it can jump close to the right place. This helps for stable, high-volume walks, but it has a cost at indexing time and must be decided when the index is created.
Memory on the coordinating node
With ordinary pagination, the coordinating node receives about the number of shards times the sum of the skip and the size. With search_after, it receives about the number of shards times the size. If you have two hundred shards and a page size of one thousand, that is two hundred thousand small records, which is manageable but not trivial. A smaller page size, or fewer shards, keeps the coordinating node calm.
Why the fetch phase stays small too
The heavy fetch work is done only for the final page, since the query phase already reduced the candidates. This is the same for every page. Trimming the returned fields with source filtering makes the fetch phase lighter still, which matters for exports of large documents.
Choose the page size to balance round trips against memory. Pages of a few hundred to a few thousand hits are common for exports, and smaller pages of ten to fifty are common for user interfaces.
8Sorting Rules: Choosing the Sort Keys
The bookmark is only as good as the sort behind it. Not every field is suited for sorting, and some choices cause errors or surprises.
Sorting relies on a compact, column-like structure called doc values, which Elasticsearch builds at indexing time for most field types. Doc values let a shard read the sort value of any document quickly. A field without doc values cannot be sorted on efficiently.
| Field type | Good for sorting? | Notes for search_after |
|---|---|---|
| Date | Yes | The sort value is a number of milliseconds since 1970. A date with nanosecond precision has a different unit, so do not mix the two in one walk. |
| Integer, long, and other numbers | Yes | Sort values are numbers. Mixed number types across indices may need care. |
| Keyword | Yes | Sort values are text. Sorting is alphabetical by byte order, not by language rules. |
| Text | No, by default | Analyzed text is not sortable. Use a keyword sub-field for sorting. |
| Boolean and IP | Yes | Rarely unique, so they need a tiebreaker. |
| Score | Yes, with care | Scores can differ between shard copies and change as the index changes, so pages may shift without a point in time. |
| Geographic distance | Yes | The sort value is a distance number. Rarely unique. |
| Script-based sort | Possible, but avoid | Slow and hard to keep stable |
Sorting a class by height is easy, because everyone has a measured number. Sorting them by “how nice their handwriting looks” is difficult, because no ready number exists. Doc values are the measured numbers that the engine keeps for every document so that sorting is fast and consistent.
Direction and missing values
Each sort field has a direction, ascending or descending. The bookmark refers to a position in the direction that you chose, so after means later in that direction. If some documents lack the sort field, the missing option decides where they go: first, last, or with a chosen value. The sort values of those documents are special extreme numbers, and they must be sent back like any other value. Keep the setting the same on every page.
Fields with several values
A field can hold several values in one document, for example a list of tags or prices. For sorting, Elasticsearch needs one value per document, so the mode option chooses which one: the smallest, the largest, or a computed value. The bookmark then contains that chosen value. Because the choice affects the order, it must be the same on every page.
Searching several indices
A search can target several indices at once. If the sort field is mapped differently in some of them, for example as a date in one and a keyword in another, sorting fails or gives odd results. If an index does not have the field at all, the unmapped_type option tells the engine how to treat it. When you page through data spread over many indices, such as daily logs, make sure that the sort fields have the same type everywhere.
Sort on stable values
- Prefer immutable fields. A creation time does not change, while a “last updated” time does. Sorting on a value that changes moves documents around during the walk.
- Prefer indexed values over computed ones. Scripts are slower and can hide instability.
- Keep the mapping stable. Changing a field’s type or format during a walk invalidates the bookmark.
- Use the same sort in every request. The list of fields, their order, their directions, and their options must all match.
Relevance scores depend on statistics that differ slightly between shard copies and change as documents arrive, are deleted, or are merged. Paging by score without a frozen view can show repeated or missing results. If you must page by relevance, use a point in time, or accept small inconsistencies.
9Ties and the Tiebreaker: Why Pages Lose Documents
The most common mistake with search_after is sorting by a field that is not unique. The result is silent: pages simply skip documents, and nobody notices.
The bookmark means “give me documents that sort strictly after these values.” If several documents share the same sort values, the bookmark cannot tell them apart. A page that ends in the middle of a group of tied documents leaves some of them behind, and the next page starts after all of them.
A small example with numbers
Imagine eight documents, sorted from oldest to newest by a timestamp field. Five of them share the same timestamp, because they were created in the same second. The page size is three.
| Document | Timestamp | Unique id |
|---|---|---|
| A | 10 | 1 |
| B | 10 | 2 |
| C | 10 | 3 |
| D | 10 | 4 |
| E | 10 | 5 |
| F | 11 | 6 |
| G | 12 | 7 |
| H | 12 | 8 |
| Approach | Page 1 | Bookmark | Page 2 | Result |
|---|---|---|---|---|
| Sort by timestamp only | A, B, C | Timestamp 10 | F, G, H | Documents D and E are never returned |
| Sort by timestamp, then by unique id | A, B, C | Timestamp 10 and id 3 | D, E, F | Nothing is skipped |
| Same, third page | Not applicable | Timestamp 11 and id 6 | G, H | All eight documents were read exactly once |
flowchart TD
A["Documents A B C D E share timestamp 10"] --> B["Page 1 returns A B C, bookmark is timestamp 10"]
B --> C["Page 2 asks for timestamps strictly after 10"]
C --> D["D and E have timestamp 10, so they are not after 10"]
D --> E["D and E are silently skipped forever"]
A --> F["With a unique id in the sort, bookmark is timestamp 10 and id 3"]
F --> G["Page 2 asks for positions strictly after that pair"]
G --> H["D and E come next and nothing is lost"]
Picture a school queue sorted by age in years. Five children are exactly ten years old. You serve the first three and note, “Continue after the ten-year-olds.” The other two ten-year-olds are not “after the ten-year-olds,” so they are never served. If you had noted “Continue after the third ten-year-old, whose student number is 3,” nobody would be left out. The student number is the tiebreaker.
Ties are more common than you think
- Timestamps at low resolution. Events created in the same second or millisecond share the same time, especially with bulk loading.
- Categories and statuses. Fields with few distinct values are tied for almost all documents.
- Prices and scores. Many products have the same price, and many hits share the same relevance score.
- Imported data. Batch imports often assign one timestamp to a whole batch of documents.
The silent nature of the error
What makes this problem dangerous is that nothing fails. The requests succeed, the pages look fine, and the loop ends normally. Only a comparison of the number of documents read with the number that exist reveals the loss. Good exports therefore verify counts at the end, and good designs prevent the problem with a tiebreaker.
Ties also make the order unstable
Even the order within a group of ties is not guaranteed. Different shard copies, or the same shard at different times, may list tied documents in different orders. Without a tiebreaker, even a single page can change between two identical requests. A unique tiebreaker gives the whole list one fixed order.
Whenever you use search_after, make the last sort field unique per document, or use a point in time, which adds a unique tiebreaker for you. Do not rely on luck.
10Choosing a Tiebreaker
A good tiebreaker is unique, stable, cheap to sort on, and known when the index is designed. Here are your options and their trade-offs.
| Option | Unique across the whole index? | Cost and risks | Verdict |
|---|---|---|---|
| A business key stored as a keyword or number field, such as an order number | Yes, if your data guarantees it | Small doc values, cheap to sort | Best choice when available |
| A copy of the document id stored in its own keyword field | Yes | Extra field at indexing time, cheap to sort | Very good, and easy to add in the mapping |
| The built-in document id field | Yes | Sorting on it needs heavy in-memory structures, and recent versions disable this by default | Avoid |
| The automatic shard and document position, used with a point in time | Yes, within that point in time | Cheap, added automatically, but meaningful only for that point in time | Excellent for single walks with a point in time |
| The internal document order | No, it repeats between shards | Cheap but ambiguous | Not a tiebreaker |
| The per-shard sequence number | No, it repeats between shards | Cheap but ambiguous | Not a tiebreaker |
| A generated random value stored at indexing time | Nearly, with a large enough range | Not guaranteed unique | Only as a last resort |
To tell apart people with the same name in a large company, you use an employee number. It is unique, it never changes, and it is written on every record. Using “the order in which they walked into the room today” as a tiebreaker would be unreliable, because the order changes every day. A good tiebreaker is like the employee number.
Add the tiebreaker to the mapping early
The best time to think about tiebreakers is when you design the mapping. If every document has a unique key, map it as a keyword or a number so it has doc values. If not, copy the document id into a dedicated field at indexing time, either in the application or with an ingest step. Adding such a field later means reindexing, which is far more work.
The automatic tiebreaker of a point in time
When you search with a point in time and do not include a tiebreaker of your own, Elasticsearch adds one for you. It is a hidden position value called _shard_doc, which combines the shard and the internal document number, and it is unique within that point in time. You can also list it explicitly in your sort, for example to choose its direction. It appears as the last value in the sort array of every hit.
Why the automatic tiebreaker is not always enough
The automatic value is only meaningful for one point in time. If your export job crashes and later restarts with a new point in time, the old value refers to a position that no longer exists in the new view. To resume across points in time, you need a tiebreaker that is stored in the data, such as a business key or a copied id. This is a key point for long-running exports and is discussed again in Chapters 20 and 24.
Order of the sort fields
The tiebreaker goes last. The main sort field, such as a date, comes first, and the tiebreaker resolves ties among documents with the same main value. Putting the unique field first would sort by id alone and lose the meaning of the main field.
To test your tiebreaker, compare the number of distinct values with the number of documents. If they differ, the field is not unique, and the walk can skip documents.
11The Moving Index: Consistency Between Pages
Each request sees the index as it is at that moment. If documents change between pages, the walk can show surprising results. Knowing exactly how helps you decide whether it matters.
Without a point in time, search_after is stateless, and every request sees the current data. This is often what you want for a live feed. But for an export that must be exact, changes between requests can cause trouble.
| Change between two pages | Effect on the walk | Serious? |
|---|---|---|
| A new document sorts before the bookmark | It is never seen in this walk, because the walk only moves forward | Usually acceptable; a later walk will find it |
| A new document sorts after the bookmark | It appears in a later page | Usually welcome |
| A document already read is deleted | No effect on the remaining pages | No |
| A document not yet read is deleted | It does not appear | Usually acceptable |
| A document changes so that its sort key moves it forward past the bookmark | It can be seen twice | Yes, for exact processing |
| A document changes so that its sort key moves it backward before the bookmark | It can be missed entirely | Yes, for exact processing |
| A document changes without changing its sort key | The page shows its new content | Depends on need |
| Segments merge in the background | Scores and ordering by score may shift slightly | Only when sorting by score |
Picture reading a shared list on a whiteboard while other people keep adding, crossing out, and moving names. If you note “I am at the name Miller” and someone moves a name from the end of the list to the front, you will never see it, and if someone moves a name from the front to the end, you will see it twice. The whiteboard is the live index, and a photograph of the whiteboard is a point in time.
Different shard copies, different views
Each shard has a primary copy and possibly replica copies. Requests may be served by any of them, and copies can differ slightly in what they have refreshed or merged. Without a frozen view, two consecutive pages may be served by two copies that see the data a little differently. For sorting on immutable, unique fields, this rarely matters. For sorting by score, it can. The preference option lets you route related requests to the same copies, which reduces this effect.
When live behavior is what you want
A live feed or a “load more” button often benefits from seeing current data. New items appear as users scroll. The occasional repeated or skipped item in a very active feed is acceptable. In such cases, plain search_after with a good unique tiebreaker is exactly right, and adding a point in time would only hold extra resources.
When you need a frozen view
- Exports and migrations. Every document must be read exactly once.
- Reports and audits. Numbers must match a defined moment.
- Batch processing. A job that changes documents while reading them must not read its own changes.
- Relevance paging. Pages by score must not shift because of background changes.
Ask “does it matter if a page reflects changes made while I read?” If the answer is no, live search_after is simple and light. If the answer is yes, use a point in time. The next chapter shows how.
12Point in Time: Freezing the View
A point in time is a photograph of an index. Every search that uses it sees exactly the same data, no matter how much the index changes afterward.
The point in time feature, usually shortened to PIT, was added in Elasticsearch 7.10 to solve the consistency problem of Chapter 11. You open a PIT on one or more indices, and Elasticsearch returns an identifier, a long, opaque text value. From then on, you can run searches against that identifier, and every search sees the index exactly as it was when you opened the PIT.
How it works in simple terms
Remember that shards store data in read-only segments, and a search works with a consistent set of segments called a reader. When you open a PIT, each shard keeps its current reader alive, even if new segments appear or old ones are merged away. Searches with the PIT identifier use those preserved readers. New documents, updates, and deletions made afterward are invisible to them. The photograph stays sharp until you close it or it expires.
Think of a librarian who, on your request, sets aside a copy of the library catalog exactly as it looks at noon and puts your name on it. All afternoon you can look things up in that copy, even though new books keep arriving and old ones are being removed in the real catalog. When you tell the librarian you are done, or when you have not visited for too long, the reserved copy is thrown away. The reserved copy is the PIT.
sequenceDiagram
participant A as Application
participant E as Elasticsearch
A->>E: Open a point in time on the index with a keep alive
E-->>A: Point in time identifier
A->>E: Search page 1 using the identifier, sort, and size
E-->>A: Hits with sort values and a possibly updated identifier
A->>E: Search page 2 using the latest identifier and search_after
E-->>A: Hits with sort values and a possibly updated identifier
A->>E: Close the point in time using the latest identifier
E-->>A: Confirmation
The keep alive time
When you open a PIT, you set a keep_alive value, such as one minute. This is not the total lifetime of the PIT. It is the time that the PIT stays alive after its last use. Each search that uses the PIT can include its own keep alive value, and that extends the life again. As long as you keep asking within the interval, the PIT lives on. If you go quiet for longer than the interval, Elasticsearch releases it.
The identifier can change
Each search response using a PIT contains an identifier. It may be different from the one you sent. Always use the identifier from the most recent response for the next request. Treat it as a value that can be renewed.
Special rules for searches with a PIT
- No index name. The PIT already defines which indices are searched, so the search request does not name them.
- No routing or preference. The PIT has fixed which shard copies hold the frozen view, so these options do not apply.
- Any query. Unlike scroll, a PIT is not bound to one query. You may run different queries against the same frozen view.
- Automatic tiebreaker. As Chapter 10 explained, a unique position value is added to the sort unless you add your own.
Closing the PIT
When the walk is finished, close the PIT explicitly with the delete operation, passing the latest identifier. This releases the resources on the shards at once instead of waiting for expiry. A good program closes the PIT even when errors occur, in the same way that it closes files.
What a PIT does not do
It does not copy data. It does not protect you from deletion of the whole index, because deleting an index removes its files. It does not make the results permanent; it is a temporary view. And it does not survive a restart of the nodes that hold it or the movement of a shard to another node, because the preserved readers live in the memory and files of those nodes.
A PIT shares the shard’s existing readers, so opening one is quick and light. Scroll contexts, by contrast, are heavier and tied to a single query. This is why the documentation prefers PIT for deep pagination.
13search_after with a Point in Time, Step by Step
Together, the two features give an exact, resource-friendly way to read every matching document once and only once.
Open the point in time
Name the indices and choose a keep alive time that is a bit longer than the time you need to process one page. Store the identifier you receive.
Send the first search
Use the identifier in the pit section, give the query, the sort, the page size, and no bookmark. Leave out the index name. Consider switching off total hit counting.
Process the page
Handle the hits. Note that each hit’s sort array ends with the automatic position value if you did not add your own tiebreaker.
Remember the bookmark and the newest identifier
Keep the sort array of the last hit, and keep the identifier from the response, which may differ from the earlier one.
Send the next search
Repeat the same query and sort, add the bookmark in search_after, and use the newest identifier in the pit section with the keep alive time.
Stop on an empty page
When no hits come back, the walk is complete. Check the shard failure count of the last responses before you trust the result.
Close the point in time
Release the resources with the latest identifier, and do this in a cleanup step that runs even after failures.
flowchart TD
A["Open PIT with a keep alive"] --> B["Search page with pit, sort, size"]
B --> C{"Any hits?"}
C -->|No| G["Verify shard failures, then close the PIT"]
C -->|Yes| D["Process hits"]
D --> E["Save last sort values and the newest PIT id"]
E --> F["Search next page with search_after and the newest PIT id"]
F --> C
Imagine taking a photograph of a long wall of name tags, and then reading the tags from the photograph in batches, noting the last name each time. No matter how many people move around the real wall while you read, your batches never repeat a name or skip one, because you always read from the same photograph. The bookmark tells you where you stopped, and the photograph guarantees that the wall you read from never changes.
Choosing the keep alive
Pick a value that comfortably covers the time between two requests, including the time to process a page and to retry after a hiccup. One to five minutes is common. A very long value wastes resources when a client crashes, since the PIT lingers. A too-short value risks expiry during a slow step. Because each request extends the life, the value need not cover the whole walk.
Combining an automatic and an explicit tiebreaker
For simple, single-run walks, the automatic tiebreaker is enough. For jobs that must be able to resume after a crash, add your own unique field to the sort. You may even keep both: the business key first, so that a resumed walk under a new PIT can continue from a stored bookmark, and the automatic value last, which does no harm.
Processing while reading
If your job changes the documents it reads, for example by updating them, the PIT keeps its frozen view, so the job does not see its own updates and does not loop. Be aware that the changes still create new segments and may lead to extra disk use while the PIT lives, which Chapter 18 explains.
Checking for completeness
At the end of an exact walk, compare the number of documents processed with an independent count taken at the same PIT, if the count matters. Since the PIT is frozen, a count query against it returns a number that must match your processed total. This simple check catches configuration errors such as a non-unique tiebreaker.
Sending an older PIT identifier after a newer one was issued can fail or behave unexpectedly. Update your variable after every response, and close the latest one at the end.
14Comparing Pagination Methods
Elasticsearch offers several ways to read many results. Each fits a different need, and choosing the right one saves both effort and cluster resources.
| Method | Depth | Consistent view? | Server state | Jump to a page? | Best for |
|---|---|---|---|---|---|
| From and size | Limited by the result window, ten thousand by default | No | None | Yes | Shallow paging in search result pages |
| Scroll | Unlimited | Yes, frozen at the first request | A search context per scroll | No | Legacy exports, and internal jobs such as reindexing |
| Search after alone | Unlimited | No, it is live | None | No | Feeds, infinite scroll, live “load more” |
| Search after with a point in time | Unlimited | Yes | A light point in time | No | Exact exports and batch jobs, and dependable deep paging |
| Point in time with slices | Unlimited | Yes | A light point in time shared by workers | No | Very large exports read in parallel |
flowchart TD
A["I need to read many results"] --> B{"Only the first few pages, with jumping between them?"}
B -->|Yes| C["Use from and size"]
B -->|No| D{"Must every document be read exactly once?"}
D -->|No| E["Use search_after alone with a unique tiebreaker"]
D -->|Yes| F["Use search_after with a point in time"]
F --> G{"Is the export huge and time critical?"}
G -->|Yes| H["Add slices and read them in parallel"]
G -->|No| I["Use one sequential walk"]
Consider four ways of reading a big encyclopedia. You can open it at a page number, which is quick for pages near the front. You can ask a clerk to copy the whole book for you before you start, which is the scroll. You can leave a bookmark and read on from there, which is search after. Or you can leave a bookmark in a sealed copy that nobody may edit, which is search after with a point in time. Each way suits a different reader.
Why scroll is no longer the first choice
Scroll works, but it is heavier, it binds the context to a single query, and it is not flexible about changing the query mid-way. Its state must be kept on the server for every reader, and it competes for a limited number of open contexts. Elasticsearch still uses scroll-like behavior internally for tasks such as reindexing, and older applications rely on it, but new code is advised to use a PIT with search after.
Mixing methods in one product
A common design uses from and size for the first few pages of a search result screen, where jumping is useful, and switches to search_after when the user goes deeper or when a background job needs everything. This combination gives users the comfort of page numbers where they matter and the efficiency of bookmarks where depth matters.
Cost summary in one sentence each
- From and size: cost grows with the page number, and it stops at the result window.
- Scroll: cost is steady, but it holds a heavy context.
- Search after alone: cost is steady and stateless, but the view is live.
- Search after with a point in time: cost is steady, the view is frozen, and the held resource is light.
When in doubt about a deep read, choose search_after with a point in time, add a unique tiebreaker you control, and keep the page size moderate. It is the combination that the documentation itself recommends.
15Going Back and Jumping Around
A bookmark points forward. Yet users want a Previous button and sometimes a page number box. There are honest ways to offer these, with limits.
The search_after option is a forward-moving tool. It says “what comes after this position.” It has no built-in way to say “what came before,” and it cannot jump to the fiftieth page without walking there. Still, applications can build the missing features.
The cursor stack for Previous
The simplest way to go back is to remember the bookmarks you used. Every time the user moves forward, the application pushes the bookmark used for the page just shown onto a stack. To go back, it pops one bookmark and requests that page again with it. Since each stored bookmark points to the position just before the page, the same page comes back, subject to whatever changes happened in the live index in the meantime.
Think of a hiker who drops a colored ribbon at each junction of a trail. To return, the hiker follows the ribbons in reverse order. The hiker does not need to remember the whole path, only the ribbons. The stack of stored bookmarks is a trail of ribbons.
The reverse sort trick
A second way to go backward is to flip every direction in the sort, use the sort values of the first hit of the current page as the bookmark, and ask for a page. The engine returns the documents that sort just before the current page, but in reverse order. The application reverses the list before showing it. This trick also gives a “Last page” button: ask for the first page with all directions reversed and reverse the result.
| User need | How to serve it | Caveat |
|---|---|---|
| Next page | Use the sort values of the last hit as the bookmark | None |
| Previous page | Pop a stored bookmark, or reverse the sort and use the first hit’s values | The page may differ slightly if the live index changed |
| First page | Search again without a bookmark | None |
| Last page | Reverse all sort directions and take the first page, then reverse the list | The size of the last page depends on the total count |
| Jump to page N | Use from and size if N is shallow, otherwise walk, or offer no jumping | Deep jumps are not supported cheaply |
| Show the total number of pages | Count once with a separate request | Counts up to a limit unless tracking is enabled, and the total may change |
Why random jumping is not offered
To jump to page fifty, the engine would need to know the sort values at the start of page fifty, and those exist only after computing the first forty-nine pages. There is no shortcut, because the bookmark is a value taken from the data, not a number counted from the start. Some applications precompute bookmarks for every hundredth page in the background, and offer coarse jumps. Others redesign the interface: filters, search boxes, and date pickers help users reach the region they need in a few steps instead of paging.
User interface designs that fit
- Infinite scroll or “load more.” Perfect for forward-only reading, and the bookmark is all you need.
- Next and previous buttons. Served by the cursor stack.
- Filters and date jumps. Instead of page numbers, let users narrow the results.
- Approximate page counts. Show “more than ten thousand results” instead of an exact page count.
Very few users read past the first pages of any result list. If you offer page numbers up to a shallow limit with from and size, and bookmark-based loading beyond it, you serve both the common and the rare reader well.
16Reading in Parallel with Sliced Point in Time Searches
A single walk reads one page at a time. For very large exports, slices let several workers read different parts of the same frozen view at once.
A slice is a disjoint part of the result set. When you ask for slice number two out of eight, you receive a well-defined eighth of the matching documents, and the other seven slices contain the rest, with no overlap. The slice option takes two numbers: the identifier of the slice and the total number of slices. Each worker reads its own slice with its own bookmark, all against the same point in time.
flowchart TD
A["Open one point in time"] --> B["Slice 0 of 4"]
A --> C["Slice 1 of 4"]
A --> D["Slice 2 of 4"]
A --> E["Slice 3 of 4"]
B --> B1["Worker 0: own search_after loop"]
C --> C1["Worker 1: own search_after loop"]
D --> D1["Worker 2: own search_after loop"]
E --> E1["Worker 3: own search_after loop"]
B1 --> F["Downstream system: all documents exactly once"]
C1 --> F
D1 --> F
E1 --> F
Imagine a huge warehouse inventory that must be counted before closing. Instead of one person walking every aisle, the manager freezes the ledger, divides the warehouse into four zones, and sends four counters, each with their own clipboard and bookmark. Everyone works at the same time, and together they count every item exactly once. The frozen ledger is the point in time, and the zones are the slices.
Rules for slicing
- One point in time for all slices. The slices are consistent only if they share the same frozen view.
- Same query and sort in all slices. Only the slice identifier differs between workers.
- No global order. The order of documents is guaranteed only inside one slice. Do not expect worker zero’s last page to come before worker one’s first page.
- Bookmarks per slice. Each worker keeps and, if needed, checkpoints its own bookmark.
- Keep alive for everyone. Each search extends the life of the shared point in time, so make sure that at least one worker’s requests keep it alive, or that each uses a keep alive that covers gaps.
How many slices?
More slices mean more parallelism, but also more load on the cluster at once. A sensible start is a number of slices that is not more than the number of shards, since slicing along shard boundaries is the cheapest way to divide the work. Using many more slices than shards can make the division itself more expensive. Check the documentation of your version for details, and measure the effect on cluster load while the export runs.
Do not overload the cluster
Four workers reading pages of a thousand hits produce four times the load of one worker. On a cluster that also serves live searches, a large parallel export can hurt user latency. Run exports at quiet times, limit the number of workers, use moderate page sizes, and watch search latency while the job runs.
Handling failures with slices
If one worker fails, the others can continue. The failed worker’s slice can be resumed from its last saved bookmark, as long as the point in time is still alive. If the point in time has expired, a resumed worker needs a new point in time and a business-unique tiebreaker so that it continues from the stored bookmark under the new view. Because the new view may differ from the old one, the job must tolerate small differences or must verify counts afterward.
Parallel reading finishes sooner but uses more of the cluster at the same time. Treat the number of slices as a dial for balancing speed against the impact on other users.
17Performance: What Makes a Page Fast or Slow
The bookmark already removes the biggest cost of deep paging. A handful of other choices decide how quickly each page arrives.
| Lever | Effect | Risk or limit |
|---|---|---|
| Page size | Larger pages mean fewer round trips but more memory on shards and the coordinating node | Very large pages strain memory and increase latency per request |
| Total hit counting | Switching it off avoids counting all matches on every page | You will not get an exact total from those requests |
| Source filtering | Returning only needed fields makes the fetch phase lighter | You must know which fields you need |
| Doc value fields instead of the source | Reading a few fields from doc values can be much faster than loading whole documents | Values come in a normalized form, such as sorted arrays for multi-valued fields |
| Filter context instead of scoring queries | Skips scoring work when the sort does not use the score | Filters do not rank by relevance |
| Sorting on doc values fields | Fast, memory-friendly comparisons | Fields need doc values, and text fields are not sortable |
| Index sorting matching the sort | Enables early termination for repeated walks in the same order | Must be chosen at index creation and slows indexing a little |
| Fewer, well-sized shards | Less merging on the coordinating node, fewer parallel tasks | Shard layout affects many other things |
| Consistent shard copies | Warm caches and stable order when not using a point in time | Slightly less even load distribution |
Delivering packages from a warehouse is faster when each truck carries a sensible load, when the labels on the boxes are easy to read, and when the drivers do not stop to weigh every box. Page size, doc values, and switching off unneeded counting are the same kind of savings for a search request.
Page size in practice
For user interfaces, a page of ten to fifty is typical. For exports, hundreds to a few thousand work well. There is a sweet spot: very small pages cause too many round trips, and very large pages create heavy responses that take long to serialize and to process. Try several sizes with your data, and choose the one where throughput stops improving.
Sorting by fields, not by score
When a search sorts by a field and not by score, Elasticsearch does not need to compute scores, which saves time. If your query is just a filter with no relevance needs, express it in a filter context and sort by a stable field. This combination is the fastest way to walk large sets of documents.
Keep the request shape stable
Because every page repeats the same query and sort, the engine can reuse work such as parsed queries and warmed file caches. Do not vary the query between pages, and avoid adding unneeded features such as highlighting or aggregations to every request of a walk. Aggregations should be computed once, in a separate request.
Measure with the reported time
Each response contains a took value, the time in milliseconds spent inside Elasticsearch. Compare it across pages in a walk. If it stays flat, your setup works as intended. If it rises steadily, something unexpected is happening: a sort that is not using doc values, a filter that scans too much, or a cluster under load.
Before launching a feature, test the request for a page far into the results, as well as the first page. If the deep page is as fast as the first, your configuration is sound.
18The Resource Cost of a Point in Time
A frozen view is not free. It holds old files on disk and small structures in memory for as long as it lives. Small habits keep that cost tiny.
To keep the photograph sharp, each shard must keep the segments that were current when the PIT was opened, even if merges would normally replace and delete them. So, as long as the PIT lives, disk space that would have been reclaimed remains in use.
stateDiagram-v2
[*] --> Open
Open --> InUse: A search uses the PIT and extends the keep alive
InUse --> InUse: More searches keep it alive
InUse --> Closed: The application closes it
InUse --> Expired: No search within the keep alive time
Expired --> [*]
Closed --> [*]
Reserving a table at a restaurant means that the table stays empty for you, even if other guests are waiting. A reservation held for a few minutes is harmless. A reservation held all evening by someone who never arrives wastes a table that others could use. Point in time contexts work the same way, and the keep alive time is the length of the hold.
What is held
- Old segments on disk. If the index has heavy updates or merges during the life of the PIT, more old files stay behind. The extra space depends on how much the index changes.
- Small in-memory structures. Each shard keeps a reader and some bookkeeping. This is small for one PIT, but it adds up with many.
- Open contexts. Each PIT creates a context on every shard that it covers. Clusters have limits on the number of open search contexts.
A small sizing example
Imagine fifty exports running at the same time, each with a PIT over an index that has twenty shards. That creates one thousand open contexts. If your cluster’s limit or practical budget is lower, some exports will fail with errors about too many contexts. This example is invented to show the arithmetic, but it explains why large numbers of long PITs need planning.
Habits that keep the cost low
- Short keep alive. Use just enough to cover the gap between requests.
- Close when done. Release resources immediately instead of waiting for expiry.
- Limit concurrency. Cap the number of simultaneous exports.
- Prefer smaller scopes. Open PITs on the specific indices you need, not on wildcard patterns that cover everything.
- Do not open one per user request. Interactive interfaces that need only “load more” usually do not need a PIT at all.
- Watch disk margins. On heavily updated indices, leave free space for the segments that PITs keep alive.
How to see what is open
The node statistics for search report the number of open contexts on each node. A number that rises and does not come back down points to PITs or scrolls that are never closed. Chart this number over time, and alert on sudden growth.
A program that opens a PIT and crashes without closing it leaves the context to expire on its own. With a short keep alive, the cost is tiny. With a very long one, and many crashes, it accumulates. Short keep alive times are the best protection against leaks.
19Advantages, Disadvantages, and Trade-offs
The bookmark approach trades some convenience for a large gain in efficiency. Here is an honest scorecard so that you can decide when to use it.
Advantages
- The cost per page stays about the same at any depth.
- Memory use on shards and coordinating nodes stays small and predictable.
- There is no ten thousand hit ceiling from the result window.
- It is stateless without a point in time, so the cluster keeps no cursors for users.
- Combined with a point in time, it gives an exact, frozen, repeat-free walk.
- It avoids the shifting pages that plague skip-based paging on busy indices.
- It suits infinite scroll, feeds, exports, and batch jobs very naturally.
- Bookmarks are tiny and can be stored to resume a job later.
Disadvantages
- It moves forward only, and it cannot jump to page numbers.
- It needs a sort, and the sort must end with a unique field to be complete.
- Without a point in time, the view is live, so changes can cause repeats or misses.
- A point in time holds old segments and contexts while it lives.
- Sort values must be carried exactly by the client, which adds application work.
- Going backward and showing total pages need extra design.
- Score-based paging is fragile without a frozen view.
- Silent data loss is possible if the tiebreaker is not unique.
The central trade-off: simplicity against efficiency
Skip-based paging is the simplest to understand and the easiest to build. The bookmark is only slightly more complicated, but it requires the application to store values and to design its interface around forward movement. In return, it removes the depth limit and the growing cost. For shallow reading, simplicity wins. For deep reading, efficiency wins.
The second trade-off: freshness against consistency
A live walk shows the newest data but may repeat or miss documents that change during the walk. A walk with a point in time shows a consistent view but hides changes made after it started and holds resources. The right choice depends on whether exactness or freshness matters more for the task.
The third trade-off: keep alive against cost
A long keep alive time protects a slow job from expiry, but it also leaves resources held longer if the job crashes. A short time saves resources but demands quick processing between pages and good retry logic. Balancing the two is part of designing a robust job.
The fourth trade-off: parallelism against load
Slices finish large jobs sooner but load the cluster harder. On a shared cluster, speed must be balanced against the needs of live users.
When search_after is the wrong tool
If users need to jump to arbitrary pages and the result lists are short, use skip-based paging. If you need aggregated numbers, use aggregations instead of walking documents. If you need a one-time bulk copy between clusters or indices, purpose-built features such as reindexing or snapshots may be simpler and faster than a custom walk.
A bookmark is a wonderful way to continue reading a novel. It is not a good way to look up a fact on page 312 of a reference book, where an index or a page number works better. The right tool depends on how you read.
20Best Practices and Design Patterns
Reliable pagination is mostly a matter of a few good habits, chosen at design time. These eight habits cover almost every case.
Design a unique key into the mapping
Give every document a keyword or numeric field that is unique, immutable, and has doc values. Use it as the last sort field.
Use a point in time for exact reads
Exports, migrations, and batch jobs should always walk a frozen view and close it when finished.
Checkpoint the bookmark after each page
Store the last sort values durably after each processed page, so that a crashed job resumes instead of restarting.
Make consumers idempotent
Design the receiving side so that processing the same document twice does no harm, for example by keying writes on the document id.
Use opaque cursor tokens in APIs
Do not expose raw sort values to callers. Wrap the bookmark and needed context in a signed, expiring token.
Hybrid paging in user interfaces
Use skip-based paging for the first few pages, and switch to bookmark paging beyond a chosen depth.
Verify counts at the end
Compare the number of documents processed with a count from the same frozen view, and treat a mismatch as a failure.
Keep the request identical across pages
Build the query and sort once, and reuse them unchanged, adding only the bookmark and the newest point in time identifier.
Pattern 3 in action
A nightly job copies three hundred million documents into a warehouse. After each page of two thousand documents, it writes the last sort values into a small table together with a job identifier. On the tenth night, the network fails at the two hundredth million. The next morning, the job reads the stored bookmark, opens a new point in time, and continues from the same position, using the business key in the sort. It finishes the remaining hundred million documents without repeating the first two hundred million. The job’s design took an afternoon and saved a whole night of work.
Pattern 4 in action
The same warehouse job writes each document to the target using the document id as the key, so that a repeated write replaces the same record. When the resumed job re-reads the last page before the crash, the duplicates overwrite themselves, and the target ends up with exactly one record per document.
Pattern 6 in action
A product search page shows page numbers up to page ten using skip-based paging, which costs little at that depth. A “show more results” button beyond that uses bookmark paging with a signed token. Ninety-nine percent of visitors never go beyond page three, and the rest get a fast, safe experience.
A pattern for documentation
Write a short design note for each paged feature: the query, the sort with its unique key, the page size, whether a point in time is used, the keep alive time, the maximum depth allowed, and how failures are handled. When someone changes the mapping or the sort later, this note shows what depends on them.
The best pagination code is short and predictable. A loop, a bookmark, a checkpoint, and a cleanup step are enough. Cleverness in this area usually hides bugs that appear only at scale.
21Anti-Patterns and Common Mistakes
These are the traps behind most pagination bugs. Each is common and easy to avoid once you know it.
What people do
They sort only by a timestamp or another field that many documents share, and page through the results.
Why it hurts
Documents that tie with the last hit of a page are skipped without any error. The export looks complete but lacks records.
Better approach
End the sort with a unique field, or use a point in time and its automatic tiebreaker, and verify counts at the end.
What people do
They raise the result window to hundreds of thousands so that skip-based paging keeps working.
Why it hurts
The safety limit is gone, and the cost per deep page remains. A few large requests can exhaust memory on shards and coordinating nodes.
Better approach
Keep the default window, and use bookmark paging for deep reads.
What people do
They sort by a field that changes, such as a last-updated time, during an export.
Why it hurts
Updated documents move in the order and can be read twice or missed altogether. The job may even never finish if updates outpace reading.
Better approach
Sort by an immutable field such as a creation time and a unique key, and use a point in time so that the view is frozen.
What people do
They change the query, the sort, or the mapping between pages of one walk.
Why it hurts
The bookmark refers to a position in a specific order. In another order, it points to a different place or to nothing, and errors or skipped documents follow.
Better approach
Keep the query and the sort identical for the whole walk. Start a new walk if either changes.
What people do
They open a point in time for every user request in an interactive interface and never close it.
Why it hurts
Contexts pile up on the shards, old segments are held on disk, and the cluster can hit its context limits during busy hours.
Better approach
Use plain bookmark paging for interactive “load more” features, and reserve points in time for jobs that need exactness. Always close them.
What people do
They treat an empty or short page as success without checking whether some shards failed.
Why it hurts
A search can return partial results when a shard fails. A job may then stop early or skip a part of the data, and nobody notices.
Better approach
Inspect the shard failure count of every response, or ask for the request to fail on partial results, and retry.
What people do
They sort by the built-in document id field as a tiebreaker without checking the cost.
Why it hurts
Sorting on that field needs heavy in-memory structures, and recent versions disable it by default, so requests may fail or use large amounts of heap.
Better approach
Copy the id into a dedicated keyword field with doc values, or use the automatic tiebreaker of a point in time.
Other frequent mistakes
- Sending a non-zero skip value together with the bookmark.
- Reformatting sort values instead of sending them back untouched.
- Using an outdated point in time identifier after a newer one was issued.
- Choosing a page size so large that responses time out.
- Using scroll in new code where a point in time would be lighter.
- Forgetting to close points in time when errors occur.
- Assuming that pages of a live walk are stable snapshots.
22Designing an Application Cursor, and Security Considerations
When your own API hands bookmarks to callers, you become responsible for keeping them safe, honest, and useful. A small amount of design prevents leaks and abuse.
Most applications do not let browsers talk to Elasticsearch directly. A web or mobile client calls your application service through a load balancer or an API gateway. The service queries Elasticsearch and returns a page of results with something the client can use to ask for the next page. What should that something be?
flowchart LR
A["Browser or mobile app"] -->|Page request with cursor token| B["API gateway with rate limits"]
B --> C["Application service"]
C -->|Decode and verify token| D["Build search with sort and search_after"]
D --> E["Elasticsearch cluster"]
E --> F["Hits with sort values"]
F --> G["Application builds a new signed cursor token"]
G -->|Page of results plus next cursor token| A
Why not return raw sort values?
- They reveal data. Sort values are copies of field values. They might contain identifiers, times, or names that the caller should not learn.
- They can be tampered with. A caller could craft arbitrary bookmarks to probe positions or to request strange combinations that your code did not expect.
- They tie your API to your internal mapping. Changing the sort fields later would break every client that stored bookmarks.
Opaque, signed tokens
The safer design is an opaque cursor token. The service packs the bookmark, the identifier of the query or filter settings, the user identity, an expiry time, and possibly the point in time identifier into one text value, then encodes and signs it with a secret key. The client only stores and returns the token. On the way back, the service verifies the signature, checks the expiry and the user, and rebuilds the search from trusted parts.
A cloakroom gives you a numbered ticket, not the key to the cloakroom. The ticket says nothing about what is inside, cannot be altered without being noticed, and is valid only for a limited time and only at that cloakroom. An opaque, signed cursor token is the same kind of ticket for a place in a result list.
| Risk | What could happen | Protection |
|---|---|---|
| Raw sort values exposed to callers | Private field values leak in responses | Wrap values inside an opaque token |
| Forged or altered bookmark | A caller probes data order or triggers errors | Sign tokens and verify them on every request |
| Token reused by another user | One user continues another user’s walk | Bind the token to the user identity and check it |
| Point in time identifier leaked | Another party could search the frozen view | Keep the identifier server side or inside a signed, encrypted token, and use short lifetimes |
| Endless scraping through pages | Bulk copying of your data through the API | Rate limits, maximum depth, and authentication on the gateway |
| Oversized page requests | Cluster overload | Enforce a maximum page size in the service |
| Traffic captured on the network | Queries and results exposed | Encrypt connections between clients, the service, and the cluster |
| Sensitive values in logs | Bookmarks and queries appear in logs and slow logs | Limit log content and access, and set short retention |
Access control still applies
Elasticsearch checks the privileges of the user or service account on every search, including searches that use search_after. Roles that limit which indices, documents, or fields a user may read are applied to each request. Creating a point in time also needs read access to the target indices. Use dedicated service accounts with the least privileges that the feature needs, and never give an export job wider rights than its indices require.
Do not share point in time identifiers between users
Treat the identifier as a secret that grants access to a frozen view of the data. Do not put it into URLs, logs, or browser storage in the clear. In most designs, the identifier never needs to leave the server side.
Limit depth and rate for public APIs
For APIs exposed to the public, agree on a maximum number of pages or documents that one caller may read, and enforce it in the gateway or the service. Bookmark paging makes deep reading cheap for the cluster, which also makes it cheap for scrapers. Rate limits and quotas protect both your data and your budget.
If your data includes personal information, remember that exports and cursor tokens are copies and traces of it. Confirm with your security and legal teams how long they may exist, who may access them, and how they are protected.
23Monitoring, Logging, and Metrics
A healthy paging feature is quiet. A few numbers show early when pages slow down, contexts leak, or an export loses documents.
| Question | Where to look | What healthy looks like |
|---|---|---|
| Is each page as fast as the first? | The reported time of each response, tracked by page number | Roughly flat as the walk goes deeper |
| How many search contexts are open? | Node statistics for search, which report open contexts | Stable, returning to a low baseline after jobs finish |
| Are searches being rejected? | Thread pool statistics for the search pool, and rejection errors in client logs | Zero or very rare |
| Are shards failing during walks? | The shard failure counts in responses, logged by the client | Always zero for exact jobs |
| Are points in time expiring unexpectedly? | Client errors that mention a missing search context | None during normal jobs |
| Did the export lose or repeat documents? | Processed count versus a count taken on the same frozen view | The two numbers match exactly |
| Is the disk growing because of held segments? | Disk usage on data nodes during long walks | Small, temporary growth |
| Are slow pages visible to operators? | The search slow log, which records the query body including the bookmark | Few entries, and none from routine jobs |
A courier company tracks how long each delivery takes, how many trucks are on the road, how many parcels were returned, and whether the count at the depot matches the count of delivery slips. When these numbers are steady, nobody worries. When one drifts, the manager investigates. The same simple accounting keeps paging jobs trustworthy.
Metrics worth charting
- Page latency by depth. A line that rises with depth reveals a configuration problem.
- Open contexts per node. Shows leaks and overload.
- Search rejections. A sign of cluster saturation.
- Documents per second for each export. Shows throughput and the effect of tuning.
- Checkpoint age for running jobs. Shows whether jobs are progressing.
- Count mismatches. The most important quality metric for exact jobs.
- Coordinating node heap and garbage collection. Large pages and many slices show up here first.
Logging in the application
Log, for every job, the job identifier, the query fingerprint, the page number, the number of hits, the time taken, the shard failure count, and the current checkpoint. Do not log raw bookmarks or documents if they contain private data. With these logs, you can reconstruct exactly what happened in any run.
Alerts that help
Create a short list of alerts. First, an alert when open contexts on any node stay high for more than a set period. Second, an alert when an export reports a count mismatch or shard failures. Third, an alert on search rejections. Fourth, an alert when page latency at depth rises above a limit. Fifth, an alert when a job has not advanced its checkpoint for a long time. Each alert should link to a short runbook.
Build the final count comparison into the job itself, so that a mismatch fails the job loudly. A check that requires a human to remember is a check that eventually gets skipped.
24Failure Scenarios and Recovery
Long walks meet failures: expired contexts, restarts, overloaded nodes, and half-finished pages. A robust job expects them and knows how to continue.
flowchart TD
A["A page request fails"] --> B{"What kind of error?"}
B -->|Timeout or rejection| C["Wait with growing delay, then retry the same page"]
B -->|Missing search context| D["Open a new point in time"]
D --> E["Resume from the last saved bookmark with the same query and sort"]
B -->|Shard failures reported| F["Retry the page, and fail the job if failures persist"]
B -->|Sort or bookmark error| G["Stop: query, sort, or mapping changed. Start a new walk"]
C --> H["Continue the loop"]
E --> H
F --> H
Scenario: the point in time expired
If the job pauses longer than the keep alive time, the shards release the frozen view. The next request fails with an error saying that no search context was found. The recovery is to open a new point in time and continue from the last saved bookmark, using the same query and sort. This works only if the sort contains a stored, business-unique tiebreaker, because the automatic position value belongs to the old point in time. The new view may differ from the old one, so the job should tolerate small changes or check counts at the end.
Scenario: a node restarts or a shard moves
The frozen view lives on specific nodes. If one of them restarts, or a shard relocates, the context is lost, and the point in time fails in the same way as an expired one. The same recovery applies. If this happens often, review why nodes restart, and schedule big jobs away from maintenance windows and rebalancing.
Scenario: some shards fail during a page
A search can succeed partially. The response lists how many shards failed, and the hits come only from the others. An exact job must not accept such a page. The best practice is to check the failure count on every response, and either retry, or request that a partial result be treated as an error. Otherwise the job may silently miss a whole shard’s worth of documents.
Scenario: timeouts and rejections
Under heavy load, the cluster may reject requests when its queues are full, or a request may time out. Searches are read-only, so retrying the same page is safe. Use growing delays between attempts, and reduce the page size or the number of parallel workers if the problem persists.
Scenario: duplicates or misses in a live walk
Without a point in time, updates and deletions during the walk can cause repeats or misses, as Chapter 11 explained. Design the consumer to be idempotent to handle repeats, and use a later reconciliation pass, for example by comparing counts or by running the walk again for documents changed since the start, to catch misses.
Scenario: the mapping changed mid-walk
If a sort field changes type or format while a walk is in progress, bookmarks no longer match, and requests fail with errors about the type of a value. Stop the walk, fix the cause, and start a new one. Do not attempt to continue with converted values unless you are sure of the meaning.
A long road trip needs a plan for breakdowns. If the car stalls, you wait and try again. If the rental agency withdraws the car, you get a new one and continue from the last town on the map. If the map itself changes, you stop and plan again. You never drive back to the start unless the map says so. A paging job with saved bookmarks behaves like a well-prepared traveller.
Scenario: the client crashes
A crash leaves the point in time to expire and the job to be restarted. The stored checkpoint tells the new run where to continue. Without a checkpoint, the job starts from the beginning, which is safe but slow. With a short keep alive, the abandoned point in time disappears quickly and costs little.
Habits that make recovery easier
- Store a checkpoint after every page, or every few pages.
- Include a business-unique tiebreaker in the sort for resumable jobs.
- Make consumers idempotent.
- Check shard failures and final counts automatically.
- Close points in time in a cleanup step that always runs.
If you lose the last bookmark, do not invent one from a rough timestamp. You may skip or repeat a large range of documents. Restart from a known checkpoint, or from the beginning, and use idempotent writes to make repetition harmless.
25Troubleshooting Common Problems
When something looks wrong, match the message or symptom to its likely meaning. This table covers what people meet most often.
| What you see | Most likely meaning | First things to check |
|---|---|---|
| Result window is too large, the sum of from and size must be at most ten thousand | The request uses skip-based paging beyond the default limit | Switch to search_after, with a point in time if exactness matters |
| The request complains that a sort is required for search_after | No sort was given | Add a sort with a unique last field |
| The request says that from must be zero when search_after is used | A skip value was sent together with the bookmark | Remove the skip value, or set it to zero |
| The number of search_after values differs from the number of sort fields | The bookmark does not match the sort | Send exactly one value per sort field, in the same order |
| A bookmark value cannot be parsed, or has the wrong type | The value was converted, or the field type differs across indices | Send the sort values back untouched, and check mappings across indices |
| No search context found for the identifier, or a search context missing error | The point in time expired, was closed, or was lost when a node restarted | Open a new point in time and resume from the saved bookmark |
| The request says that indices cannot be used with a point in time | An index name was given in the path together with a PIT | Remove the index name, since the PIT defines the target |
| The request says that routing or preference cannot be used with a point in time | Options that conflict with the frozen view were sent | Remove them from searches that use a PIT |
| Fielddata is disabled on text fields | The sort uses an analyzed text field | Sort on a keyword sub-field instead |
| Sorting on the document id field fails or uses much memory | The built-in id field is not meant for sorting in recent versions | Use a copied id in a keyword field, or the automatic tiebreaker of a PIT |
| Some documents never appear in the walk | The sort is not unique, so tied documents were skipped | Add a unique tiebreaker and compare counts |
| Some documents appear twice | A live walk with a changing sort field, or shard copies that differ | Use a point in time, sort on immutable fields, and make consumers idempotent |
| Pages get slower as the walk deepens | Scoring or scripts in the query, sort without doc values, or an overloaded cluster | Use filters, sort on doc values fields, and check load and page size |
| Errors about too many open search contexts | Points in time or scrolls are not closed, or too many run at once | Close contexts, shorten keep alive, and limit concurrent jobs |
| Score-sorted pages shift or repeat | Scores change between copies or as data changes | Use a point in time, or add preference for the same shard copies |
A calm way to debug
Read the message carefully
Most errors about pagination say exactly which rule was broken: missing sort, wrong count of values, or a forbidden option.
Reproduce with two pages
Take the smallest example: the first page and the second page, with a small page size. If two pages misbehave, the design is wrong, not the scale.
Look at the sort arrays
Print the sort array of the last hit of page one, and compare it with what you sent for page two. They should be identical.
Check uniqueness
Compare the number of distinct values of your tiebreaker with the number of documents. If they differ, fix the tiebreaker first.
Check the frozen view
Decide whether a point in time is needed, and whether the identifier you use is the latest one.
Check the environment
Look at shard failures, rejections, node restarts, and load, since many problems in long walks are resource problems.
Create a small test index with ten documents that all share one timestamp, page through them three at a time using only the timestamp as the sort, and count what you receive. Then add a unique field to the sort and count again. Seeing the loss with your own eyes teaches the tiebreaker rule better than any explanation.
26Real-World Scenarios
Here are four common situations, with rough numbers, showing search_after at work. The companies are imaginary, but the patterns are the ones teams meet every day.
Scenario 1: The endless feed
PinBoardly, the imaginary photo-sharing site from Chapter 2, moves its feed from skip-based paging to bookmark paging. The sort is newest first by creation time, then by a unique photo id. The service wraps the bookmark in a signed token that expires after an hour and is bound to the user. Page latency no longer grows as users scroll, the ten thousand hit error disappears, and no point in time is used, because a live feed is happy to show new pictures.
Scenario 2: The export that lost four in a thousand
At the imaginary firm AuditArc, a compliance export of one hundred and twenty million documents sorts only by event time. Many events share the same millisecond, so the job silently skips about four documents in every thousand. An auditor notices the gap when comparing counts. The team adds a copied id field as a tiebreaker, uses a point in time, checkpoints the bookmark, and builds a final count comparison into the job. The next export matches the source count exactly.
Scenario 3: The log viewer with Older and Newer buttons
The imaginary company TraceNest offers a log viewer where engineers press “load older events” to go back in time. The service keeps a stack of bookmarks in the browser session, inside signed tokens, so that the “newer” button steps back through the same pages. It uses skip-based paging for the first five pages and bookmark paging beyond that. Page latency stays under a couple of hundred milliseconds at any depth, and the cluster never sees a deep skip.
Scenario 4: The enrichment job that updates what it reads
The imaginary logistics company ParcelPilot runs a job that reads every parcel record and writes back extra fields. A first version without a point in time sometimes reads its own updates and loops. The fix is a point in time with a two-minute keep alive, four slices with four workers, a checkpoint per slice, and a business-unique tiebreaker. The job finishes in about two hours instead of six, and it can resume after a failure without repeating work.
A worked example with numbers
Set the scene
An index has five shards and forty million documents. A job wants to read the results from position 50,000 onward, one hundred documents per page.
The skip-based way
The request for position 50,000 would need each shard to return about 50,100 hits, so the coordinating node would merge about 250,500 records for a single page. The default result window of ten thousand rejects the request outright.
The bookmark way
Each shard returns at most one hundred hits after the bookmark, so the coordinating node merges about five hundred records. That is one five-hundredth of the skip-based cost, and the request is accepted.
Ties matter
Suppose the sort is a timestamp, and about three hundred documents share the same value at the end of a page. Without a tiebreaker, up to two hundred and ninety-nine documents in that group are skipped. With a unique id in the sort, none are skipped.
The frozen view
Using a point in time, the job reads all forty million documents in 400,000 pages. Each page is confirmed to come from the same view, and the final count matches an independent count on that same view.
The result
The figures are approximations to show the shape of the difference, and real numbers depend on your data and cluster. The lesson is that bookmark paging turns an impossible request into an ordinary one.
27Frequently Asked Questions
28Summary and Key Takeaways
The search_after option is a simple idea with a big effect: continue from where you stopped, instead of counting from the start. Here is everything to remember.
Key Takeaways
- What it is: a bookmark for sorted searches, made from the sort values of the last hit, that lets each request continue right after a known position.
- Why it exists: skip-based paging gets slower and heavier with depth and stops at the result window, while the bookmark keeps the cost and memory per page nearly flat.
- What it needs: a sort, a zero skip value, an identical query and sort on every page, and a bookmark that matches the sort in count and type.
- What can go wrong: ties silently skip documents, so end the sort with a unique field, and live changes can repeat or miss documents unless the view is frozen.
- How to freeze the view: open a point in time with a short keep alive, use its latest identifier on every page, rely on its automatic tiebreaker or add your own, and close it when done.
- How to build robust jobs: checkpoint bookmarks, keep a business-unique tiebreaker for resuming, make consumers idempotent, check shard failures, and verify counts.
- How to protect and watch it: use opaque signed cursor tokens, limit depth and rate, and monitor page latency, open contexts, rejections, and count mismatches.
Keep in mind that option names, defaults, limits, and the availability of features such as the point in time API can change between Elasticsearch versions, so confirm exact details against the documentation of the release you run. With that habit, and the ideas in this guide, you can read as deep into your results as you need, quickly and safely, without ever counting from the front cover again.