Stop Using SKIP/LIMIT in Neo4j: Cursor Pagination with Indexed Properties
SKIP/LIMIT pagination slows linearly in Neo4j. Use cursor pagination on an indexed, monotonic property and let index-free adjacency handle relationship hops for stable page latency.
31 Dec 2025, 23:48 UTC

If your Neo4j API endpoint gets slower on page 20, 200, and 2000, the problem is almost always SKIP/LIMIT pagination combined with a missing or wrong index on the filter property. The useful fix is cursor pagination on a unique, monotonically increasing property and letting index-free adjacency do the relationship hops. That gives stable latency instead of a linear scan that grows with offset.
Why SKIP/LIMIT is expensive in a graph
SKIP/LIMIT looks cheap in Cypher but forces the planner to materialize and discard rows. With an offset of 10,000 the engine still has to find and skip the first 10,000 matching rows before returning the next page. On large result sets this becomes a full collection scan and sort, and latency rises with page number.
Index-free adjacency is Neo4j's storage design where each node stores direct pointers to its relationships. Traversing MATCH (n)-[r]->(m) follows those pointers without scanning other nodes. Property filters are different: they require an index lookup or a label scan. Mixing an unindexed property filter with SKIP/LIMIT is the common slow path.
Index best practice before pagination
Filtered properties need an index that matches the predicate type. Equality lookups use an equality index, range lookups use a range index. Without it Neo4j falls back to a label scan, which reads every node with that label.
Create an index in the Neo4j Browser or cypher-shell as a user with admin privileges. Creating an index on a live database adds write overhead until the index is online.
CREATE INDEX user_created_idx FOR (u:User) ON (u.createdAt, u.id)Verify planner choice before deploying. Run EXPLAIN or PROFILE in the same session you will query from.
EXPLAIN MATCH (u:User) WHERE u.createdAt >= datetime({epochMillis: 1720000000000}) RETURN u.id ORDER BY u.createdAt, u.id LIMIT 50Look for NodeIndexSeek or NodeIndexScan on :User(createdAt) and no LabelScan. If you see LabelScan, the index is missing, not yet online, or the predicate is not indexable.
Worked example: cursor pagination on User nodes
Assume a :User label with a unique, monotonically increasing id and a timestamp createdAt. The cursor is a composite of createdAt and id to break ties.
First page, no cursor:
MATCH (u:User)
WHERE u.createdAt >= datetime({epochMillis: 1720000000000})
RETURN u.id, u.createdAt
ORDER BY u.createdAt ASC, u.id ASC
LIMIT 50Next page, use the last seen values as a cursor:
MATCH (u:User)
WHERE u.createdAt > $lastCreatedAt
OR (u.createdAt = $lastCreatedAt AND u.id > $lastId)
RETURN u.id, u.createdAt
ORDER BY u.createdAt ASC, u.id ASC
LIMIT 50Parameters to pass: $lastCreatedAt and $lastId from the last row of the previous page. The WHERE clause is sargable, so the planner can start the scan at the cursor position using the index on (createdAt, id) rather than counting from the start.
When you need neighbors, keep the hop relationship-first:
MATCH (u:User {id: $userId})-[:FOLLOWS]->(f:User)
WHERE f.createdAt > $lastCreatedAt OR (f.createdAt = $lastCreatedAt AND f.id > $lastId)
RETURN f.id, f.createdAt
ORDER BY f.createdAt ASC, f.id ASC
LIMIT 50Starting from the indexed start node u and then traversing via the relationship pointer uses index-free adjacency. Avoid filtering f by a property before the hop if that property is not indexed, as it can force a scan of all neighbors.
Limits and common mistakes
Cursor pagination requires a stable sort key. If createdAt is not unique, include a tie-breaker like id. Changing sort direction requires a different cursor comparison.
Deep traversals over 3+ hops with property filters often benefit from materialized paths or graph projections instead of recursive MATCH patterns. Index-free adjacency is fast per hop, but the result set can explode.
Common mistakes:
- Using SKIP/LIMIT for deep pages. It works for the first few pages but degrades predictably.
- Creating an index on a single property then filtering on a composite expression. The planner may not use it.
- Assuming relationship traversal is free when the start node is found by a label scan. Fix the start node lookup first.
Practical check after deployment: run PROFILE on the first and a later page query. Compare total db hits and page faults. With a proper cursor and index, db hits should stay roughly constant per page. If they grow with page number, the index is not being used.
Rollback note: dropping an index is immediate but can increase query cost. If you create an index for testing, remove it after validation if it is not needed:
DROP INDEX user_created_idxRun this as an admin and confirm no queries depend on it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.