Server-Side Pagination in FeathersJS with the params.pagination API
Configure FeathersJS service pagination with paginate.default and paginate.max, understand the total/limit/skip envelope, and avoid the offset, sorting, and double-pagination mistakes that break it.
23 Sept 2026, 01:56 UTC

If your FeathersJS service returns every row in a collection on each find call, the fix is not a custom endpoint — it is the built-in pagination support. When a service is configured with a paginate option, Feathers parses the client's $limit and $skip query parameters, enforces a maximum page size, and returns a paginated envelope (total, limit, skip, data) instead of a bare array. This article shows the configuration, a worked example, and the mistakes that silently break it.
What pagination actually does in Feathers
Pagination in Feathers is service-level, not adapter-level magic. You declare it in the service options, and Feathers' service wrapper then normalizes $limit/$skip from the query, clamps them to your configured bounds, and makes the values available to the adapter (or your custom find) via params. The official database adapters (feathers-knex, feathers-mongoose, feathers-sequelize, etc.) translate those values into the driver's skip/limit for you.
Two numbers control everything:
default— the page size applied when the client sends no$limit.max— the hard ceiling. A client asking for$limit=100000gets clamped tomax, which is your main defense against someone dumping the whole table into memory.
Worked configuration
The example below assumes Feathers v5 (Dove) with the generated application structure, but the paginate option behaves the same way in v4. Adjust imports to your setup.
// src/services/items/items.class.js (or items.service.js registration)
import { KnexService } from '@feathersjs/knex'
export class ItemsService extends KnexService {}
export const items = (app) => {
const options = {
Model: app.get('sqliteClient'),
name: 'items',
paginate: {
default: 10, // rows returned when the client sends no $limit
max: 50 // hard ceiling on any client-supplied $limit
}
}
app.use('items', new ItemsService(options))
}With that in place, a client request controls the page:
GET /items?$limit=5&$skip=20The response is an envelope rather than an array:
{
"total": 137,
"limit": 5,
"skip": 20,
"data": [ /* 5 items */ ]
}Key behaviors to note:
- Without
paginatein the options,findreturns a plain array and$limit/$skipare ignored by the envelope logic. totalis a separate count query. On very large tables this is the hidden cost of pagination, not the page fetch itself.- Setting
paginate: falseon the client side (allowed for internal/server calls withparams.adapteror by calling the service directly without a provider) bypasses the envelope — external HTTP clients cannot do this oncepaginateis set, which is usually what you want.
Custom find methods: reading params correctly
If you override find in a custom service instead of using an adapter, the normalized values are available on params — but you must return the envelope yourself when pagination is active:
async find(params) {
const { $limit, $skip } = this.filterQuery(params).query
// or read params.query after Feathers has filtered it
const [rows, total] = await Promise.all([
this.runQuery({ limit: $limit, skip: $skip }),
this.runCount()
])
return { total, limit: $limit, skip: $skip || 0, data: rows }
}The common failure here is returning just rows. Feathers will not wrap it for you in a custom method, so the client loses total and cannot compute page counts. The other failure is mutating params.query after the service call in a hook — the metadata has already been computed from the original values, so the envelope no longer matches the data.
Limits and common mistakes
- Deep offsets are slow.
$skip=500000forces most databases to scan and discard half a million rows. For large datasets, switch to cursor/keyset pagination (WHERE id > lastSeenId ORDER BY id LIMIT n) in a customfind; the built-in offset model does not scale to deep pages. - Keep
maxhonest. Settingmax: 10000defeats the point. Pick a ceiling your database can serve with an indexed sort, typically tens or low hundreds of rows. - Don't double-paginate. If the server paginates and your client also slices the result array locally, you will duplicate or drop rows. Reconcile: either the client renders exactly what the server page contains, or you fetch without pagination deliberately.
- Adapter differences. Adapters delegate to the driver, and drivers differ on edge cases — negative or oversized
skipvalues may return empty results or throw depending on the store. Clamp inputs in abeforehook if you accept untrusted values beyond what Feathers normalizes. - Sorting instability. Offset pagination over an unsorted or non-unique sort can return the same row on two pages when data changes between requests. Always pair pagination with a deterministic
$sortthat ends in a unique field (e.g., the primary key).
Verifying it works
Three quick checks, no test harness required:
- Request
GET /items?$limit=5(from curl or a browser — no special permissions needed beyond whatever authentication the service requires). Confirm the JSON is an object withtotal,limit,skip, anddata, and thatlimitequals 5. - Request
$limit=9999and confirm the returnedlimitis clamped to yourmaxvalue, not 9999. - Enable your driver's query logging (e.g.,
DEBUG=knex:queryfor knex-based adapters) and confirm the SQL includesLIMIT/OFFSETmatching the request. This proves pagination reaches the database rather than being applied in memory.
Note that exact option names and adapter behavior vary across Feathers versions and adapter releases; the envelope shape and the paginate.default/paginate.max options described here are long-standing behavior, but confirm against the adapter documentation for your installed version before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.