FeathersJS Pagination: Fixing the `find` Response Shape and Clamping Query Limits
Feathers services return either a paginated object or a plain array from find(). Learn how to configure pagination, normalize $limit and $skip in a before hook, and handle offset pagination limits.
30 Jul 2025, 20:36 UTC

You wire up a Feathers service, call find() from your client, and get back { total, skip, limit, data } instead of the array you expected. The frontend renders nothing, and the API feels inconsistent. The root cause is usually a mismatch between the service's paginate configuration and the client's assumptions about the response shape.
The fix is straightforward: treat pagination as a per-service contract, normalize untrusted $limit and $skip values in a before hook, and know when offset pagination stops scaling.
Two response shapes, one service option
In Feathers 4 and later, service.find() has two possible shapes. When pagination is enabled, it resolves to an object with total, skip, limit, and data. When paginate: false is set, it resolves to a plain array. Many "why is my API returning an object?" bugs come from assuming the array shape.
Pagination is configured per service through the paginate option, commonly { default: 10, max: 50 }. Generated apps usually read this from app.get('paginate'). Use paginate: false only for bounded internal or admin collections where returning every row is intentional; switching a live service from paginated to unpaginated is a breaking API change because the response shape changes from an object to an array.
Normalizing $limit and $skip in a before hook
Clients control pages with $limit and $skip in params.query, for example app.service('messages').find({ query: { $skip: 100, $limit: 50 } }). Treat these values as untrusted input. A compact pattern is to normalize the query in a before hook so the adapter receives final values.
The hook below reads the service's configured default and max, coerces a missing $limit to the default, caps it at max, and rejects negative $skip. This keeps behavior consistent across REST, Socket.io, and internal service calls.
// hooks/normalize-pagination.js
module.exports = function (options = {}) {
return async context => {
const { app, service, params } = context;
const paginate = service.options.paginate || app.get('paginate');
// Skip normalization for unpaginated services
if (!paginate || paginate === false) {
return context;
}
const query = params.query || {};
const max = paginate.max || 50;
const def = paginate.default || 10;
// Coerce missing $limit to default and cap at max
let limit = query.$limit === undefined ? def : Number(query.$limit);
if (Number.isNaN(limit) || limit < 1) {
limit = def;
} else if (limit > max) {
limit = max;
}
// Reject negative or invalid $skip
let skip = query.$skip === undefined ? 0 : Number(query.$skip);
if (Number.isNaN(skip) || skip < 0) {
throw new Error('Invalid $skip value');
}
context.params.query = {
...query,
$limit: limit,
$skip: skip
};
return context;
};
};
Register it in your service hooks:
// services/messages/messages.js
const normalizePagination = require('../../hooks/normalize-pagination');
module.exports = {
around: { all: [] },
before: {
find: [normalizePagination()]
}
// ...
};
Where to run: in your Feathers application source. Permissions: standard developer access to edit service and hook files. Expected checks: a request with $limit: 100 against a service with max: 50 should be clamped to 50; a request with $skip: -1 should throw. Risk: silently clamping over-max limits can hide client bugs; if strict enforcement is preferred, throw instead of capping.
The offset pagination trade-off
Offset pagination is a trade-off. $skip makes the database scan and discard rows, and total can be expensive on large tables. Feathers does not prescribe cursor or keyset pagination, so deep feeds often need adapter queries or a custom service method instead of very large $skip values.
Treat total as an estimate for large datasets rather than a guarantee; it is not always cheap or stable under concurrent writes. Also, real-time events are separate from find pagination: created, patched, and removed events are not automatically limited to the page a client is viewing.
Verifying the behavior
Create a scratch service with the in-memory adapter and call find with no query, { $limit: 100 }, and { $skip: 40, $limit: 20 }. Assert the response shape and effective limit. In a generated app, log app.get('paginate') and the options passed to your service constructor to confirm the active default and max.
Add a unit test for the validation hook that clamps $limit above max and rejects negative $skip, then run the service test suite:
# Run from your project root
npm test -- services/messages
For SQL-backed adapters, inspect the query log for one pageable request to confirm limit/offset behavior and whether a count query is issued. If the log shows a full table scan for a high $skip, consider a custom method or cursor strategy.
Pagination in Feathers is explicit, not magical. Lock down the response shape you need, normalize client input before it reaches the adapter, and measure before you trust deep offsets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.