gRPC Pagination and Filtering

Real systems hold thousands or millions of records. A method that returns everything at once wastes memory, slows replies, and can exceed the message size limit. Pagination returns data in small pages. Filtering narrows results to what the caller needs. Both belong in every list method from day one.

The Library Shelf Analogy

A librarian never carries every book to your desk. You ask for a topic, such as cooking, and receive one trolley of ten books. You return for the next trolley when you finish. Pagination follows that routine: the server sends one trolley per request and remembers where the last one ended.

Client ──"cooking books, 10 per page"──▶ Server
Client ◀──books 1-10 + bookmark "A7"──── Server
Client ──"continue from bookmark A7"──▶ Server
Client ◀──books 11-20 + bookmark "B3"── Server

Page Token Design

The standard gRPC list pattern uses two request fields and one response field.

message ListProductsRequest {
  int32  page_size  = 1;   // how many items the caller wants
  string page_token = 2;   // bookmark from the previous reply
  string category   = 3;   // optional filter
}

message ListProductsResponse {
  repeated Product products = 1;
  string next_page_token    = 2;   // empty means no more pages
}

The first request sends an empty token. Each reply carries a token for the following page. An empty token in the reply signals the last page.

Offset Paging and Cursor Paging

MethodHow It WorksWeakness
OffsetSkip N rows, take M rowsSlow on large tables, shifts when rows change
CursorContinue after the last seen keyNeeds a stable sort key

Cursor paging scales better. The database jumps straight to the bookmark using an index.

Cursor Query Example

SELECT id, name, price_cents
FROM products
WHERE category = ? AND id > ?
ORDER BY id
LIMIT ?

The server fetches one row more than the page size. An extra row proves that another page exists. The server drops the extra row and builds the next token from the last row it returns.

Opaque Tokens

Treat tokens as opaque strings. Encode the cursor, for example with base64, so clients never build or edit tokens by hand. A hidden format lets you change the paging method later with no contract change.

func encodeToken(lastID string) string {
  return base64.URLEncoding.EncodeToString([]byte(lastID))
}

func decodeToken(token string) (string, error) {
  raw, err := base64.URLEncoding.DecodeString(token)
  if err != nil {
    return "", status.Error(codes.InvalidArgument, "invalid page token")
  }
  return string(raw), nil
}

Limit the Page Size

Set a default and a maximum. Callers who send zero get the default. Callers who ask for too much get the maximum.

size := int(req.PageSize)
if size <= 0 {
  size = 20
}
if size > 100 {
  size = 100
}

Filtering and Sorting

Add typed filter fields for common needs. Typed fields stay safe, easy to validate, and easy to index in the database.

message ListProductsRequest {
  int32  page_size  = 1;
  string page_token = 2;
  string category   = 3;
  int64  min_price_cents = 4;
  int64  max_price_cents = 5;
  SortOrder order_by = 6;
}

Never paste filter text from a request into a database query. Use parameterized queries to block injection attacks.

Field Masks

A field mask lets callers name the fields they want back. Lean responses save bandwidth when objects are large.

import "google/protobuf/field_mask.proto";

message GetProductRequest {
  string id = 1;
  google.protobuf.FieldMask read_mask = 2;   // example: "name,price_cents"
}

Streaming Alternative

Server streaming can deliver a large result set without pages. The client receives items as they arrive. Choose paging for user interfaces with a page by page experience. Choose streaming for bulk exports and background jobs.

Summary

Use page size and page token fields, return a next token, and cap the page size. Prefer cursor paging, keep tokens opaque, and filter with typed fields. These habits keep list methods fast as data grows.