Diagnose a product-listing slowdown before changing caches

Measure the product-listing request as a chain of work, then change the slowest proven stage instead of treating cache changes as a default fix.

Znode 9GuideIntermediateGCG engineering guide
In this guide
Browser navigation Storefront render Product API Search and data dependencies Inventory and price context Measured outcome
A timing map should follow the request from browser through storefront and product dependencies, with each measured stage labeled by duration and freshness risk.

Start with a bounded complaint

A slow product listing page is usually a chain of delays rather than one bad cache setting. A category request can include browser work, HTML delivery, API work, product projection, inventory or price context, search, database access, and third-party scripts. Changing a cache before locating the slow stage can preserve stale data without improving the reader's experience.

Write the incident in one sentence that names the route, storefront, customer context, representative category, and observed symptom. Include whether the problem is a slow first view, slow pagination, slow filters, or an apparently slow page after the document has already loaded. That boundary prevents a product API diagnosis from quietly becoming a browser-rendering investigation.

  • Choose a synthetic category and test account with stable permissions.
  • Record cold and repeat views separately.
  • Keep price, inventory, personalization, and filter state explicit in every observation.

Collect a timing breakdown

Use browser network timing to separate document, stylesheet, script, image, and API requests. Correlate each request with server-side duration and a request identifier that contains no account, SKU, or customer data. Then compare server processing time with total browser time. A fast API accompanied by a long client waterfall is a different problem from a slow product result.

Capture at least several repeats under the same controlled conditions and report ranges, not a single flattering result. Test one category that has modest product count and one that represents the complaint. Avoid measuring from an administrator session if normal buyers receive different catalogs, price lists, or inventory visibility.

Save the helper as observe-plp.ts in a temporary storefront diagnostics module. It polls up to 200 times at 50 ms intervals and reports incomplete or unavailable when a finalized navigation entry cannot be obtained. Browser scheduling can stretch that interval. Request-to-first-byte measures responseStart minus requestStart, so it includes network and server time. The document-load and interactive figures overlap and should not be added together. No route, query string, account, or product identifier enters the result.

observe-plp.ts typescript

export type NavigationSnapshot = Pick<PerformanceNavigationTiming,
"startTime" | "requestStart" | "responseStart" | "responseEnd" |
"domInteractive" | "loadEventEnd">;
export type NavigationResult =
| { state: "complete"; loadMs: number; requestToFirstByteMs: number;
downloadMs: number; interactiveMs: number }
| { state: "unavailable" | "incomplete" | "invalid" };
// read/pause allow deterministic timing tests. No URL or account data leaves here.
export async function waitForNavigation(
read: () => NavigationSnapshot | undefined,
pause: () => Promise<void>,
attempts = 200
): Promise<NavigationResult> {
if (!Number.isInteger(attempts) || attempts < 1 || attempts > 600)
throw new RangeError("attempts must be 1..600");
let sawEntry = false;
for (let i = 0; i < attempts; i++) {
const entry = read();
if (entry) {
sawEntry = true;
const values = [entry.startTime, entry.requestStart, entry.responseStart,
entry.responseEnd, entry.domInteractive, entry.loadEventEnd];
if (values.some(v => !Number.isFinite(v) || v < 0))
return { state: "invalid" };
// loadEventEnd becomes available after the load handlers have finished.
if (entry.loadEventEnd > 0) {
if (entry.requestStart < entry.startTime ||
entry.responseStart < entry.requestStart ||
entry.responseEnd < entry.responseStart ||
entry.domInteractive < entry.startTime ||
entry.loadEventEnd < entry.domInteractive)
return { state: "invalid" };
return {
state: "complete",
loadMs: Math.round(entry.loadEventEnd - entry.startTime),
requestToFirstByteMs: Math.round(entry.responseStart - entry.requestStart),
downloadMs: Math.round(entry.responseEnd - entry.responseStart),
interactiveMs: Math.round(entry.domInteractive - entry.startTime)
};
}
}
if (i + 1 < attempts) await pause();
}
return { state: sawEntry ? "incomplete" : "unavailable" };
}
export function observeDocumentNavigation(): Promise<NavigationResult> {
return waitForNavigation(
() => performance.getEntriesByType("navigation")[0] as
PerformanceNavigationTiming | undefined,
() => new Promise<void>(resolve => window.setTimeout(resolve, 50))
);
}
// Invoke in a browser diagnostic module:
// void observeDocumentNavigation().then(result => console.table(result));

Isolate one stage at a time

Compare a response that excludes optional enrichments only in a non-production diagnostic build, or use existing observability to time each dependency. Do not ship a broad feature bypass as a measurement tool. A product projection may be slow because it performs repeated price or inventory lookups, while a filter can be slow because its index is stale or its query shape grows with the category size.

Treat freshness as a requirement alongside latency. Inventory synchronization and publish-time projection can affect what a shopper sees, but their presence does not prove they are the page bottleneck. If a change improves timing while making availability or account prices incorrect, it failed the actual commerce requirement.

  • Compare product retrieval with and without the suspected dependent call in a controlled environment.
  • Inspect query count and payload size before adding a cache.
  • Verify that filters, paging, prices, and inventory remain correct after each experiment.

Choose and verify the smallest remedy

Possible remedies include removing duplicate work, batching a bounded dependency, reducing unnecessary payload, correcting an index or publish path, deferring nonessential browser work, or adding a cache whose key represents every value that changes the result. A cache key that omits account, portal, currency, quantity, inventory context, or filter state can return a fast but incorrect listing.

Publish a before-and-after result only after repeating the same scenarios and checking the business outcomes. Record the exact version, environment, test fixtures, sampling method, and known limitations. Roll back a change when it creates stale availability, leaked account pricing, or an unexplained error increase. That record makes the next performance investigation faster and more honest.

Znode 9 documents separate cache eviction behavior for API and WebStore processes. Use that guide after the trace identifies a stale or redundant cache path, then choose the affected layer deliberately.

Verification checklist

A useful diagnostic ends with evidence that another engineer can reproduce. Keep the baseline and result together, and attach traces only after removing sensitive headers, identifiers, and query values. Re-run after deployment, because a local improvement can disappear behind production CDN, search, or integration behavior.

  • Confirm the representative product count and selected filters are unchanged.
  • Compare cold, repeat, authenticated, and anonymous paths when those contexts differ.
  • Check browser errors, failed API calls, pagination, prices, and inventory freshness.
  • Set a review date when catalog volume, integrations, or platform patch changes.

References and further reading

Bring your next engineering question.

Need help isolating a commerce performance problem?

Explore how GCG can help

Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.