Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use cursor-based pagination for large datasets instead of .collect(). Prevents performance issues and provides smooth infinite scroll.
.claude/skills/kunanonj-cursor-plugin-convex-rule-use-pagination-for-large-datasets/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 73% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 96% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 363% | 0% |
| case-15 | ✗→✓ | ▲ Improved | 337% | 0% |
Never use .collect() on large or unbounded queries. Use Convex's cursor-based pagination instead.
typescriptexport const getAllTasks = query({ handler: async (ctx) => { // ❌ Loads ALL tasks - slow and breaks with large data return await ctx.db.query("tasks").collect(); }, });
Problems:
typescriptexport const getTasks = query({ args: { paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { return await ctx.db .query("tasks") .order("desc") .paginate(args.paginationOpts); }, });
Benefits:
Rule of thumb: If it could grow to 100+ items, paginate!
typescriptimport { query } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; export const listTasks = query({ args: { paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); return await ctx.db .query("tasks") .withIndex("by_user", q => q.eq("userId", user._id)) .order("desc") .paginate(args.paginationOpts); }, });
Returns:
typescript{ page: Doc<"tasks">[], // Current page of results continueCursor: string, // Cursor for next page isDone: boolean, // True if no more pages }
typescriptimport { usePaginatedQuery } from "convex/react"; import { api } from "../convex/_generated/api"; function TaskList() { const { results, status, loadMore } = usePaginatedQuery( api.tasks.listTasks, {}, { initialNumItems: 20 } ); return ( <div> {results?.map(task => ( <TaskItem key={task._id} task={task} /> ))} {status === "CanLoadMore" && ( <button onClick={() => loadMore(20)}>Load More</button> )} {status === "LoadingMore" && <div>Loading...</div>} </div> ); }
typescriptimport { useEffect, useRef } from "react"; function InfiniteTaskList() { const { results, status, loadMore } = usePaginatedQuery( api.tasks.listTasks, {}, { initialNumItems: 20 } ); const observerRef = useRef<IntersectionObserver>(); const loadMoreRef = useRef<HTMLDivElement>(null); useEffect(() => { if (observerRef.current) observerRef.current.disconnect(); observerRef.current = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && status === "CanLoadMore") { loadMore(20); } }); if (loadMoreRef.current) { observerRef.current.observe(loadMoreRef.current); } return () => observerRef.current?.disconnect(); }, [status, loadMore]); return ( <div> {results?.map(task => ( <TaskItem key={task._id} task={task} /> ))} {status === "CanLoadMore" && ( <div ref={loadMoreRef} className="h-20 flex items-center justify-center"> Loading more... </div> )} </div> ); }
typescriptexport const listTasks = query({ args: { status: v.optional(v.union( v.literal("todo"), v.literal("done") )), paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); let query = ctx.db .query("tasks") .withIndex("by_user", q => q.eq("userId", user._id)); // Filter in TypeScript after query const results = await query.order("desc").paginate(args.paginationOpts); if (args.status) { return { ...results, page: results.page.filter(task => task.status === args.status), }; } return results; }, });
Note: For better performance, use compound indexes:
typescript// schema.ts tasks: defineTable({ userId: v.id("users"), status: v.string(), }).index("by_user_and_status", ["userId", "status"]) // Query with index export const listTasks = query({ args: { status: v.union(v.literal("todo"), v.literal("done")), paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); return await ctx.db .query("tasks") .withIndex("by_user_and_status", q => q.eq("userId", user._id).eq("status", args.status) ) .order("desc") .paginate(args.paginationOpts); }, });
Convex pagination is fully reactive! When data changes, pages update automatically.
With offset-based pagination, insertions/deletions cause:
Convex uses cursor-based pagination with automatic tracking:
typescript// Items are inserted/deleted while user scrolls // Convex automatically handles this! const { results } = usePaginatedQuery(api.tasks.listTasks, {}, { initialNumItems: 20 }); // Results stay consistent even as data changes // No duplicates, no missing items
How it works:
For complex cases, use getPage from convex-helpers:
typescriptimport { getPage } from "convex-helpers/server/pagination"; export const customPagination = query({ args: { category: v.string(), paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); // Custom query logic const results = await ctx.db .query("posts") .withIndex("by_user", q => q.eq("userId", user._id)) .filter(q => q.eq(q.field("category"), args.category)) .collect(); // Apply custom pagination return getPage(results, args.paginationOpts); }, });
typescriptexport const searchTasks = query({ args: { searchTerm: v.string(), paginationOpts: paginationOptsValidator, }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); const allTasks = await ctx.db .query("tasks") .withIndex("by_user", q => q.eq("userId", user._id)) .collect(); // Filter by search term const filtered = allTasks.filter(task => task.title.toLowerCase().includes(args.searchTerm.toLowerCase()) ); // Paginate filtered results return getPage(filtered, args.paginationOpts); }, });
Better: Use Convex's built-in text search when available.
typescriptinterface PaginationOptions { numItems: number; // How many items per page cursor: string | null; // Cursor from previous page (null for first page) id?: string; // Optional: Specific ID to paginate from } // Usage const page1 = await query.paginate({ numItems: 20, cursor: null }); const page2 = await query.paginate({ numItems: 20, cursor: page1.continueCursor });
typescriptconst { results, status, loadMore } = usePaginatedQuery( api.tasks.list, {}, { initialNumItems: 20 } ); <button onClick={() => loadMore(20)} disabled={status !== "CanLoadMore"} > {status === "LoadingMore" ? "Loading..." : "Load More"} </button>
typescriptconst [showAll, setShowAll] = useState(false); const { results } = usePaginatedQuery( api.tasks.list, {}, { initialNumItems: showAll ? 100 : 10 } ); <button onClick={() => setShowAll(!showAll)}> {showAll ? "Show Less" : "Show All"} </button>
typescriptconst [activeTab, setActiveTab] = useState<"todo" | "done">("todo"); const { results } = usePaginatedQuery( api.tasks.listByStatus, { status: activeTab }, { initialNumItems: 20 } ); // Separate pagination per tab
typescript // ✅ Fast .withIndex("by_user", q => q.eq("userId", userId)) .paginate(opts)
// ❌ Slow .filter(q => q.eq(q.field("userId"), userId)) .paginate(opts)
typescript // ✅ Good: 10-50 items per page { initialNumItems: 20 }
// ❌ Too small: Too many requests { initialNumItems: 5 }
// ❌ Too large: Slow loading { initialNumItems: 500 }
typescript // ✅ Use .order() for consistent pagination .query("tasks") .withIndex("by_created") .order("desc") .paginate(opts)
typescriptexport const debugCount = query({ handler: async (ctx) => { const count = (await ctx.db.query("tasks").collect()).length; console.log(`Total tasks: ${count}`); return count; }, });
typescriptconst { results, status } = usePaginatedQuery(api.tasks.list, {}, { initialNumItems: 20 }); console.log({ itemsLoaded: results?.length, status, canLoadMore: status === "CanLoadMore" });
.collect() on unbounded queries.paginate() for lists that could grow largeinitialNumItems (10-50)usePaginatedQuery on frontend.order() for consistent results| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 9,181 | 6,060 | -34% | 1 | 1 | 0% | 1,949 | 4,409 | +126% | 0 | 0 | — |
case-02 | fail→pass | 11,108 | 8,013 | -28% | 1 | 1 | 0% | 2,842 | 4,908 | +73% | 0 | 0 | — |
case-03 | fail→fail | 9,183 | 6,158 | -33% | 1 | 1 | 0% | 2,001 | 4,610 | +130% | 0 | 0 | — |
case-04 | pass→pass | 10,015 | 6,798 | -32% | 1 | 1 | 0% | 2,099 | 4,709 | +124% | 0 | 0 | — |
case-05 | fail→pass | 11,487 | 8,619 | -25% | 1 | 1 | 0% | 2,599 | 5,093 | +96% | 0 | 0 | — |
case-06 | fail→fail | 3,730 | 4,653 | +25% | 1 | 1 | 0% | 797 | 4,066 | +410% | 0 | 0 | — |
case-07 | fail→fail | 11,460 | 7,597 | -34% | 1 | 1 | 0% | 2,790 | 4,953 | +78% | 0 | 0 | — |
case-08 | fail→pass | 15,698 | 9,103 | -42% | 1 | 1 | 0% | 3,778 | 5,379 | +42% | 0 | 0 | — |
case-09 | pass→pass | 6,801 | 3,663 | -46% | 1 | 1 | 0% | 1,489 | 3,841 | +158% | 0 | 0 | — |
case-10 | pass→pass | 3,693 | 5,367 | +45% | 1 | 1 | 0% | 701 | 4,145 | +491% | 0 | 0 | — |
case-11 | pass→pass | 4,367 | 4,504 | +3% | 1 | 1 | 0% | 930 | 4,042 | +335% | 0 | 0 | — |
case-12 | fail→pass | 4,124 | 3,815 | -7% | 1 | 1 | 0% | 860 | 3,984 | +363% | 0 | 0 | — |
case-13 | pass→pass | 10,600 | 7,289 | -31% | 1 | 1 | 0% | 2,070 | 4,618 | +123% | 0 | 0 | — |
case-14 | fail→fail | 4,651 | 4,031 | -13% | 1 | 1 | 0% | 1,105 | 4,053 | +267% | 0 | 0 | — |
case-15 | fail→pass | 4,916 | 7,197 | +46% | 1 | 1 | 0% | 1,083 | 4,734 | +337% | 0 | 0 | — |
case-16 | fail→fail | 5,961 | 4,425 | -26% | 1 | 1 | 0% | 1,322 | 4,029 | +205% | 0 | 0 | — |
case-17 | fail→fail | 5,641 | 7,054 | +25% | 1 | 1 | 0% | 1,396 | 4,704 | +237% | 0 | 0 | — |
case-18 | fail→fail | 3,915 | 3,695 | -6% | 1 | 1 | 0% | 943 | 3,895 | +313% | 0 | 0 | — |
case-19 | fail→fail | 5,977 | 2,696 | -55% | 1 | 1 | 0% | 1,324 | 3,664 | +177% | 0 | 0 | — |
case-20 | pass→pass | 8,636 | 8,409 | -3% | 1 | 1 | 0% | 2,246 | 4,941 | +120% | 0 | 0 | — |
case-21 | pass→pass | 14,994 | 16,197 | +8% | 1 | 1 | 0% | 2,776 | 6,030 | +117% | 0 | 0 | — |
case-22 | fail→fail | 4,100 | 3,960 | -3% | 1 | 1 | 0% | 863 | 3,825 | +343% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +23 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.