Pagination, Filtering and Searching
Design scalable collection APIs using offset and cursor pagination, filtering, sorting, searching, connection types, stable cursors, and database-aware query strategies.
Why Pagination Is Essential
Returning an entire collection from a GraphQL API is easy to implement but dangerous at scale.
This schema looks harmless:
type Query {
users: [User!]!
}
But if the database contains five million users, a request for users could attempt to retrieve and serialize an enormous amount of data.
Large responses create pressure on:
- Database memory
- Database CPU
- Network bandwidth
- Node.js memory
- JSON serialization
- GraphQL execution
- Client memory
- Browser rendering
Pagination places an explicit boundary on each request.
A basic API might use:
users(limit: Int!, offset: Int!): [User!]!
A more structured API can return metadata:
type UserPage {
items: [User!]!
totalCount: Int!
hasNextPage: Boolean!
}
Pagination should be enforced server-side. Do not simply trust the client to request reasonable values.
For example, an API may cap limit at 100 even if the schema accepts an integer.
Pagination does not only improve UX. It protects databases, servers, networks, and clients from accidentally or intentionally requesting enormous collections.
- Large GraphQL collections should be bounded.
- Pagination protects infrastructure as well as improving UI behavior.
- Server-side limits should prevent excessive page sizes.
- Pagination metadata helps clients navigate large datasets.
Why should a GraphQL server enforce a maximum page size?
Offset-Based Pagination
Offset pagination uses a page position and page size.
For example:
users(limit: 20, offset: 40)
This means the client wants up to 20 users after skipping the first 40 records.
In MongoDB, the conceptual query might be:
UserModel
.find(filter)
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
Offset pagination is easy to understand and works well for many administrative interfaces and relatively small datasets.
However, large offsets can become expensive because the database may need to walk past many records before producing the requested page.
There is also a consistency problem when records are inserted or deleted between requests.
Imagine:
Request 1 → offset 0
New record inserted
Request 2 → offset 20
The position of records can shift, potentially causing duplicates or skipped items.
A stable sort order is therefore important even with offset pagination.
Offset pagination is not inherently bad. It is a useful technique when its trade-offs match the workload.
Easy page/offset modelPotentially expensiveCan shift page boundaries- Offset pagination is simple and useful for many use cases.
- Large offsets can become expensive.
- Changing datasets can shift page boundaries.
- A deterministic sort order should accompany pagination.
What is a common weakness of offset pagination on frequently changing datasets?
Cursor-Based Pagination
Cursor pagination represents a position in an ordered result set rather than an absolute offset.
A common GraphQL shape is:
users(first: 20, after: $cursor) {
edges {
node {
id
name
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
The cursor can represent a stable position based on an ordering field such as:
createdAt + unique ID
For example, suppose records are ordered by:
createdAt DESC, _id DESC
A cursor can encode the values of the last returned record. The next query can ask for records after that position according to the same ordering.
This is often more efficient and stable than large offsets, but cursor pagination is more complex to implement correctly.
A cursor should generally be treated as an opaque value by the client. The client should not need to understand whether it contains a timestamp, database ID, encoded tuple, or another internal representation.
A good cursor is:
- deterministic
- tied to a defined ordering
- opaque to clients
- safe to validate
- stable enough for the intended traversal model
Cursor pagination is not automatically better. The underlying database query must still have appropriate indexes.
- Cursors represent positions in an ordered dataset.
- Clients should generally treat cursors as opaque.
- Cursor pagination needs a deterministic ordering.
- Database indexes should support the cursor query.
What should a GraphQL client generally assume about the internal contents of a cursor?
Filtering and Sorting
Pagination usually becomes useful when combined with filtering and sorting.
A GraphQL API might define:
input ProductFilterInput {
category: ProductCategory
minPrice: Float
maxPrice: Float
search: String
}
enum ProductSortField {
PRICE
CREATED_AT
NAME
}
enum SortDirection {
ASC
DESC
}
input ProductSortInput {
field: ProductSortField!
direction: SortDirection!
}
Then:
products(
first: Int!
after: String
filter: ProductFilterInput
sort: ProductSortInput
): ProductConnection!
The server should translate these inputs into controlled database operations rather than accepting arbitrary database syntax.
Sorting also needs deterministic tie-breaking.
For example, sorting only by createdAt can produce ambiguous ordering if multiple documents share the same timestamp.
A better database order might be:
createdAt DESC, _id DESC
The unique ID acts as a tie-breaker.
This matters especially for cursor pagination. If ordering is not deterministic, the same cursor can produce inconsistent page boundaries.
input ProductFilterInput {
category: ProductCategory
minPrice: Float
maxPrice: Float
search: String
}
enum ProductSortField {
PRICE
CREATED_AT
NAME
}
enum SortDirection {
ASC
DESC
}
input ProductSortInput {
field: ProductSortField!
direction: SortDirection!
}- Filters should be represented with explicit GraphQL input types.
- Sort fields should come from a controlled enum where possible.
- Deterministic ordering is important for reliable pagination.
- Tie-breaker fields can make ordering stable.
Why is a unique tie-breaker useful when sorting records for cursor pagination?
Designing Search in GraphQL
Search is different from simple filtering.
Filtering asks questions such as:
category = ELECTRONICS
price >= 1000
isActive = true
Search may ask for relevance across text:
Find products matching "wireless mechanical keyboard"
A GraphQL schema can expose:
products(search: "wireless keyboard") {
edges {
node {
id
name
}
}
}
But the GraphQL schema should not dictate how search is implemented.
Possible backends include:
- MongoDB text search
- MongoDB Atlas Search
- Elasticsearch
- OpenSearch
- PostgreSQL full-text search
- A dedicated search service
Search systems often provide relevance scoring, stemming, typo tolerance, tokenization, ranking, and autocomplete features that simple database filters do not.
For large systems, the GraphQL service may query a dedicated search index and then load full domain objects from the primary database.
The important architectural principle is that GraphQL exposes a stable search capability while the implementation can evolve independently.
Exact or structured conditionsText and relevance- Filtering and search solve different problems.
- Search can require specialized infrastructure.
- The GraphQL schema should hide search implementation details.
- Large search workloads may use a dedicated search index.
Why might a production application use a dedicated search engine instead of a simple database filter?