HomeBytesheetsRoadmapsUI TemplatesUI ToolsStack Discussion
5 Topics
Chapter•185 Minutes

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 is also a resource-protection mechanism

Pagination does not only improve UX. It protects databases, servers, networks, and clients from accidentally or intentionally requesting enormous collections.

Key Takeaways
  • 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.
Knowledge Check

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.

Offset Pagination
Simple: Easy page/offset model
Clients can reason about page numbers and offsets easily.
Large offsets: Potentially expensive
The database may need to traverse many records before reaching the offset.
Changing data: Can shift page boundaries
Inserts and deletes between requests can cause duplicates or skipped records.
Key Takeaways
  • 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.
Knowledge Check

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.

Cursor Pagination
Key Takeaways
  • 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.
Knowledge Check

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.

Typed Filtering and Sorting Input
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!
}
💡 The API exposes a controlled set of filtering and sorting capabilities. Clients cannot directly provide arbitrary MongoDB or SQL expressions. The repository translates these typed values into database-specific queries.
Key Takeaways
  • 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.
Knowledge Check

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.

Filtering vs Search
Filtering: Exact or structured conditions
Examples include category, status, date ranges, and boolean conditions.
Search: Text and relevance
Often requires indexing, tokenization, ranking, and relevance calculations.
Key Takeaways
  • 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.
Knowledge Check

Why might a production application use a dedicated search engine instead of a simple database filter?