Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

View File

@@ -0,0 +1,989 @@
# Query Performance
**Impact: HIGH**
Pagination strategies, relation expansion, field selection, batch operations, and N+1 query prevention.
---
## 1. Use Back-Relations for Inverse Lookups
**Impact: HIGH (Fetch related records without separate queries)**
Back-relations allow you to expand records that reference the current record, enabling inverse lookups in a single request. Use the `collectionName_via_fieldName` syntax.
**Incorrect (manual inverse lookup):**
```javascript
// Fetching a user, then their posts separately
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId);
// Extra request for posts
const posts = await pb.collection('posts').getList(1, 100, {
filter: pb.filter('author = {:userId}', { userId })
});
return { ...user, posts: posts.items };
}
// 2 API calls
// Fetching a post, then its comments
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId);
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
expand: 'author'
});
return { ...post, comments };
}
// 2 API calls
```
**Correct (using back-relation expand):**
```javascript
// Expand posts that reference this user
// posts collection has: author (relation to users)
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // collectionName_via_fieldName
});
console.log('User:', user.name);
console.log('Posts:', user.expand?.posts_via_author);
return user;
}
// 1 API call!
// Expand comments that reference this post
// comments collection has: post (relation to posts)
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
const comments = post.expand?.comments_via_post || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.content}`);
});
return post;
}
// 1 API call with nested expansion!
// Multiple back-relations
async function getUserWithAllContent(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author,comments_via_author,likes_via_user'
});
return {
user,
posts: user.expand?.posts_via_author || [],
comments: user.expand?.comments_via_author || [],
likes: user.expand?.likes_via_user || []
};
}
```
**Back-relation syntax:**
```
{referencing_collection}_via_{relation_field}
Examples:
- posts_via_author -> posts where author = current record
- comments_via_post -> comments where post = current record
- order_items_via_order -> order_items where order = current record
- team_members_via_team -> team_members where team = current record
```
**Nested back-relations:**
```javascript
// Get user with posts and each post's comments
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author.comments_via_post'
});
// Access nested data
const posts = user.expand?.posts_via_author || [];
posts.forEach(post => {
console.log('Post:', post.title);
const comments = post.expand?.comments_via_post || [];
comments.forEach(c => console.log(' Comment:', c.content));
});
```
**Important considerations:**
```javascript
// Back-relations always return arrays, even if the relation field
// is marked as single (maxSelect: 1)
// Limited to 1000 records per back-relation
// For more, use separate paginated query
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author'
});
// If user has 1500 posts, only first 1000 are included
// For large datasets, use paginated approach
async function getUserPostsPaginated(userId, page = 1) {
return pb.collection('posts').getList(page, 50, {
filter: pb.filter('author = {:userId}', { userId }),
sort: '-created'
});
}
```
**Use in list queries:**
```javascript
// Get all users with their post counts
// (Use view collection for actual counts)
const users = await pb.collection('users').getList(1, 20, {
expand: 'posts_via_author'
});
users.items.forEach(user => {
const postCount = user.expand?.posts_via_author?.length || 0;
console.log(`${user.name}: ${postCount} posts`);
});
```
**When to use back-relations vs separate queries:**
| Scenario | Approach |
|----------|----------|
| < 1000 related records | Back-relation expand |
| Need pagination | Separate query with filter |
| Need sorting/filtering | Separate query |
| Just need count | View collection |
| Display in list | Back-relation (if small) |
Reference: [PocketBase Back-Relations](https://pocketbase.io/docs/working-with-relations/#back-relation-expand)
## 2. Use Batch Operations for Multiple Writes
**Impact: HIGH (Atomic transactions, 10x fewer API calls, consistent state)**
Batch operations combine multiple create/update/delete operations into a single atomic transaction. This ensures consistency and dramatically reduces API calls.
**Incorrect (individual requests):**
```javascript
// Creating multiple records individually
async function createOrderWithItems(order, items) {
// If any fails, partial data remains!
const createdOrder = await pb.collection('orders').create(order);
for (const item of items) {
await pb.collection('order_items').create({
...item,
order: createdOrder.id
});
}
// 1 + N API calls, not atomic
}
// Updating multiple records
async function updatePrices(products) {
for (const product of products) {
await pb.collection('products').update(product.id, {
price: product.newPrice
});
}
// N API calls, some might fail leaving inconsistent state
}
// Mixed operations
async function transferFunds(fromId, toId, amount) {
// NOT ATOMIC - can leave invalid state!
await pb.collection('accounts').update(fromId, { 'balance-': amount });
// If this fails, money disappears!
await pb.collection('accounts').update(toId, { 'balance+': amount });
}
```
**Correct (using batch operations):**
```javascript
// Atomic batch create
async function createOrderWithItems(order, items) {
const batch = pb.createBatch();
// Pre-generate order ID so items can reference it in the same batch
// PocketBase accepts custom IDs (15-char alphanumeric)
const orderId = crypto.randomUUID().replaceAll('-', '').slice(0, 15);
// Queue order creation with known ID
batch.collection('orders').create({ ...order, id: orderId });
// Queue all items referencing the pre-generated order ID
items.forEach(item => {
batch.collection('order_items').create({
...item,
order: orderId
});
});
// Execute atomically
const results = await batch.send();
// All succeed or all fail together
return {
order: results[0],
items: results.slice(1)
};
}
// Batch updates
async function updatePrices(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').update(product.id, {
price: product.newPrice
});
});
const results = await batch.send();
// 1 API call, atomic
return results;
}
// Batch upsert (create or update)
async function syncProducts(products) {
const batch = pb.createBatch();
products.forEach(product => {
batch.collection('products').upsert({
id: product.sku, // Use SKU as ID for upsert matching
name: product.name,
price: product.price,
stock: product.stock
});
});
return batch.send();
}
// Mixed operations in transaction
// NOTE: Batch operations respect API rules per-operation, but ensure your
// business logic validates inputs (e.g., sufficient balance) server-side
// via hooks or API rules to prevent unauthorized transfers.
async function transferFunds(fromId, toId, amount) {
const batch = pb.createBatch();
batch.collection('accounts').update(fromId, { 'balance-': amount });
batch.collection('accounts').update(toId, { 'balance+': amount });
// Create audit record
batch.collection('transfers').create({
from: fromId,
to: toId,
amount,
timestamp: new Date()
});
// All three operations atomic
const [fromAccount, toAccount, transfer] = await batch.send();
return { fromAccount, toAccount, transfer };
}
// Batch delete
async function deletePostWithComments(postId) {
// First get comment IDs
const comments = await pb.collection('comments').getFullList({
filter: pb.filter('post = {:postId}', { postId }),
fields: 'id'
});
const batch = pb.createBatch();
// Queue all deletions
comments.forEach(comment => {
batch.collection('comments').delete(comment.id);
});
batch.collection('posts').delete(postId);
await batch.send();
// Post and all comments deleted atomically
}
```
**Batch operation limits:**
- **Must be enabled first** in Dashboard > Settings > Application (disabled by default; returns 403 otherwise)
- Operations execute in a single database transaction
- All succeed or all rollback
- Respects API rules for each operation
- Configurable limits: `maxRequests`, `timeout`, and `maxBodySize` (set in Dashboard)
- **Avoid large file uploads** in batches over slow networks -- they block the entire transaction
- Avoid custom hooks that call slow external APIs within batch operations
**When to use batch:**
| Scenario | Use Batch? |
|----------|-----------|
| Creating parent + children | Yes |
| Bulk import/update | Yes |
| Financial transactions | Yes |
| Single record operations | No |
| Independent operations | Optional |
Reference: [PocketBase Batch API](https://pocketbase.io/docs/api-records/#batch-operations)
## 3. Expand Relations Efficiently
**Impact: HIGH (Eliminates N+1 queries, reduces API calls by 90%+)**
Use the `expand` parameter to fetch related records in a single request. This eliminates N+1 query problems and dramatically reduces API calls.
**Incorrect (N+1 queries):**
```javascript
// Fetching posts then authors separately - N+1 problem
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20);
// N additional requests for N posts!
for (const post of posts.items) {
post.authorData = await pb.collection('users').getOne(post.author);
}
return posts;
}
// 21 API calls for 20 posts!
// Even worse with multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20);
for (const post of posts.items) {
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
post.tags = await Promise.all(
post.tags.map(id => pb.collection('tags').getOne(id))
);
}
// 60+ API calls!
}
```
**Correct (using expand):**
```javascript
// Single request with expanded relations
async function getPostsWithAuthors() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author'
});
// Access expanded data
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
});
return posts;
}
// 1 API call!
// Multiple relations
async function getPostsWithAll() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
posts.items.forEach(post => {
console.log('Author:', post.expand?.author?.name);
console.log('Category:', post.expand?.category?.name);
console.log('Tags:', post.expand?.tags?.map(t => t.name));
});
}
// Still just 1 API call!
// Nested expansion (up to 6 levels)
async function getPostsWithNestedData() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author.profile,category.parent,comments_via_post.author'
});
posts.items.forEach(post => {
// Nested relations
console.log('Author profile:', post.expand?.author?.expand?.profile);
console.log('Parent category:', post.expand?.category?.expand?.parent);
// Back-relations (comments that reference this post)
console.log('Comments:', post.expand?.['comments_via_post']);
});
}
// Back-relation expansion
// If comments collection has a 'post' relation field pointing to posts
async function getPostWithComments(postId) {
const post = await pb.collection('posts').getOne(postId, {
expand: 'comments_via_post,comments_via_post.author'
});
// Access comments that reference this post
const comments = post.expand?.['comments_via_post'] || [];
comments.forEach(comment => {
console.log(`${comment.expand?.author?.name}: ${comment.text}`);
});
return post;
}
```
**Expand syntax:**
| Syntax | Description |
|--------|-------------|
| `expand: 'author'` | Single relation |
| `expand: 'author,tags'` | Multiple relations |
| `expand: 'author.profile'` | Nested relation (2 levels) |
| `expand: 'comments_via_post'` | Back-relation (records pointing to this) |
**Handling optional expand data:**
```javascript
// Always use optional chaining - expand may be undefined
const authorName = post.expand?.author?.name || 'Unknown';
// Type-safe access with TypeScript
interface Post {
id: string;
title: string;
author: string; // Relation ID
expand?: {
author?: User;
};
}
const posts = await pb.collection('posts').getList<Post>(1, 20, {
expand: 'author'
});
```
**Limitations:**
- Maximum 6 levels of nesting
- Respects API rules on expanded collections
- Large expansions may impact performance
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
## 4. Select Only Required Fields
**Impact: MEDIUM (Reduces payload size, improves response time)**
Use the `fields` parameter to request only the data you need. This reduces bandwidth and can improve query performance, especially with large text or file fields.
**Incorrect (fetching everything):**
```javascript
// Fetching all fields when only a few are needed
const posts = await pb.collection('posts').getList(1, 20);
// Returns: id, title, content (10KB), thumbnail, author, tags, created, updated...
// Only displaying titles in a list
posts.items.forEach(post => {
renderListItem(post.title); // Only using title!
});
// Wasted bandwidth on content, thumbnail URLs, etc.
// Fetching user data with large profile fields
const users = await pb.collection('users').getFullList();
// Includes: avatar (file), bio (text), settings (json)...
// When you only need names for a dropdown
```
**Correct (selecting specific fields):**
```javascript
// Select only needed fields
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,created'
});
// Returns only: { id, title, created }
// For a dropdown/autocomplete
const users = await pb.collection('users').getFullList({
fields: 'id,name,avatar'
});
// Include expanded relation fields
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author',
fields: 'id,title,expand.author.name,expand.author.avatar'
});
// Returns: { id, title, expand: { author: { name, avatar } } }
// Wildcard for all direct fields, specific for expand
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category',
fields: '*,expand.author.name,expand.category.name'
});
// All post fields + only name from expanded relations
```
**Using excerpt modifier:**
```javascript
// Get truncated text content
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title,content:excerpt(200,true)'
});
// content is truncated to 200 chars with "..." appended
// Multiple excerpts
const posts = await pb.collection('posts').getList(1, 20, {
fields: 'id,title:excerpt(50),content:excerpt(150,true)'
});
// Excerpt syntax: field:excerpt(maxLength, withEllipsis?)
// - maxLength: maximum characters
// - withEllipsis: append "..." if truncated (default: false)
```
**Common field selection patterns:**
```javascript
// List view - minimal data
const listFields = 'id,title,thumbnail,author,created';
// Card view - slightly more
const cardFields = 'id,title,content:excerpt(200,true),thumbnail,author,created';
// Detail view - most fields
const detailFields = '*,expand.author.name,expand.author.avatar';
// Autocomplete - just id and display text
const autocompleteFields = 'id,name';
// Table export - specific columns
const exportFields = 'id,email,name,created,status';
// Usage
async function getPostsList() {
return pb.collection('posts').getList(1, 20, {
fields: listFields,
expand: 'author'
});
}
```
**Performance impact:**
| Field Type | Impact of Selecting |
|------------|-------------------|
| text/editor | High (can be large) |
| file | Medium (URLs generated) |
| json | Medium (can be large) |
| relation | Low (just IDs) |
| number/bool | Low |
**Note:** Field selection happens after data is fetched from database, so it primarily saves bandwidth, not database queries. For database-level optimization, ensure proper indexes.
Reference: [PocketBase Fields Parameter](https://pocketbase.io/docs/api-records/#fields)
## 5. Use getFirstListItem for Single Record Lookups
**Impact: MEDIUM (Cleaner code, automatic error handling for not found)**
Use `getFirstListItem()` when you need to find a single record by a field value other than ID. It's cleaner than `getList()` with limit 1 and provides proper error handling.
**Incorrect (manual single-record lookup):**
```javascript
// Using getList with limit 1 - verbose
async function findUserByEmail(email) {
const result = await pb.collection('users').getList(1, 1, {
filter: pb.filter('email = {:email}', { email })
});
if (result.items.length === 0) {
throw new Error('User not found');
}
return result.items[0];
}
// Using getFullList then filtering - wasteful
async function findUserByUsername(username) {
const users = await pb.collection('users').getFullList({
filter: pb.filter('username = {:username}', { username })
});
return users[0]; // Might be undefined!
}
// Fetching by ID when you have a different identifier
async function findProductBySku(sku) {
// Wrong: getOne expects the record ID
const product = await pb.collection('products').getOne(sku); // Fails!
}
```
**Correct (using getFirstListItem):**
```javascript
// Clean single-record lookup by any field
async function findUserByEmail(email) {
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
return null; // Not found
}
throw error;
}
}
// Lookup by unique field
async function findProductBySku(sku) {
return pb.collection('products').getFirstListItem(
pb.filter('sku = {:sku}', { sku })
);
}
// Lookup with expand
async function findOrderByNumber(orderNumber) {
return pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: orderNumber }),
{ expand: 'customer,items' }
);
}
// Complex filter conditions
async function findActiveSubscription(userId) {
return pb.collection('subscriptions').getFirstListItem(
pb.filter(
'user = {:userId} && status = "active" && expiresAt > @now',
{ userId }
)
);
}
// With field selection
async function getUserIdByEmail(email) {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email }),
{ fields: 'id' }
);
return user.id;
}
```
**Comparison with getOne:**
```javascript
// getOne - fetch by record ID
const post = await pb.collection('posts').getOne('abc123');
// getFirstListItem - fetch by any filter (use pb.filter for safe binding)
const post = await pb.collection('posts').getFirstListItem(
pb.filter('slug = {:slug}', { slug: 'hello-world' })
);
const user = await pb.collection('users').getFirstListItem(
pb.filter('username = {:name}', { name: 'john' })
);
const order = await pb.collection('orders').getFirstListItem(
pb.filter('orderNumber = {:num}', { num: 12345 })
);
```
**Error handling:**
```javascript
// getFirstListItem throws 404 if no match found
try {
const user = await pb.collection('users').getFirstListItem(
pb.filter('email = {:email}', { email })
);
return user;
} catch (error) {
if (error.status === 404) {
// No matching record - handle appropriately
return null;
}
// Other error (network, auth, etc.)
throw error;
}
// Wrapper function for optional lookup
async function findFirst(collection, filter, options = {}) {
try {
return await pb.collection(collection).getFirstListItem(filter, options);
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
// Usage
const user = await findFirst('users', pb.filter('email = {:e}', { e: email }));
if (!user) {
console.log('User not found');
}
```
**When to use each method:**
| Method | Use When |
|--------|----------|
| `getOne(id)` | You have the record ID |
| `getFirstListItem(filter)` | Finding by unique field (email, slug, sku) |
| `getList(1, 1, { filter })` | Need pagination metadata |
| `getFullList({ filter })` | Expecting multiple results |
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)
## 6. Prevent N+1 Query Problems
**Impact: HIGH (Reduces API calls from N+1 to 1-2, dramatically faster page loads)**
N+1 queries occur when you fetch a list of records, then make additional requests for each record's related data. This pattern causes severe performance issues at scale.
**Incorrect (N+1 patterns):**
```javascript
// Classic N+1: fetching related data in a loop
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20); // 1 query
for (const post of posts.items) {
// N additional queries!
post.author = await pb.collection('users').getOne(post.author);
post.category = await pb.collection('categories').getOne(post.category);
}
// Total: 1 + 20 + 20 = 41 queries for 20 posts
}
// N+1 with Promise.all (faster but still N+1)
async function getPostsParallel() {
const posts = await pb.collection('posts').getList(1, 20);
await Promise.all(posts.items.map(async post => {
post.author = await pb.collection('users').getOne(post.author);
}));
// Still 21 API calls, just parallel
}
// Hidden N+1 in rendering
function PostList({ posts }) {
return posts.map(post => (
<PostCard
post={post}
author={useAuthor(post.author)} // Each triggers a fetch!
/>
));
}
```
**Correct (eliminate N+1):**
```javascript
// Solution 1: Use expand for relations
async function getPostsWithDetails() {
const posts = await pb.collection('posts').getList(1, 20, {
expand: 'author,category,tags'
});
// All data in one request
posts.items.forEach(post => {
console.log(post.expand?.author?.name);
console.log(post.expand?.category?.name);
});
// Total: 1 query
}
// Solution 2: Batch fetch related records
async function getPostsWithAuthorsBatch() {
const posts = await pb.collection('posts').getList(1, 20);
// Collect unique author IDs
const authorIds = [...new Set(posts.items.map(p => p.author))];
// Single query for all authors (use pb.filter for safe binding)
const filter = authorIds.map(id => pb.filter('id = {:id}', { id })).join(' || ');
const authors = await pb.collection('users').getList(1, authorIds.length, {
filter
});
// Create lookup map
const authorMap = Object.fromEntries(
authors.items.map(a => [a.id, a])
);
// Attach to posts
posts.items.forEach(post => {
post.authorData = authorMap[post.author];
});
// Total: 2 queries regardless of post count
}
// Solution 3: Use view collection for complex joins
// Create a view that joins posts with authors:
// SELECT p.*, u.name as author_name, u.avatar as author_avatar
// FROM posts p LEFT JOIN users u ON p.author = u.id
async function getPostsFromView() {
const posts = await pb.collection('posts_with_authors').getList(1, 20);
// Single query, data already joined
}
// Solution 4: Back-relations with expand
async function getUserWithPosts(userId) {
const user = await pb.collection('users').getOne(userId, {
expand: 'posts_via_author' // All posts by this user
});
console.log('Posts by user:', user.expand?.posts_via_author);
// 1 query gets user + all their posts
}
```
**Detecting N+1 in your code:**
```javascript
// Add request logging to detect N+1
let requestCount = 0;
pb.beforeSend = (url, options) => {
requestCount++;
console.log(`Request #${requestCount}: ${options.method} ${url}`);
return { url, options };
};
// Monitor during development
async function loadPage() {
requestCount = 0;
await loadAllData();
console.log(`Total requests: ${requestCount}`);
// If this is >> number of records, you have N+1
}
```
**Prevention checklist:**
- [ ] Always use `expand` for displaying related data
- [ ] Never fetch related records in loops
- [ ] Batch fetch when expand isn't available
- [ ] Consider view collections for complex joins
- [ ] Monitor request counts during development
Reference: [PocketBase Expand](https://pocketbase.io/docs/api-records/#expand)
## 7. Use Efficient Pagination Strategies
**Impact: HIGH (10-100x faster list queries on large collections)**
Pagination impacts performance significantly. Use `skipTotal` for large datasets, cursor-based pagination for infinite scroll, and appropriate page sizes.
**Incorrect (inefficient pagination):**
```javascript
// Fetching all records - memory and performance disaster
const allPosts = await pb.collection('posts').getFullList();
// Downloads entire table, crashes on large datasets
// Default pagination without skipTotal
const posts = await pb.collection('posts').getList(100, 20);
// COUNT(*) runs on every request - slow on large tables
// Using offset for infinite scroll
async function loadMore(page) {
// As page increases, offset queries get slower
return pb.collection('posts').getList(page, 20);
// Page 1000: skips 19,980 rows before returning 20
}
```
**Correct (optimized pagination):**
```javascript
// Use skipTotal for better performance on large collections
const posts = await pb.collection('posts').getList(1, 20, {
skipTotal: true, // Skip COUNT(*) query
sort: '-created'
});
// Returns items without totalItems/totalPages (faster)
// Cursor-based pagination for infinite scroll
async function loadMorePosts(lastCreated = null) {
const filter = lastCreated
? pb.filter('created < {:cursor}', { cursor: lastCreated })
: '';
const result = await pb.collection('posts').getList(1, 20, {
filter,
sort: '-created',
skipTotal: true
});
// Next cursor is the last item's created date
const nextCursor = result.items.length > 0
? result.items[result.items.length - 1].created
: null;
return { items: result.items, nextCursor };
}
// Usage for infinite scroll
let cursor = null;
async function loadNextPage() {
const { items, nextCursor } = await loadMorePosts(cursor);
cursor = nextCursor;
appendToList(items);
}
// Batched fetching when you need all records
async function getAllPostsEfficiently() {
const allPosts = [];
let page = 1;
const perPage = 1000; // Larger batches = fewer requests (max 1000 per API limit)
while (true) {
const result = await pb.collection('posts').getList(page, perPage, {
skipTotal: true
});
allPosts.push(...result.items);
if (result.items.length < perPage) {
break; // No more records
}
page++;
}
return allPosts;
}
// Or use getFullList with batch option
const allPosts = await pb.collection('posts').getFullList({
batch: 1000, // Records per request (default 1000 since JS SDK v0.26.6; max 1000)
sort: '-created'
});
```
**Choose the right approach:**
| Use Case | Approach |
|----------|----------|
| Standard list with page numbers | `getList()` with page/perPage |
| Large dataset, no total needed | `getList()` with `skipTotal: true` |
| Infinite scroll | Cursor-based with `skipTotal: true` |
| Export all data | `getFullList()` with batch size |
| First N records only | `getList(1, N, { skipTotal: true })` |
**Performance tips:**
- Use `skipTotal: true` unless you need page count
- Keep `perPage` reasonable (20-100 for UI, up to 1000 for batch exports)
- Index fields used in sort and filter
- Cursor pagination scales better than offset
Reference: [PocketBase Records API](https://pocketbase.io/docs/api-records/)