GitHub: https://github.com/vendure-ecommerce/vendure/issues/4010
When listing collections with productVariants { totalItems }, the system executes N+1 queries - one count query per collection. With 4000+ collections, this causes server crashes.
Batch-fetch variant counts for all collections in a single query at the top-level resolver, cache the promise, and let field resolvers await it.
Add a dedicated productVariantCount field to the Collection type instead of relying on productVariants(take: 0) { totalItems } hack:
type Collection {
# ... existing fields
productVariantCount: Int!
}
Location: src/service/services/product-variant.service.ts
async getVariantCountsByCollectionIds(
ctx: RequestContext,
collectionIds: ID[],
): Promise<Map<ID, number>>
Single query with GROUP BY collection.id returning counts for all requested collections.
Locations:
src/api/resolvers/admin/collection.resolver.tssrc/api/resolvers/shop/shop-products.resolver.tsUses graphql-fields to check if productVariantCount is requested, then caches the promise (not awaited):
const fields = graphqlFields(info);
const itemFields = fields.items ?? {};
if ('productVariantCount' in itemFields) {
const collectionIds = result.items.map(c => c.id);
const variantCountsPromise =
this.productVariantService.getVariantCountsByCollectionIds(ctx, collectionIds);
this.requestContextCache.set(ctx, COLLECTION_VARIANT_COUNTS_CACHE_KEY, variantCountsPromise);
}
Location: src/api/resolvers/entity/collection-entity.resolver.ts
Add new productVariantCount field resolver:
@ResolveField()
async productVariantCount(
@Ctx() ctx: RequestContext,
@Parent() collection: Collection,
): Promise<number> {
// Check for cached promise from batch-fetch (list queries)
const cachedPromise = this.requestContextCache.get<Promise<Map<ID, number>>>(
ctx,
COLLECTION_VARIANT_COUNTS_CACHE_KEY,
);
if (cachedPromise) {
const counts = await cachedPromise;
return counts.get(collection.id) ?? 0;
}
// Fallback for single collection queries - fetch individually
return this.productVariantService.getVariantCountByCollectionId(ctx, collection.id);
}
Remove the take === 0 hack from productVariants resolver.
Add productVariantCount: Int! to Collection type in:
src/api/schema/admin-api/collection.api.graphqlsrc/api/schema/shop-api/collection.api.graphql (if applicable)Add fallback method for single collection queries:
async getVariantCountByCollectionId(ctx: RequestContext, collectionId: ID): Promise<number>
Location: src/cache/request-context-cache.service.ts
Fixed get() method - was using if (result) which fails for falsy values like 0. Changed to if (ctxCache.has(key)).
Location: e2e/collection-n-plus-one.e2e-spec.ts
Tests:
productVariantCount requested| Scenario | Queries (Before) | Queries (After) |
|---|---|---|
| 16 collections with counts | 38 | 4 |
| 16 collections without counts | 3 | 3 |
productVariantCount to GraphQL schemaproductVariantCount field resolvergetVariantCountByCollectionId fallback methodtake === 0 hack from productVariants resolverproductVariantCount instead of productVariants.totalItemssrc/service/services/product-variant.service.ts - batch count methodsrc/api/resolvers/admin/collection.resolver.ts - promise cachingsrc/api/resolvers/shop/shop-products.resolver.ts - promise cachingsrc/api/resolvers/entity/collection-entity.resolver.ts - cache key export, needs refactorsrc/cache/request-context-cache.service.ts - falsy value bug fixe2e/collection-n-plus-one.e2e-spec.ts - benchmark tests