A client came to me a few weeks ago with a strange one. They run a high-traffic WooCommerce store, and after a routine update their “Next Product” links started misbehaving. On products that had been bulk-imported on the same day, the navigation reloaded the current page instead of moving on. An infinite loop, and it made the catalog look broken to anyone browsing it.
My first instinct was the cache, which is usually the culprit. I flushed Redis, purged the CDN, and checked their custom fragments for race conditions. Nothing changed: the “Next” link kept pointing at the same post ID. So the problem was not in the delivery, it was in the query logic, and specifically in how WordPress has handled adjacent post navigation since 6.9.
The tiebreaker change in WordPress 6.9
Before 6.9, get_adjacent_post() leaned almost entirely on post_date. Publish five posts in the same second, which is exactly what a bulk import does, and the database had no deterministic way to decide which one came next. The order came out as a coin flip, and navigation bugs followed.
6.9 added the post ID as a tiebreaker. When two dates are identical, the query now compares IDs to settle the order. The full discussion is on Trac ticket #8107. Good fix for core, but it broke custom code that was playing fast and loose with the SQL filters.
Plenty of themes and plugins, including the one my client was running, hook into the get_{$adjacent}_post_where filter to change the navigation. They usually do a plain str_replace on the date. In 6.9 that WHERE clause turned from a single date check into a nested comparison that mentions the post ID twice. Replace only the date and leave the ID alone, and the query hands you back the current post.
Fixing the loop in the filter
If you are changing the navigation logic, swapping strings is no longer enough. You have to account for the secondary ID comparison that core now injects. This is what I ended up writing for the client.
/**
* Fixes adjacent post navigation loops for identical post dates.
*/
function bbioon_fix_adjacent_navigation( $where, $in_same_term, $excluded_terms, $taxonomy, $post ) {
// Logic to find your custom target post
$target_post = bbioon_get_custom_target( $post );
if ( ! $target_post instanceof WP_Post ) {
return $where;
}
// Replace the date comparison as usual
$where = str_replace( $post->post_date, $target_post->post_date, $where );
// Here is the kicker: we MUST handle the ID comparison for WP 6.9+
$where = preg_replace(
"/AND p\.ID (<|>) {$post->ID}\)/",
"AND p.ID $1 {$target_post->ID})",
$where
);
return $where;
}
add_filter( 'get_next_post_where', 'bbioon_fix_adjacent_navigation', 10, 5 );
add_filter( 'get_previous_post_where', 'bbioon_fix_adjacent_navigation', 10, 5 );
The regex is there because the SQL comes out slightly different for the next post than it does for the previous one. With it, when the dates match, the tiebreaker compares against the target post’s ID rather than the current one.
String replacement on SQL is brittle
The trap here is manipulating SQL with string replacement. It is quick, and it works until core changes the string, which it will, sometimes to fix a decade-old bug. Then the quick fix is the thing that takes the site down. WP_Query behaves the same way with complex joins: imprecise code survives right up to the point where it doesn’t.
This kind of debugging eats days. If you would rather hand it over than keep patching cracks in someone else’s code, get in touch with my team. We have probably seen your version of it already.
Have you hit the 6.9 navigation change yet? Most developers don’t notice it until a bulk import lands a batch of posts on the same timestamp, and then it shows up everywhere.