This diagnosis did not start with a bug report. On 1 September 2026 the client asked whether the claim page on their directory was ready for outside users to claim their profiles, which is a readiness question rather than a complaint. The directory holds person profiles and company profiles, and whoever stands behind a profile can claim it through a wizard. Three adversarial passes plus my own greps refuted the optimistic answer. One of the four claim defects I ended up fixing that night turned out to be nothing more than the number 300, written into two query lines.
That number had in fact been on record since mid-August. A read-only measurement on 16 and 17 August, with the load-bearing numbers re-verified afterwards, found that more than 400 of roughly 700 companies could not find themselves in the claim wizard, because its search was capped at 300 rows per type. That is more than half the companies in the directory. I have rounded the exact counts on purpose here.
Two query lines and one alphabetical order
The wizard's first step, FIND, renders the list of people and companies into the page and lets the visitor type a name to filter it. Before the fix, that list came from these two calls. The post type names are disguised, everything else is as it was:
$people = get_posts(['post_type' => 'dir_person', 'posts_per_page' => 300, 'orderby' => 'title', 'order' => 'ASC']);
$companies = get_posts(['post_type' => 'dir_company', 'posts_per_page' => 300, 'orderby' => 'title', 'order' => 'ASC']);Read one at a time, neither line looks suspicious. posts_per_page limits the row count, and orderby title with ASC sorts from A to Z. Put together, the meaning becomes very specific: take the first 300 titles in alphabetical order and forget the rest. A cap like this does not thin a list evenly. It cuts off the tail, and on a list sorted alphabetically, the tail is the last letters of the alphabet.
The cap applies per type. A production measurement on 14 August counted fewer than ten published person profiles, far below 300, so only the company list was cut. The company list held around 700 rows, more than twice the cap. The cut fell at a name starting with K, so every company from K onward could not be found.
What is not rendered cannot be searched
The number 300 costs far more than it looks because of how the search works. The search box in the FIND step never asks the server. The script only matches a lowercase substring against a data attribute on rows that are already in the page. In simplified form, with the attribute name disguised:
// simplified from the search function in the FIND step
var q = (input.value || '').trim().toLowerCase();
var visible = 0;
items.forEach(function (it) {
var txt = it.getAttribute('data-search-text') || '';
var show = txt.indexOf(q) !== -1;
it.style.display = show ? '' : 'none';
if (show) visible++;
});
if (empty) empty.style.display = (visible === 0) ? 'block' : 'none';The commit message for the fix puts the consequence precisely. Because the search filters client side over what is rendered, the cap was never a page-weight setting. It decided who was allowed to find themselves at all. For the company in position 301 alphabetically, the problem is not a match that misses. Its row was never in the DOM, so no amount of typing could bring it up.
What happened after an empty result was worse than the empty result itself. When no row matches, the script shows an empty-state block, and before the fix that block told the visitor they were not listed yet, together with a button to request a profile. Below the search box there was also always a link to a new-profile form for anyone who could not find themselves. So a company whose name fell after the cut typed its own name, was told it was not listed, and was pointed at the profile request form. That form mints a duplicate row instead of claiming the row that already exists.
The handler behind that form does have a duplicate guard. The typed name is turned into a slug with sanitize_title(), looked up with get_page_by_path(), and if it matches a published profile the visitor is redirected back with req_error set to exists. The comment above it even says that for a name that is already listed, the right door is the claim, not a duplicate profile. But the match is exact on the slug, so the guard only catches a name whose slug is identical to the slug of the existing profile. Meanwhile, the wizard had already convinced the visitor that they were not listed.
Links from the profile page were never cut
One thing limited the damage. Deep links from the profile page bypass the cap, so only the wizard was degraded. In the claim template, a URL whose target profile is recognised renders steps 2 and 3 directly, while the FIND list is only rendered in the else branch. Even the unclaimed company rows in that list are just links to a claim URL built from the profile slug and its type. The FIND list is essentially a signpost to the right claim URL, and the cap of 300 decided whose URLs were printed into the page at all.
The cap removed, the cost measured
The fix did not raise the number. Both queries now load every published row with no cap, still sorted by title from A to Z:
$people = get_posts(['post_type' => 'dir_person', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true]);
$companies = get_posts(['post_type' => 'dir_company', 'posts_per_page' => -1, 'post_status' => 'publish', 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true]);A value of -1 for posts_per_page means no limit. no_found_rows stops WordPress from counting the total rows for pagination, which a page that renders everything at once has no use for.
Rendering everything obviously has a cost, and that cost was measured rather than assumed: 893 KB raw, but only 37.6 KB over the wire with gzip, and 0.46 seconds to render. That is roughly 24 to 1, since 893 divided by 37.6 is about 23.8. The commit message also notes that there are no attachment lookups, and that each row's meta and terms are primed by get_posts(). After the fix, more than 700 rows render.
The fix was committed on the night of 1 September 2026. After the deploy, the WP Rocket cache was cleared, Cloudflare was purged, and the result was checked while logged out in a real browser. In that browser, the search surfaced two names from the part that used to be cut plus the name where the old cap fell, each linking to the correct claim URL. All four claim defects from that day's diagnosis are recorded as fixed and verified at the edge that same night.
The same shape on another site's blog index
Two weeks earlier, on 18 August, I fixed the same mechanism on the blog index of another site, and again the trigger was not a bug report. The client's SEO lead asked for a link to one category's archive, with the category name as the anchor text. What was asked for was a link, not a fix to a cap.
The commit message for that fix rejects the premise: the cause is a cap, not a missing link. home.php called get_categories() twice, once for the row of filter chips and once for the content rows per category, and both used number => 12. In the actual file the two calls sit far apart. Below I have put them together with the fixed version, with the shared variable name disguised:
// before: two lists, each cut at 12
$wp_categories = get_categories(['hide_empty' => true, 'number' => 12]);
$row_categories = get_categories(['hide_empty' => true, 'number' => 12]);
// after: one uncapped list, read by both
$categories = get_categories(['hide_empty' => true]);
$wp_categories = $categories;
$row_categories = $categories;There are 13 non-empty categories, ordered by name. With a cap of 12, the category whose name sorts last alphabetically was dropped from the chip row and from the content rows alike, despite holding 35 published posts.
From the SEO side, the missing chip really did read as a missing link. The category chips on the blog index are real anchors to each category's archive, and all 13 archives return 200. Inline JavaScript calls preventDefault() and filters the rows in place, but crawlers do not run click handlers. Those chip anchors are the only internal links the category archives get from the blog index, so the category whose chip was cut received no link from that page at all.
A chip without a row blanks the page
Raising 12 to 13 would have been enough for that day, and adding just the chip also looked like enough. The second option would have shipped a new bug. As soon as any chip other than all is clicked, the filter script hides every base block on the page and then only shows rows whose category attribute equals that chip's term_id. A chip with no matching row filters the page down to nothing.
The fix is one shared, uncapped category variable read by both the chip row and the content rows, which makes the coupling between them structural. The cap was removed rather than bumped, because the next category anyone adds would hit the same wall, in the same way, silently.
My SEO notes put that link request in the 18 August block whose heading says that day's requests were all shipped and verified at the edge, with the status DONE. Beyond that statement and the 13 archives returning 200, my notes hold no post-deploy measurement that specifically mentions that category's chip or row.
A cap on a sorted list is a cut, not a sample
Both cases are built from the same three ingredients: a cap that looks like a page-weight setting, an order by name, and something downstream that assumes the list is complete. The client-side search assumes every candidate is in the DOM. The chip filter assumes every chip has a row. Nothing throws an error, and what goes missing is always the tail of the alphabet. That tail grows every time the list grows, because the cap stays where it is.
If you want to sweep your own theme, a rough search like this is enough to collect the candidates:
grep -rnE "'(posts_per_page|numberposts|number)'[[:space:]]*=>[[:space:]]*[0-9]+" wp-content/themes/The results will usually be mixed with ordinary archive loops that are perfectly reasonable to cap, so each line still needs reading with two questions. Does this list feed a search or a filter on the client side? And is there another list that must always stay paired with it? If the first answer is yes, that number is deciding who is allowed to exist, and the cost of rendering everything is worth measuring before the cap is kept. If the second answer is yes, both lists belong in one variable, as on the blog index above.