Stop Using Offsets: Cursor-Based Pagination in the Facebook Graph API
Offset pagination breaks on live Facebook data. Here's how cursor-based pagination in the Graph API keeps results consistent, with a working Python loop and the rate-limit traps to avoid.
18 May 2026, 10:52 UTC

If you've ever paged through a Facebook feed by asking for "page 2" or skipping 50 records, you've probably seen the bug: an item appears twice, or one vanishes entirely. That's not your code — it's the data moving under you. Someone posted, someone deleted, and every offset you computed is now pointing at the wrong row. The Graph API's answer to this is cursor-based pagination, and it's the only reliable way to walk large edges like posts, comments, or ad insights.
The thesis is simple: stop thinking in page numbers and start thinking in bookmarks. The API hands you opaque bookmark strings, you hand them back, and the results stay consistent even while the underlying data changes.
Why offsets fall apart on live social data
Offset pagination says "give me items 101–125." That works fine for a static table. A Facebook page's post list is anything but static — new posts land at the top, old ones get deleted, and ranking can reorder things between your requests. By the time you ask for the second chunk, item 101 is a different post than it was a second ago. You get duplicates, gaps, or both.
Cursors sidestep this by encoding a position relative to a specific item rather than a count from the top. "Give me what comes after this point" stays meaningful even when items are inserted before that point.
How the paging object works
Every list response from a Graph API edge can include a paging object with two things: a cursors object containing before and after strings, and often convenience URLs in next and previous. You treat the cursor strings as opaque — don't parse them, don't build them yourself, just pass them back verbatim.
Because both directions exist, you can walk backward to earlier pages with before or forward with after. When the API stops returning a next URL (or an after cursor), you've reached the end of the edge.
A worked example: paging through a user's posts
Here's the pattern in Python, run from any environment with network access and a valid user access token. Replace USER_ID and ACCESS_TOKEN with your values — the token needs the appropriate permission for the edge you're reading (for example, user_posts on your own posts).
import requests
BASE = "https://graph.facebook.com/v19.0"
TOKEN = "ACCESS_TOKEN"
params = {"access_token": TOKEN, "limit": 25}
url = f"{BASE}/USER_ID/posts"
seen = set()
while url:
resp = requests.get(url, params=params)
resp.raise_for_status()
data = resp.json()
for post in data.get("data", []):
if post["id"] in seen:
print("duplicate:", post["id"])
seen.add(post["id"])
paging = data.get("paging", {})
url = paging.get("next") # None when the edge is exhausted
params = {} # 'next' already embeds cursor + token
print(f"collected {len(seen)} unique posts")Two details matter here. First, the next URL already contains the cursor and token, so you clear params after the first request to avoid sending stale parameters. Second, the duplicate check costs almost nothing and tells you immediately whether your pagination is actually stable. You can validate the same flow interactively in the Graph API Explorer before writing any code: request an edge with a small limit and confirm the paging.cursors values are non-empty strings.
Trade-offs and limits worth planning for
Cursors are not permanent. They're opaque, version-scoped, and can become invalid if the underlying data changes significantly or too much time passes. Don't store a cursor as a durable resume point for a job that runs next week — store the ID of the last item you processed and use time-based parameters (since/until) to pick up where you left off instead.
Rate limits are the other constraint. Facebook enforces per-app and per-user throttling, and aggressive paging loops can trigger errors (commonly error code 4 for app-level throttling). Watch the usage headers in responses — X-Application-Usage and, for ads edges, X-Ad-Account-Usage — and back off when utilization climbs. A modest limit (25–100) with a short sleep between pages is usually gentler than hammering with limit=500.
Finally, cursor behavior is only guaranteed within the API version you tested against. When you upgrade versions, re-run your pagination loop end to end — Meta's changelog occasionally adjusts edge behavior, and pagination is exactly the kind of thing that breaks quietly.
What to do next
Take one place in your codebase where you paginate a Graph API edge with offsets or manual page counting and convert it to the cursor loop above. Then run it twice against a busy edge — a page with active commenters works well — and confirm the second run produces no duplicates and no gaps in the overlapping window. That ten-minute check will tell you more than any documentation page about whether your pagination is production-ready.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.