FeathersJS Pagination: Getting `$limit`, `$skip`, and `total` Right Without Surprises
Feathers paginates find() automatically once configured, returns { total, limit, skip, data } instead of an array, and clamps client $limit to your max — here's how to set it up, verify it, and avoid the deep-offset and real-time traps.
04 Aug 2025, 12:44 UTC

Your Feathers API works fine in development, then a client asks for "page 47" of a table with 200,000 rows and everything grinds. Or worse: you expected a plain array from service.find() and got an object with data, total, limit, and skip instead, breaking your frontend. Both problems come from the same place — how Feathers handles pagination — and both are easy to fix once you know what the framework actually does.
The short version: Feathers paginates find automatically when you configure it, uses $limit and $skip as the query operators, and returns an envelope object instead of an array. Real-time events ignore pagination entirely. Design around those three facts and the rest is details.
What Feathers actually returns from find
When pagination is enabled for a service, find resolves to an object shaped like this:
{
"total": 132,
"limit": 10,
"skip": 20,
"data": [ /* up to 10 records */ ]
}total is the count of all matching records, which is what your UI needs to render page numbers. If pagination is not enabled, the same call returns a plain array. That dual return type is the number-one source of "works on one service, breaks on another" bugs, so decide per service and document it for your client code.
Clients control the window with query operators, not page/limit pairs: $limit caps how many records come back, and $skip offsets into the result set. "Page 3 with 25 per page" is $limit=25&$skip=50. If you want a page-number API for your frontend, translate it in a before hook rather than expecting Feathers to accept page directly.
Enabling and bounding pagination
Pagination is configured per service. In a typical Feathers 5 (Dove) setup using a database adapter, it looks like this:
// src/services/messages/messages.js (or .ts)
import { KnexService } from '@feathersjs/knex'
export class MessageService extends KnexService {}
export const messageService = (app) => {
const options = {
Model: app.get('sqliteClient'), // or your knex instance
name: 'messages',
paginate: {
default: 10, // applied when the client sends no $limit
max: 50 // hard ceiling; client $limit above this is clamped
}
}
app.use('messages', new MessageService(options))
}Run this wherever you register services; it needs no special permissions beyond your normal app boot. Two things matter here:
- Always set
max. Without it, a client can pass$limit=1000000and turn your API into a memory-allocation contest. Themaxvalue clamps client requests, which is your cheap denial-of-wallet protection. defaultshapes every unparameterized call. Internal server-side calls toservice.find()also get paginated unless you passpaginate: falsein params — a common gotcha when a hook expects "all" records.
To bypass pagination inside trusted server code:
const all = await app.service('messages').find({
paginate: false,
query: { roomId }
})Do this only server-side; clients can't set paginate: false from the wire.
Verifying it behaves the way you think
Don't trust the config — test the envelope. A minimal check against a running app or in an integration test:
const result = await app.service('messages').find({
query: { $limit: 10, $skip: 0, $sort: { createdAt: -1 } }
})
assert.equal(typeof result.total, 'number')
assert.ok(result.data.length <= 10)
assert.equal(result.limit, 10)Then confirm the clamp: request $limit: 9999 and assert result.limit comes back as your max (50 in the example above). From the outside, a quick curl 'http://localhost:3030/messages?$limit=5' and inspecting the JSON body tells you the same story. If you get back a bare array, pagination isn't enabled for that service.
The trade-off: offsets don't scale, and sockets don't paginate
Offset pagination ($skip) makes the database count and discard rows, so deep pages get slower as the table grows. On a table in the millions of rows, page 5,000 is genuinely expensive. The standard escape hatch is cursor-based pagination — filter on a stable, indexed column (createdAt < lastSeen plus an id tiebreaker) instead of skipping. Feathers doesn't ship a magic cursor mode; you implement it with ordinary query operators and a hook or custom method, which is fine because the query language is already expressive enough.
The second limitation catches people off guard: real-time events have no concept of pages. Over Socket.IO, a client subscribed with $limit=10 still receives created/patched events for every matching record, and those events can duplicate or reorder what's on the client's current page. Treat reads (paginated find) and live updates (events) as separate pathways: apply events to your client-side store, then re-derive the visible page, rather than blindly appending events to the current list.
Where to start
Pick one service, enable paginate with a sensible default and a firm max, update the client to read result.data and result.total, and add the two assertions above to your test suite. If a table is heading past a few hundred thousand rows, plan the cursor-based query now — retrofitting it after users notice slow deep pages is much less fun.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.