Published on July 9, 2026

A user's music library can hold 10,000+ tracks, hundreds of playlists, and years of listening history. Fetching all of that in a single API call is not just slow; most streaming services will reject the request outright. Pagination is how you retrieve large datasets in manageable chunks, and every music streaming API implements it differently.
This guide covers the pagination models you will encounter when building on music streaming APIs, the pitfalls that break naive implementations, and how to build production-grade pagination loops that work reliably across services.
Music libraries grow fast. A casual listener might have 500 saved tracks. A power user can easily exceed 15,000. Playlists, albums, and listening history multiply the data further. Streaming services enforce pagination to protect their infrastructure, keep response times predictable, and prevent individual API consumers from monopolizing bandwidth. If your app fetches a user's library, you will paginate. There is no shortcut.
| Data Type | Typical Size (Power User) | Default Page Size |
|---|---|---|
| Saved tracks | 5,000 - 20,000 | 20 - 50 |
| Playlists | 50 - 500 | 20 - 50 |
| Playlist tracks | 50 - 10,000 | 50 - 100 |
| Listening history | 1,000 - 50,000 | 20 - 50 |
| Search results | Varies | 10 - 25 |
Ignoring pagination means your app either crashes on large libraries, silently drops data, or hits rate limits within seconds. All three outcomes frustrate users and generate support tickets.
Streaming platforms do not agree on a single pagination standard. You will encounter three main models, sometimes within the same service across different endpoints.
The simplest model. You send an offset (how many items to skip) and a limit (how many to return). The API returns items starting at the offset position.
// Offset-based pagination example
const fetchTracks = async (offset = 0, limit = 50) => {
const response = await fetch(
`https://api.example.com/me/tracks?offset=${offset}&limit=${limit}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
return response.json();
};
Offset pagination is easy to understand but breaks down at scale. If items are added or removed while you paginate, you will either skip items or receive duplicates. At high offsets (page 200+), database performance degrades because the service must count through all preceding rows.
Instead of a numeric offset, the API returns an opaque cursor string that points to the next batch of results. You pass the cursor back in your next request. The service resolves it server-side to the correct position.
// Cursor-based pagination example
const fetchWithCursor = async (cursor = null) => {
const url = cursor
? `https://api.example.com/me/tracks?cursor=${cursor}&limit=50`
: `https://api.example.com/me/tracks?limit=50`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
return response.json(); // { items: [...], next_cursor: "abc123" }
};
Cursor pagination handles concurrent modifications gracefully. Items added or removed between pages do not cause skips or duplicates. Performance stays constant regardless of how deep you are in the dataset.
A variant of cursor pagination where the API returns a nextPageToken or similar field. Functionally identical to cursors, but the naming convention and token format differ. Some services use short-lived tokens that expire after minutes, requiring you to restart pagination if you pause too long.
| Pagination Model | Strengths | Weaknesses |
|---|---|---|
| Offset-based | Simple to implement, supports random access | Skips/duplicates on concurrent writes, slow at high offsets |
| Cursor-based | Consistent results, fast at any depth | No random page access, opaque cursor values |
| Token-based | Same as cursor-based | Tokens may expire, same limitations as cursor |
Building a "just loop until empty" pagination function takes five minutes. Building one that works in production takes significantly more effort. Here are the traps that catch developers.
Some services invalidate cursors after a timeout (typically 5 to 15 minutes). If your app pauses between pages (user interaction, background processing, rate limit backoff), the cursor expires and the next request fails. Your code needs to detect this and restart from the beginning or the last known good position.
Paginating a 10,000-track library at 50 items per page requires 200 API calls. Most streaming services enforce rate limits of 30 to 100 requests per minute per user. A naive loop without delays will hit rate limits by page 30 and receive 429 Too Many Requests responses. Your pagination loop must integrate with your rate limiting strategy.
With offset-based pagination, if a user adds a track to position 1 while you are on page 5, every subsequent page shifts by one item. You either miss a track or receive a duplicate. There is no way to prevent this with offset pagination; you can only detect it by tracking total counts between pages.
Some services cap the maximum retrievable items regardless of pagination. For example, an endpoint might return a total of 15,000 but only allow you to retrieve the first 10,000 through pagination. Your code should detect when the API stops returning results before reaching the reported total.
// Naive pagination loop — breaks under real conditions
const getAllTracks = async () => {
let allTracks = [];
let offset = 0;
const limit = 50;
while (true) {
const response = await fetchTracks(offset, limit);
allTracks.push(...response.items);
if (response.items.length < limit) break; // Assumes no gaps
offset += limit; // No rate limit handling
// No error handling
// No stale cursor detection
// No progress tracking
}
return allTracks; // May contain duplicates or missing items
};
A production-grade pagination function handles retries, rate limits, progress reporting, and cursor expiration. Here is a pattern that covers these cases.
const paginateAll = async ({
fetchPage, // (cursor) => Promise<{ items, nextCursor, total }>
onPage, // (items, progress) => void
maxRetries = 3,
baseDelay = 1000,
}) => {
let cursor = null;
let allItems = [];
let pageNumber = 0;
while (true) {
let attempts = 0;
let page;
while (attempts < maxRetries) {
try {
page = await fetchPage(cursor);
break;
} catch (error) {
attempts++;
if (error.status === 429) {
const retryAfter = error.headers?.['retry-after'] || baseDelay * attempts;
await sleep(retryAfter * 1000);
continue;
}
if (error.message?.includes('expired_cursor')) {
// Cursor expired — restart from beginning
cursor = null;
allItems = [];
pageNumber = 0;
continue;
}
if (attempts >= maxRetries) throw error;
await sleep(baseDelay * Math.pow(2, attempts));
}
}
allItems.push(...page.items);
pageNumber++;
if (onPage) {
onPage(page.items, {
pageNumber,
totalSoFar: allItems.length,
estimatedTotal: page.total,
});
}
if (!page.nextCursor || page.items.length === 0) break;
cursor = page.nextCursor;
// Respectful delay between pages
await sleep(200);
}
return allItems;
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
This function retries on rate limits with exponential backoff, restarts on expired cursors, reports progress per page, and adds a small delay between requests to stay under rate limits.
Handling rate limits and retry logic across multiple streaming services is tedious, error-prone work. Each service returns rate limit headers differently, enforces different windows, and uses different error codes. MusicAPI normalizes all of this behind a single interface, so your pagination code does not need per-service rate limit logic.
When you build directly against each streaming service, you write a different pagination implementation for each one. Offset parameters, cursor formats, page size limits, and response shapes all vary. MusicAPI provides a unified API layer that normalizes pagination across all supported services.
Here is what fetching a user's playlists looks like with MusicAPI versus doing it yourself across multiple services:
With MusicAPI (one implementation for all services):
// Single pagination loop works for Spotify, Apple Music, Tidal, and more
const getUserPlaylists = async (connectionId) => {
let allPlaylists = [];
let cursor = null;
do {
const response = await fetch(
`https://api.musicapi.com/user/playlists?` +
`connectionId=${connectionId}&limit=50` +
(cursor ? `&cursor=${cursor}` : ''),
{ headers: { Authorization: `Bearer ${MUSICAPI_KEY}` } }
);
const data = await response.json();
allPlaylists.push(...data.items);
cursor = data.nextCursor;
} while (cursor);
return allPlaylists;
};
Without MusicAPI (separate implementation per service):
// Service A: offset-based
const getPlaylistsServiceA = async () => {
let offset = 0;
// ... offset pagination logic, service-specific headers
// Service B: cursor-based with expiring tokens
// ... completely different pagination logic
// Service C: page-number based with different auth
// ... yet another pagination pattern
};
MusicAPI handles the translation layer: consistent cursor format across all services, unified page-size parameters, automatic retry on stale cursors, and normalized response shapes. You write one pagination loop, and it works for every supported service.
Check out endpoints like Get User Playlists and Get Favorite Tracks to see paginated responses in action.
Most streaming services cap page sizes between 50 and 100 items per request. Some endpoints allow up to 200. Requesting more than the maximum returns the service's default page size without an error. MusicAPI normalizes this with a consistent limit parameter across all services, capped at each service's actual maximum.
Yes, but carefully. Parallel pagination across different endpoints (playlists and tracks simultaneously) works well because rate limits are typically per-user, not per-endpoint. Parallel pagination of the same endpoint risks cursor conflicts and can double your rate limit consumption. Use parallel requests for independent data types and sequential pagination within a single dataset.
Full library syncs should run as background jobs, not blocking operations. Paginate the entire library on first connection, store the results locally, then use webhook notifications or periodic delta syncs to catch changes. Avoid re-paginating the full library on every app launch; instead, track a sync timestamp and fetch only items modified after that point.
Save your cursor or offset position after each successful page. If the process fails (network error, server timeout, app crash), resume from the last saved position instead of restarting from page one. For cursor-based APIs, check whether the saved cursor is still valid before resuming. If it has expired, restart the pagination but skip items you have already stored locally.
Three strategies work well together. First, request only the fields you need. Most APIs support field selection (fields=id,name,artist), which reduces payload size and response time. Second, use the largest page size the service allows to minimize the total number of requests. Third, implement connection pooling and keep-alive headers to reduce TCP handshake overhead across hundreds of sequential requests.
MusicAPI provides a consistent pagination interface across all supported services, with normalized cursors, unified page-size parameters, and built-in retry logic for expired cursors. You still control the pagination loop in your code (deciding when to fetch the next page, how to store results), but MusicAPI eliminates the per-service differences that make pagination code complex.
Ready to skip months of OAuth and SDK work? Start your free MusicAPI trial and connect 10+ streaming services with one unified API.