Almost every report that a court data API is slow turns out, on inspection, to be a report about page four hundred. Page one came back fine. Page ten came back fine. Somewhere past page two hundred the same query started taking seconds instead of milliseconds, and by page eight hundred the job was timing out. The instinct is to blame the server. The cause is almost always the way the client asked for the next page.
This is not a subtle bug and it is not specific to any one vendor. It is a property of how offset pagination works against a distributed index, and it becomes severe in exact proportion to the size of the corpus. On a table with fifty thousand rows nobody ever notices. On a keyword index holding roughly 310 million case records assembled from eCourts, the National Judicial Data Grid and individual court registries, it is the single most common reason a bulk pull falls over.
This article is about the two mechanisms available on POST /search/cases, when each one is correct, what the pagination envelope is actually telling you, why the semantic endpoint behaves differently and must be planned for separately, and the checkpointing discipline that turns a fragile script into a job you can leave running for four days. The argument in one line: if your pull needs a page number larger than about fifty, you have chosen the wrong instrument, and no amount of client tuning will fix that.
There Are Two Ways to Ask for the Next Page
Keyword search on POST /search/cases accepts both. They look similar in the request body and behave nothing alike beyond the first few pages.
The first is ordinary offset paging: send page as an integer, defaulting to 1, and limit as an integer, defaulting to 20 and accepting a maximum of 100. Page 3 with a limit of 100 means skip the first two hundred matching records and give me the next hundred. This is what every UI does, because a user who clicks Next twice is at page 3 and a user who clicks a page number in a paginator expects to land there directly. Offset paging supports random access. That is its entire reason for existing.
The second is cursor paging: send searchAfter, a string cursor, instead of incrementing page. The response tells you where the last page ended, you hand that value back on the next request, and the engine resumes from exactly that position in the sort order. There is no random access. You cannot jump to the middle of a cursor stream. What you get in exchange is that page nine hundred costs the same as page two, because the engine never has to walk the first eighty nine thousand results to find where you are.
The rule that decides everything downstream
Use page and limit when a human is looking at the results and will realistically stop within the first few screens. Use searchAfter the moment a machine is consuming the results and intends to walk the whole set. Mixing them is the failure. A backfill written with page++ is a backfill that gets slower every hour it runs, which is exactly the opposite of what you want from a job that has to finish.
Reading the Pagination Envelope Properly
Every successful response carries a pagination object alongside the data array. It has five fields, and most integration bugs come from trusting the wrong one.
| Field | What it is | How to use it, and how not to |
|---|---|---|
| page | The page you asked for, echoed back | Useful for asserting that the server understood you. Do not derive your own position from it when you are cursor paging, because you are not using page numbers at all. |
| limit | The page size in effect | Echoes what you sent, for any value the schema accepted. It is not a clamp: a limit above 100 never reaches this envelope, because the request is rejected with HTTP 400 and a `details` array reading `limit: Limit cannot exceed 100 results per page.` Cap your own page size at 100 before you send it. |
| total | The number of matching records | On keyword search this is a count over the index and is the right basis for a progress bar. On semantic search treat it as an estimate, not a contract. |
| totalPages | total divided by limit, rounded up | Read it as a warning signal. If a slice reports twelve thousand pages, the slice is too broad and needs partitioning before you start, not a faster client. |
| hasMore | Whether anything follows this page | The only loop condition you should ever write. Terminate on hasMore being false rather than on your own counter reaching totalPages, because counters drift and the server's view is the one that matters. |
A word on total. It is genuinely useful and it is also the field people over trust. On the keyword index it is a count of what matched, and for planning a pull that is exactly what you want: fifty thousand matching records at a limit of 100 is five hundred calls, and at 10 requests per minute per key that is fifty minutes of budget before anything else your product is doing. Do that arithmetic before you write the client, not after the first weekend run.
What total does not promise is stability across the life of a long pull. The corpus is not frozen. Registries publish, orders land, records get enriched. A slice that reported 48,300 matches on Monday may report a slightly different number on Wednesday. This is one more reason a cursor is the right tool for bulk work: it walks a consistent position in a sort order rather than re-deriving an offset against a set that has moved underneath it.
Why Deep Offset Paging Degrades and a Cursor Does Not
It is worth being concrete about the mechanism, because once you have seen it you will never write page++ over a large corpus again.
A keyword index of this size is not one file on one machine. It is sharded. When you ask for the top 100 results by relevance, the coordinating node asks every shard for its own top 100, collects them, merges and sorts, and returns the best hundred overall. That is cheap, and it is why page one is fast.
Now ask for page 500 at a limit of 100. The coordinator cannot ask each shard for records 49,900 to 50,000 of the global ordering, because no shard knows the global ordering. It has to ask every shard for its own top 50,000, merge fifty thousand results per shard, sort the lot, and then throw away everything except the last hundred. The work grows linearly with the offset and it is almost entirely wasted work. Memory grows with it too. This is why deep paging does not degrade gracefully: it degrades in proportion to how deep you have gone, and it takes the whole node's budget with it.
A cursor sidesteps all of it. searchAfter encodes the sort position of the last record you received. Every shard can answer the question give me the next hundred records after this position without materialising anything before it. The cost of a page is constant. Page 900 and page 2 are the same amount of work, which is precisely the property a bulk pull needs.
Offset paging asks the engine to find your place by counting from the beginning every time. A cursor tells the engine where your place is. At 310 million records that is not an optimisation, it is the difference between a job that finishes and a job that does not.
The Semantic Endpoint Pages Differently, and You Must Plan For It
POST /search/cases/semantic is a different machine with a similar looking request body, and treating the two as interchangeable is a common and expensive mistake in bulk work.
Semantic search runs against the embedded corpus, which is far smaller than the keyword index. The keyword side holds roughly 310 million records. The semantically embedded and AI analysed side is in the low millions. Anyone who implies that 310 million judgments have been embedded and analysed by a model is describing something that does not exist, and you should plan your coverage expectations accordingly.
Mechanically, the pipeline runs in a fixed order. Your query is normalised, so a case number written as 123-2024 or 123/2024 is collapsed to 1232024 and captured separately. A model extracts structured filters from the prose, such as court, year, case type and case number, and returns a cleaned query. The cleaned query is expanded into legal terminology. An embedding is generated. A vector search runs with a minimum similarity score of 0.3. Full case documents are then hydrated from the primary store, and each result carries a score.
- Paging is offset based over the vector store. There is no searchAfter cursor on the semantic path. You move through results with page and limit, which means the deep paging problem applies here too and there is no cursor escape hatch. Keep semantic result sets shallow by design.
- Totals are estimated, not exact. Nearest neighbour retrieval with a similarity floor does not produce a clean count of everything that matched, because matching is a distance and not a yes or a no. Use the total on this endpoint for display, not for reconciliation arithmetic.
- Explicit filters override extracted ones. If you pass a filters object, its keys win over anything the model inferred from the prose. For programmatic work, always pass filters explicitly rather than hoping the extraction guesses your intent.
- A filters only query falls back to keyword. If the cleaned query ends up shorter than three characters, the request falls back to keyword search and meta.fallbackMode is set to opensearch. Log that field. A pipeline silently running on the wrong engine will produce results that look fine and are not what you asked for.
- It costs AI credit and it returns 403 when the allowance is exhausted. Backoff does not cure a 403 here. That is a budget condition, not a throughput condition, and it needs a human rather than a retry.
- It is slow by design. The endpoint holds the connection open with keep alive headers and allows up to ten minutes. Do not put it in the same worker pool as cheap deterministic calls.
Use each engine for what it can be complete about
For an exhaustive pull, meaning every matter of a given case type before a given court in a given year, use keyword search with filters and a cursor. It can be complete over what has been indexed. Semantic search returns the nearest passages, not all matching passages, so it is the wrong instrument for a job whose success condition is completeness. Use it to discover the vocabulary and the shape of a set, then run the exhaustive pull with keyword filters.
Partition the Corpus Before You Page It
The best cure for deep paging is never being deep. Rather than one query that matches four million records and then walking it, decompose the pull into slices narrow enough that no single query goes more than a few dozen pages deep.
The natural partition keys are already filter fields on the endpoint. Court is the first axis, because it is the coarsest and it maps to how the data is actually produced. Year is the second. Where a slice is still too large, and it frequently will be for a busy High Court in a recent year, drop to fromDate and toDate windows in strict YYYY-MM-DD and cut the year into months or fortnights. Case type is a useful third axis where the pull is genuinely type specific, remembering that a primary case type is expanded server side into the underlying registry codes, so asking for Appeal reaches a set of codes such as CA, CRA and LPA rather than a single literal string.
Enumerate the slices first, as data
Write the full cross product of court and year, or court and date window, into a table in your own store before you make a single API call. Each row is a unit of work with a status. This is the difference between a job you can resume and a script you have to restart.
Probe each slice with a limit of 1
One cheap call per slice returns the pagination envelope and therefore the total. Now you know which slices are small, which are large, and which need splitting further before the real pull starts. Splitting after you have started is how partial datasets happen.
Split anything that reports too many pages
Pick a threshold and hold it. If totalPages at a limit of 100 exceeds roughly fifty, split the slice by month, and if a month is still too big, by week. A slice that finishes in fifty calls is a slice that finishes inside one coffee break and leaves a clean checkpoint behind it.
Walk each slice with searchAfter, at limit 100
Within a slice, never increment page. Take the cursor from each response, persist it, and hand it back on the next call. Leaving limit at its default of 20 costs you five times as many calls for the same records, which on a budget of 10 requests per minute is five times the wall clock.
Mark the slice complete only when hasMore is false
Not when your loop counter matched totalPages, and not when the data array came back short. hasMore is the server's own statement about whether anything follows, and it is the only condition that stays correct when the corpus moves under you mid pull.
Checkpointing a Pull That Runs for Days
At 10 requests per minute per key, a serious backfill is measured in days. That is not a problem provided the job is built to be interrupted, because over four days it certainly will be: a deploy, a network blip, an upstream 502, a 429 you did not expect because an interactive user shared the key.
The discipline is small and it is not optional. After every successful page, write three things to your own store within the same transaction as the records: the slice identifier, the cursor value the response gave you, and a monotonically increasing page counter for observability. Then, and only then, acknowledge the work. If the process dies between the API call and the write, you re-fetch one page on restart and nothing is lost. If you write records first and the cursor last, a crash in between costs you a duplicated page, which is harmless when writes are idempotent and corrupting when they are not.
Idempotency is the other half. Key every write on the case id or the case number, use an upsert rather than an insert, and make re-running any single page a no-op in terms of net effect. Once every unit of work is safe to run twice, resumption stops being a design problem and becomes a loop that skips completed slices. A related post in this series goes into retry and backoff strategy in depth; for present purposes it is enough to say that a 429 tells you exactly how long to wait via retryAfter, and a 408 is telling you something quite different, which the next section deals with.
The 90 Second Budget, and What a 408 Is Really Telling You
Keyword search on POST /search/cases has a hard server side budget of 90 seconds. A query that exceeds it returns 408. An upstream failure returns 502, which is a different condition and deserves a different response.
A 408 is almost never a sign that the platform is unwell. It is a sign that you asked a question whose answer required more work than the budget allowed, and the two usual causes are a very broad query with no filters and a deep offset. Both are within your control. Retrying the identical request harder is the one response guaranteed not to help, because the second attempt has exactly the same work to do as the first.
- Narrow the slice, do not repeat the query. Add court if it is missing. Add year. If year is present, cut to a fromDate and toDate window. A slice that timed out over a full year will usually complete comfortably over a quarter.
- Check whether you were deep. If the 408 arrived at page 300, the offset is the cost, not the query. Restart that slice on a cursor and the timeout disappears without any change to the filters.
- Drop the limit only as a last resort. Halving the limit halves the returned payload but does not reduce the offset work, so it treats a symptom. Partitioning treats the cause.
- Distinguish 408 from 502 in your handler. A 408 is a query shape problem that your code should fix by re-slicing. A 502 is an upstream failure that a bounded exponential backoff with jitter should ride out. Conflating them means either retrying a hopeless query forever or abandoning a slice that would have succeeded on the next attempt.
- Log the slice, not just the error. An operator needs to know which court and which date window failed. An error line that says request timed out, with no slice identifier, is a line that costs somebody an afternoon.
Most Complaints That an API Is Slow Are Pagination Complaints
It is worth stating the general claim plainly, because it holds well beyond this API. When a data integration is described as slow, the number people quote is almost always an aggregate: the backfill took eleven days, the sync never catches up, the export times out. Those are throughput and strategy figures. They are only loosely related to how long any individual request took.
Take the arithmetic seriously. Suppose you want every matter of a given case type across all 25 High Courts for the last ten years. Approached as one query walked with offsets, it is a single enormous result set, deep paging from about page thirty onwards, timeouts by the low hundreds, and a job with no natural resume point. Approached as 250 slices, one per court and year, each probed and split where necessary, each walked on a cursor at a limit of 100, it is a queue of independent units, none of them deep, each of them resumable, and the total wall clock is set almost entirely by the request budget rather than by any per request latency.
The second design does not use a faster server. It uses the same server correctly. And it has a property the first design lacks entirely: at any moment you can answer the question how far along is this, because progress is a count of completed slices rather than a page number nobody can interpret.
The expensive failure is not the slow one
A slow pull is annoying. A pull that finished, reported success, and quietly missed six weeks of a High Court's records because a 408 was swallowed inside a retry wrapper is a professional problem, not an engineering one. In legal work the output of a backfill is a diligence list, a limitation calculation or a conflicts check. Count what you attempted, count what you wrote, and alert on the difference. Partial completion must be loud.
The Checklist Before You Start the Run
None of this is complicated. It is a small number of decisions made before the first call rather than after the first failure.
Slices enumerated as rows
Court by year, or court by date window, written to your own store with a status column before the job starts. A pull whose plan lives only in a running process is a pull you cannot resume, pause, or report on.
Cursor within, offsets never
searchAfter inside a slice, at a limit of 100, terminating on hasMore. Offset paging reserved for interactive screens where a human is clicking and will stop within a few pages.
Checkpoint after every page
Slice id, cursor and page counter written with the records, in that order, transactionally. Idempotent upserts keyed on case id so a repeated page costs nothing but a little throughput.
Separate lanes for the AI path
Semantic search and analysis calls run slower, draw credit, page by offset and can fail with 403. Keep them out of the pool that does cheap deterministic work, with their own concurrency and their own alerting.
The parameter reference, including the exact filter fields and the limit ceiling, is at the API documentation, and what the corpus actually covers across the Supreme Court, all 25 High Courts, the district judiciary and the tribunals is set out at the API overview. Before committing to a backfill of a given size, price the call count at API pricing rather than discovering the shape of the bill halfway through the run.
One last honesty note that matters more for bulk work than for anything else. Metadata completeness varies by court and by year. District Court records are thinner than Supreme Court records, and older records are thinner than recent ones. A field being absent on a record usually means the registry never published it, not that the pull dropped it. Build your reconciliation to distinguish those two cases, because a pipeline that treats missing metadata as a fetch failure will retry the same records forever and still never fill the gap.
Design the slices before you write the loop
Deep offset paging over an index of roughly 310 million records degrades by construction, and a cursor removes the problem entirely. On the CourtMesh API, POST /search/cases takes page and limit for interactive screens and searchAfter for bulk walks, with a limit ceiling of 100, a pagination envelope that tells you when to stop, and a 90 second query budget that narrow slices comfortably fit inside. The full parameter and response reference is at the API documentation, coverage across the Supreme Court, the High Courts, the District Courts and the tribunals is at the API overview, and you can price the call count for a planned backfill at API pricing.
Explore CourtMesh


