WordPress REST API Pagination: Diagnose Silent Failures First
October 2, 2026
Why Offset Pagination Times Out Above 10k Rows
Pagination in WordPress REST API looks solved. It isn't, not once your dataset grows past a certain point. The default offset pagination that ships with core works fine on a few hundred posts and quietly falls apart on tens of thousands. If you're seeing timeouts, dropped records, or pages that just never load, you're not doing it wrong. You're hitting a limitation nobody documents clearly.
Here's the mechanic that causes it. Offset pagination tells MySQL "skip the first N rows, then give me the next batch." The database doesn't know how to jump straight to row 40,000. It has to scan through everything before it, discard it, then return your slice. According to MySQL's documentation, this makes offset pagination scale poorly because the database must skip rows before returning results, which is effectively O(n) for large tables. Page 2 is fast. Page 400 is not.
Standard WordPress REST endpoints expose this through the familiar ?page=1&per_page=10 parameters, as described in the [WordPress REST API Handbook](https://developer.wordpress.org/rest-api/). That's convenient and it's also the entire problem. Nothing in that default setup logs when a query stalls. You just get a slow response or a timeout, and the error log tells you nothing useful.
I've seen this directly on filtered datasets north of 50,000 posts. Even when a filter narrows the final result down to a handful of rows, the database still has to skip everything ahead of the offset before it can apply that filter. The fix depends heavily on what you're actually paginating. Posts, custom post types, and meta-filtered data all fail differently, which is why diagnosis has to come before you touch any code.
Meta Filtering Without Indexes Causes Silent Full Table Scans
This is the one that catches people off guard, because it doesn't look like a pagination problem at first. It looks like your filter is just slow.
WP_Query's meta_query runs a full table scan by default, regardless of how specific your filter is. The reason is structural: post meta has no index on meta_value. The WordPress Codex entry for WP_Query is blunt about this: filtering by post meta without proper indexing causes full table scans on large datasets. It doesn't matter if you're filtering on one custom field with ten possible values across 50,000 posts. MySQL still walks every row.
The result is memory exhaustion or a timeout, and WordPress won't warn you beforehand. There's no "this query is about to be expensive" message. The integration just stops responding, and you're left guessing whether it's your filter logic, your hosting, or something else entirely.
Indexed meta queries are possible, but they live outside what core REST gives you. You're either adding direct indexes to the postmeta table, which means touching the database layer directly, or restructuring how that data is stored so it's queryable without a full scan. If you're going to touch indexes or schema directly, back up the database and test on a staging copy first. This isn't something to experiment with on a live site.
Filtered Result Counts Don't Match Total Counts, Breaking Pagination Logic
This failure is sneakier because the API doesn't error out. It just lies to you.
When you apply a filter, the total_pages value returned in the response sometimes gets calculated from the pre-filter count rather than the actual filtered count. Your client code trusts that number to calculate pagination boundaries. If it's wrong, you either request pages that don't exist or stop short of pages that do, silently dropping data your integration thinks it already has.
The root cause is almost always a mismatch between the query that counts total results and the query that fetches them. Somewhere in the endpoint logic, the count query and the fetch query aren't using the same filter conditions.
To diagnose it: compare the X-WP-Total header against the size of the actual filtered result set you get back. If they don't line up, trace back through the register_rest_route callback and check whether your count logic and your fetch logic are built from the same query parameters. This is a common failure point in custom endpoints where someone wrote the count as an afterthought.
Cursor Pagination Breaks When Posts Are Inserted or Deleted Mid-Request
Cursor pagination gets pitched as the fix for offset's performance problems, and it is faster. It also introduces a different kind of fragility that people don't expect until it bites them.
Offset pagination at least gives you a stable page number to request again. Cursor pagination relies on a sort key, usually post ID, and assumes the underlying data doesn't shift between requests. If a post gets inserted or deleted while a client is paging through results, the cursor position shifts under it.
WordPress has no built-in cursor support. Anything you build is custom, which means you're also responsible for handling edge cases like a deleted post shifting everything downstream. On third-party integrations that poll your API on a schedule, I've seen a single cursor reset cause duplicate records on one side and missing records on the other, because the polling system had no way to know the cursor had moved. If you're dealing with scheduled syncs in general, I've written separately about why scheduled API syncs keep missing data, which covers a lot of the same root causes from the sync side.
Cursor pagination is worth the added complexity specifically when you're syncing high-volume, frequently-updated datasets. For a low-traffic catalog or a site that rarely changes, it's solving a problem you don't have yet.
How to Diagnose Which Failure You're Experiencing
Before you rebuild anything, figure out which of the above you're actually dealing with. These four failures look similar from the outside but need different fixes.
- Enable MySQL query logging, or use a tool like Query Monitor, so you can see the actual SQL being generated and how long it takes.
- Check your error logs for timeouts with no specific error message attached. That's the signature of offset pagination stalling under load, not a code exception.
- Test with a small
per_pagevalue, 5 or 10, on a deeply-filtered query. If page 1 returns fast and page 50 is slow, you're looking at offset degradation, not a filter problem. - Compare
total_pagesagainst the result count you'd expect from your filter. A mismatch points straight at a count-query bug. - Inspect the
WP_REST_Requestobject to confirm which parameters are actually reachingWP_Query. Parameter name mismatches between your frontend and your endpoint silently break filters with no error at all.
On stores and marketplaces I've built, I've run into every one of these independently: pagination returning wrong page counts once a filter got applied, silent timeouts on datasets past 50,000 posts filtered by meta with no indexes, and cursor pagination that needed rework after it broke mid-sync for a third-party integration. They look like the same bug from the outside. They're not.
Fix: Choose Cursor Pagination, Indexed Meta, or Pre-Filtered Views Based on Your Use Case
There's no single correct pagination strategy. The right one depends on what you're filtering and who's consuming it.
For public REST endpoints filtering on built-in fields like title, status, or date, optimize with proper MySQL indexes and stay on offset pagination. It's simpler to reason about and good enough at that scale.
For custom post types filtered by custom fields, you're choosing between adding direct indexes to the postmeta table or moving to a denormalized structure that's faster to query. Either one touches the database directly, so back it up and test on staging first.
For third-party integrations syncing on a schedule, implement cursor pagination keyed on a timestamp plus ID, not ID alone. ID-only cursors are exactly what breaks when rows get inserted mid-sync.
For large WooCommerce stores, don't rebuild this yourself. The [WooCommerce REST API documentation](https://woocommerce.com/document/woocommerce-rest-api/) confirms native filtering on price, stock, and category through built-in, indexed parameters. That's already solved for you.
If you're earlier in the decision, before you've even built the custom post type structure, it's worth checking that show_in_rest and meta registration are set up correctly from the start. The [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/post-types/registering-custom-post-types/) is clear that custom post types need 'show_in_rest' => true explicitly, and meta fields need their own registration on top of that. Missing this is one of the most common reasons a filter looks broken when it's actually just invisible to the API.
For general REST groundwork, including route registration and parameter validation through register_rest_route, the WordPress REST API Handbook covers how built-in sanitization avoids a lot of injection risk without extra code. Worth knowing before you start adding custom filter parameters of your own.
If you've been staring at a pagination bug that only shows up past a certain dataset size, which of these four are you actually looking at? That question alone will save you a rebuild.
FAQ
Do I need a plugin to paginate WordPress REST API endpoints?
No. WordPress includes stable REST endpoints since version 4.7 without plugins. Custom registration via register_rest_route lets you add pagination parameters. Plugins add convenience but they also add points of failure if they're poorly written.
Why is my filtered query fast locally but slow in production?
Production usually has more data. Offset pagination degrades predictably with dataset size. Test pagination at the actual row count you'll hit in production. If it's slow, the issue is indexing or query design, not your code.
Can I use cursor pagination with WooCommerce REST API?
WooCommerce's native REST API uses offset pagination. Cursor support exists in custom implementations but adds complexity. For most stores, optimized offset pagination with proper indexes is faster and simpler.
Does registering a custom post type with show_in_rest automatically expose all its meta fields?
No. Post meta requires explicit registration. Use register_rest_field or register_meta with show_in_rest => true to expose custom fields. Missing this step is a common reason filters silently fail.