search_after in Elasticsearch

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.

Everyday Analogy

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.

Sort values
The bookmark is built from the sort values of the last hit
Any depth
The cost per page does not grow with how deep you already are
Next page
It moves forward from a known position and does not jump to page numbers
i
Name check

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.

Everyday Analogy

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.

!
Do not raise the window as a fix

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.

Hit

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.

Page

One batch of hits

A page is the group of hits returned by one request. Its length is set by the size option.

Sort

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.

Sort values

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.

Cursor

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.

Tiebreaker

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.

Segment

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.

Point in 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.

Everyday Analogy

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
Figure 1: Every shard returns only its next few hits after the bookmark. The coordinating node merges them and returns one page with sort values for the next request.

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.

i
Good to know

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 requestedSkip (from)SizeHits each shard must returnHits merged with 5 shards
Page 10101050
Page 109010100500
Page 100990101,0005,000
Page 1,0009,9901010,00050,000
Page 1,00110,00010Rejected by the result windowNot allowed by default
Everyday Analogy

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.

!
A trap on busy indices

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.

1

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.

2

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.

3

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.

4

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.

5

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
Figure 2: The bookmark loop. The only thing carried from one request to the next is the array of sort values of the last hit.
Everyday Analogy

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 from option must be zero or unset when search_after is 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.

i
Practical rule

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 requestRoleRule for search_after
QueryChooses the matching documentsMust stay identical on every page of one walk
SortDefines the order and therefore the meaning of a positionRequired, and must stay identical on every page
SizeNumber of hits per pageMust not exceed the result window, and can differ between pages, though a constant value is simplest
FromNumber of hits to skipMust be zero or unset
Search afterThe bookmark: sort values of the last hit already seenAbsent on the first page, then one value for every sort field
Track total hitsControls counting of all matchesOften switched off for deep walks to save work
Source filteringLimits which fields of each hit are returnedRecommended to keep pages light
Point in timePins the search to a frozen viewOptional but strongly recommended for exact walks, and then no index name is given
Everyday Analogy

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 sentSort array of a hitWhat each position means
Newest first by a date field, then by a unique id fieldA large number, then a text valueThe date as milliseconds since 1970, and the unique id
Price ascending, then a unique order numberA number, then a numberThe price, and the order number
A keyword field ascending onlyOne text valueThe keyword itself, which may not be unique
Date, with a point in time in use and no explicit tiebreakerA number, then a second numberThe 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.
i
Send the values back untouched

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.

1

The request arrives

A coordinating node receives the search with its sort, size, and bookmark, and it decides which shard copies to ask.

2

Each shard receives the bookmark

The coordinating node forwards the query, the sort, the size, and the bookmark to one copy of each shard.

3

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.

4

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.

5

The coordinating node merges

It merges the partial lists by sort values, keeps the best page size, and drops the rest.

6

The fetch phase collects the documents

The coordinating node asks the shards that own the winners for their sources and other requested details.

7

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
Figure 3: Every shard returns at most one page of small records, so the merge stays small no matter how deep the walk is.
Everyday Analogy

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.

i
Practical rule

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 typeGood for sorting?Notes for search_after
DateYesThe 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 numbersYesSort values are numbers. Mixed number types across indices may need care.
KeywordYesSort values are text. Sorting is alphabetical by byte order, not by language rules.
TextNo, by defaultAnalyzed text is not sortable. Use a keyword sub-field for sorting.
Boolean and IPYesRarely unique, so they need a tiebreaker.
ScoreYes, with careScores can differ between shard copies and change as the index changes, so pages may shift without a point in time.
Geographic distanceYesThe sort value is a distance number. Rarely unique.
Script-based sortPossible, but avoidSlow and hard to keep stable
Everyday Analogy

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.
!
Sorting by score alone

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.

DocumentTimestampUnique id
A101
B102
C103
D104
E105
F116
G127
H128
ApproachPage 1BookmarkPage 2Result
Sort by timestamp onlyA, B, CTimestamp 10F, G, HDocuments D and E are never returned
Sort by timestamp, then by unique idA, B, CTimestamp 10 and id 3D, E, FNothing is skipped
Same, third pageNot applicableTimestamp 11 and id 6G, HAll 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"]
Figure 4: Without a tiebreaker, documents that tie with the last hit are skipped. With a unique field in the sort, every position is distinct.
Everyday Analogy

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.

!
Always finish the sort with a unique field

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.

OptionUnique across the whole index?Cost and risksVerdict
A business key stored as a keyword or number field, such as an order numberYes, if your data guarantees itSmall doc values, cheap to sortBest choice when available
A copy of the document id stored in its own keyword fieldYesExtra field at indexing time, cheap to sortVery good, and easy to add in the mapping
The built-in document id fieldYesSorting on it needs heavy in-memory structures, and recent versions disable this by defaultAvoid
The automatic shard and document position, used with a point in timeYes, within that point in timeCheap, added automatically, but meaningful only for that point in timeExcellent for single walks with a point in time
The internal document orderNo, it repeats between shardsCheap but ambiguousNot a tiebreaker
The per-shard sequence numberNo, it repeats between shardsCheap but ambiguousNot a tiebreaker
A generated random value stored at indexing timeNearly, with a large enough rangeNot guaranteed uniqueOnly as a last resort
Everyday Analogy

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.

i
Check uniqueness with a count

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 pagesEffect on the walkSerious?
A new document sorts before the bookmarkIt is never seen in this walk, because the walk only moves forwardUsually acceptable; a later walk will find it
A new document sorts after the bookmarkIt appears in a later pageUsually welcome
A document already read is deletedNo effect on the remaining pagesNo
A document not yet read is deletedIt does not appearUsually acceptable
A document changes so that its sort key moves it forward past the bookmarkIt can be seen twiceYes, for exact processing
A document changes so that its sort key moves it backward before the bookmarkIt can be missed entirelyYes, for exact processing
A document changes without changing its sort keyThe page shows its new contentDepends on need
Segments merge in the backgroundScores and ordering by score may shift slightlyOnly when sorting by score
Everyday Analogy

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.
i
Decide on purpose

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.

Everyday Analogy

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
Figure 5: The life of a point in time. It is opened once, used for many searches, and closed when the walk is finished.

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.

i
Cheap compared with scroll

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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

7

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
Figure 6: The complete walk with a point in time. The PIT identifier and the bookmark are refreshed after every page.
Everyday Analogy

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.

!
Use the newest identifier

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.

MethodDepthConsistent view?Server stateJump to a page?Best for
From and sizeLimited by the result window, ten thousand by defaultNoNoneYesShallow paging in search result pages
ScrollUnlimitedYes, frozen at the first requestA search context per scrollNoLegacy exports, and internal jobs such as reindexing
Search after aloneUnlimitedNo, it is liveNoneNoFeeds, infinite scroll, live “load more”
Search after with a point in timeUnlimitedYesA light point in timeNoExact exports and batch jobs, and dependable deep paging
Point in time with slicesUnlimitedYesA light point in time shared by workersNoVery 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"]
Figure 7: A simple decision path. Most projects end at one of the three middle answers.
Everyday Analogy

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.
i
A good default

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.

Everyday Analogy

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 needHow to serve itCaveat
Next pageUse the sort values of the last hit as the bookmarkNone
Previous pagePop a stored bookmark, or reverse the sort and use the first hit’s valuesThe page may differ slightly if the live index changed
First pageSearch again without a bookmarkNone
Last pageReverse all sort directions and take the first page, then reverse the listThe size of the last page depends on the total count
Jump to page NUse from and size if N is shallow, otherwise walk, or offer no jumpingDeep jumps are not supported cheaply
Show the total number of pagesCount once with a separate requestCounts 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.
i
Design for how people really read

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
Figure 8: One frozen view, several readers. Each worker runs its own bookmark loop over its slice.
Everyday Analogy

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.

!
Slices are not free

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.

LeverEffectRisk or limit
Page sizeLarger pages mean fewer round trips but more memory on shards and the coordinating nodeVery large pages strain memory and increase latency per request
Total hit countingSwitching it off avoids counting all matches on every pageYou will not get an exact total from those requests
Source filteringReturning only needed fields makes the fetch phase lighterYou must know which fields you need
Doc value fields instead of the sourceReading a few fields from doc values can be much faster than loading whole documentsValues come in a normalized form, such as sorted arrays for multi-valued fields
Filter context instead of scoring queriesSkips scoring work when the sort does not use the scoreFilters do not rank by relevance
Sorting on doc values fieldsFast, memory-friendly comparisonsFields need doc values, and text fields are not sortable
Index sorting matching the sortEnables early termination for repeated walks in the same orderMust be chosen at index creation and slows indexing a little
Fewer, well-sized shardsLess merging on the coordinating node, fewer parallel tasksShard layout affects many other things
Consistent shard copiesWarm caches and stable order when not using a point in timeSlightly less even load distribution
Everyday Analogy

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.

i
Test a deep page early

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 --> [*]
Figure 9: A point in time lives as long as it is used within its keep alive time, and it is released when it is closed or when it expires.
Everyday Analogy

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.

!
Leaks are quiet

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.

Everyday Analogy

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.

Pattern 1

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.

Pattern 2

Use a point in time for exact reads

Exports, migrations, and batch jobs should always walk a frozen view and close it when finished.

Pattern 3

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.

Pattern 4

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.

Pattern 5

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.

Pattern 6

Hybrid paging in user interfaces

Use skip-based paging for the first few pages, and switch to bookmark paging beyond a chosen depth.

Pattern 7

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.

Pattern 8

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.

i
Keep it boring

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.

ANTI-PATTERN 01Avoid
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.

ANTI-PATTERN 02Avoid
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.

ANTI-PATTERN 03Avoid
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.

ANTI-PATTERN 04Avoid
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.

ANTI-PATTERN 05Avoid
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.

ANTI-PATTERN 06Avoid
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.

ANTI-PATTERN 07Avoid
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
Figure 10: The application service, not the browser, talks to Elasticsearch. It packs the bookmark into a signed token and verifies it on the way back.

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.

Everyday Analogy

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.

RiskWhat could happenProtection
Raw sort values exposed to callersPrivate field values leak in responsesWrap values inside an opaque token
Forged or altered bookmarkA caller probes data order or triggers errorsSign tokens and verify them on every request
Token reused by another userOne user continues another user’s walkBind the token to the user identity and check it
Point in time identifier leakedAnother party could search the frozen viewKeep the identifier server side or inside a signed, encrypted token, and use short lifetimes
Endless scraping through pagesBulk copying of your data through the APIRate limits, maximum depth, and authentication on the gateway
Oversized page requestsCluster overloadEnforce a maximum page size in the service
Traffic captured on the networkQueries and results exposedEncrypt connections between clients, the service, and the cluster
Sensitive values in logsBookmarks and queries appear in logs and slow logsLimit 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.

!
Compliance reminder

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.

QuestionWhere to lookWhat healthy looks like
Is each page as fast as the first?The reported time of each response, tracked by page numberRoughly flat as the walk goes deeper
How many search contexts are open?Node statistics for search, which report open contextsStable, returning to a low baseline after jobs finish
Are searches being rejected?Thread pool statistics for the search pool, and rejection errors in client logsZero or very rare
Are shards failing during walks?The shard failure counts in responses, logged by the clientAlways zero for exact jobs
Are points in time expiring unexpectedly?Client errors that mention a missing search contextNone during normal jobs
Did the export lose or repeat documents?Processed count versus a count taken on the same frozen viewThe two numbers match exactly
Is the disk growing because of held segments?Disk usage on data nodes during long walksSmall, temporary growth
Are slow pages visible to operators?The search slow log, which records the query body including the bookmarkFew entries, and none from routine jobs
Everyday Analogy

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.

i
Make the count check automatic

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
Figure 11: A recovery plan for paging jobs. Most failures end in a retry or a resume from the saved bookmark.

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.

Everyday Analogy

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.
!
Never guess a bookmark

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 seeMost likely meaningFirst things to check
Result window is too large, the sum of from and size must be at most ten thousandThe request uses skip-based paging beyond the default limitSwitch to search_after, with a point in time if exactness matters
The request complains that a sort is required for search_afterNo sort was givenAdd a sort with a unique last field
The request says that from must be zero when search_after is usedA skip value was sent together with the bookmarkRemove the skip value, or set it to zero
The number of search_after values differs from the number of sort fieldsThe bookmark does not match the sortSend exactly one value per sort field, in the same order
A bookmark value cannot be parsed, or has the wrong typeThe value was converted, or the field type differs across indicesSend the sort values back untouched, and check mappings across indices
No search context found for the identifier, or a search context missing errorThe point in time expired, was closed, or was lost when a node restartedOpen a new point in time and resume from the saved bookmark
The request says that indices cannot be used with a point in timeAn index name was given in the path together with a PITRemove the index name, since the PIT defines the target
The request says that routing or preference cannot be used with a point in timeOptions that conflict with the frozen view were sentRemove them from searches that use a PIT
Fielddata is disabled on text fieldsThe sort uses an analyzed text fieldSort on a keyword sub-field instead
Sorting on the document id field fails or uses much memoryThe built-in id field is not meant for sorting in recent versionsUse a copied id in a keyword field, or the automatic tiebreaker of a PIT
Some documents never appear in the walkThe sort is not unique, so tied documents were skippedAdd a unique tiebreaker and compare counts
Some documents appear twiceA live walk with a changing sort field, or shard copies that differUse a point in time, sort on immutable fields, and make consumers idempotent
Pages get slower as the walk deepensScoring or scripts in the query, sort without doc values, or an overloaded clusterUse filters, sort on doc values fields, and check load and page size
Errors about too many open search contextsPoints in time or scrolls are not closed, or too many run at onceClose contexts, shorten keep alive, and limit concurrent jobs
Score-sorted pages shift or repeatScores change between copies or as data changesUse a point in time, or add preference for the same shard copies

A calm way to debug

1

Read the message carefully

Most errors about pagination say exactly which rule was broken: missing sort, wrong count of values, or a forbidden option.

2

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.

3

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.

4

Check uniqueness

Compare the number of distinct values of your tiebreaker with the number of documents. If they differ, fix the tiebreaker first.

5

Check the frozen view

Decide whether a point in time is needed, and whether the identifier you use is the latest one.

6

Check the environment

Look at shard failures, rejections, node restarts, and load, since many problems in long walks are resource problems.

i
A quick self-test

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

1

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.

2

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.

3

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.

4

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.

5

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.

6

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

Q1What is search_after in Elasticsearch?
It is a search option that continues a sorted search after a position given by the sort values of the last hit already seen. It lets you read result pages of any depth at about the same cost per page, without skipping a count of results.
Q2Why is a sort required?
The bookmark is a position in the sort order. Without a sort, there is no order in which to define a position after which to continue.
Q3Why must the last sort field be unique?
The bookmark means “strictly after these values.” If several documents share the same values, those that tie with the last hit of a page are skipped. A unique last field, or the automatic tiebreaker of a point in time, makes every position distinct.
Q4What is a point in time, and do I always need it?
A point in time is a frozen, consistent view of the indices, opened with a keep alive time. You need it for exact reads such as exports. For live feeds where small changes between pages are acceptable, plain search_after is lighter.
Q5Can I use search_after together with from?
Only with a skip value of zero. The bookmark replaces skipping, and any larger skip value is rejected.
Q6Can I jump to page fifty?
Not directly. A bookmark comes from the data, so you must walk to the position, or precompute bookmarks. Skip-based paging supports jumping, but only within the result window.
Q7How do I go back to the previous page?
Keep a stack of the bookmarks you used, or reverse all sort directions, use the sort values of the first hit of the current page as the bookmark, and reverse the returned list.
Q8Is search_after better than scroll?
For new code, the documentation recommends search_after with a point in time for deep pagination. It is lighter than scroll, flexible about queries, and does not need a heavy context per reader.
Q9What happens if the point in time expires?
The next request fails with an error about a missing search context. Open a new point in time and resume from the last saved bookmark, using a business-unique tiebreaker so that the bookmark keeps its meaning.
Q10Why did my export miss some documents?
The usual causes are a non-unique sort, which skips tied documents, a sort on a field that changed during the walk, or partial shard failures that were not checked. Fix the tiebreaker, use a point in time, and verify counts.
Q11Can I read in parallel?
Yes. Open one point in time, split the work into slices, and run one bookmark loop per slice. Order is guaranteed only within a slice, and the parallel load should be balanced against other users of the cluster.
Q12What page size should I use?
Ten to fifty for user interfaces, and a few hundred to a few thousand for exports. Test with your own data, and choose the size where throughput stops improving.
Q13Should I expose raw sort values in my API?
No. Wrap the bookmark in an opaque, signed, expiring token bound to the user and to the query. This prevents leaks, tampering, and misuse.
Q14Does a point in time slow down my cluster?
Slightly, by holding old segments and small structures while it lives. With a short keep alive, explicit closing, and limits on concurrency, the cost is small.

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.