Asking for every post is one argument away, which is why it survives review: it works on the development database, and the development database has forty products in it. On a catalogue that has grown to forty thousand it is a query with no LIMIT followed by forty thousand objects built in memory.
// fine on a 40-post blog, fatal on a 40,000-product catalogue
$all = new WP_Query( array(
'post_type' => 'product',
'posts_per_page' => -1,
) );
// bounded, unhydrated, and without the row count nobody reads
$batch = new WP_Query( array(
'post_type' => 'product',
'posts_per_page' => 500,
'paged' => $page,
'fields' => 'ids',
'no_found_rows' => true,
) );
The cost is not the SQL. WordPress hydrates every returned row into a WP_Post and then primes the meta and term caches for the whole set, so memory scales with the number of matching posts rather than with the size of the page being rendered. When the answer genuinely is “all of them”, fields => ids skips the hydration and returns a flat array of integers, and paging with a bounded posts_per_page keeps peak memory flat however far the catalogue grows. no_found_rows belongs on the same query for the same reason: without pagination on screen, the SQL_CALC_FOUND_ROWS pass over the entire result set is work whose output is discarded.