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
| Method | How It Works | Weakness |
|---|---|---|
| Offset | Skip N rows, take M rows | Slow on large tables, shifts when rows change |
| Cursor | Continue after the last seen key | Needs 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.
