Diagnose a product-listing slowdown before changing caches
Separate document timing, API work, and browser long tasks so a product-listing performance change addresses the delay that was actually measured.
In this guide
- Browser navigation
- Storefront render
- Product API
- Search and data dependencies
- Inventory and price context
- Measured outcome
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
First separate browser requests for the document, assets, and APIs. For a slow API, compare server processing time with the duration shown by the browser. For a fast API followed by a frozen screen, inspect browser work. Use the same category and buyer context for several repeats and report a range.
Place observe-plp.ts in a temporary browser diagnostics module and call observeDocumentNavigation after a full page navigation. It waits for a completed Navigation Timing entry, polling up to 200 times at 50 ms intervals. Browser scheduling can extend the elapsed wait. Use resource timing and application marks for filter or pagination requests that do not reload the document.
The result contains timing values only. It excludes routes, query strings, account IDs, and product IDs. A complete result is distinct from unavailable timing support, a still-incomplete load, or invalid measurements.
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));
Read requestToFirstByteMs as network plus server time before the response starts. downloadMs measures the document download, interactiveMs measures time until the document is interactive, and loadMs measures time through the load event. These intervals overlap, so do not add them. Pair a high first-byte value with server traces before deciding which dependency to change.
Isolate one stage at a time
Time the suspected dependency in a controlled environment or use the application's existing traces. Repeated price calls, oversized product responses, search queries, and browser scripts require different remedies. Change one suspected cause at a time so the result identifies which work mattered.
Keep product availability, account pricing, filters, and paging in the comparison. A faster response with stale stock or the wrong contract price fails the commerce requirement. Use the supplemental long-task observer when the browser remains busy after responses arrive.
- 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.
Count long browser tasks during a diagnostic interaction
Start observeLongTasks before a filter or pagination interaction, call stop after the interaction settles, then inspect read(). A result such as count 3 and totalMs 180 means three long-task entries were observed with 180 ms of combined duration. It does not identify which script caused them. supported false means the browser lacks this observation API, while supported true with count 0 means no long tasks were captured during the observation.
diagnostics/observe-long-tasks.ts typescript
export function observeLongTasks(): {
supported: boolean; read(): { count: number; totalMs: number }; stop(): void;
} {
let count = 0, totalMs = 0;
const read = () => ({ count, totalMs: Math.round(totalMs) });
if (typeof PerformanceObserver === "undefined" ||
!PerformanceObserver.supportedEntryTypes.includes("longtask"))
return { supported: false, read, stop: () => {} };
const collect = (entries: readonly PerformanceEntry[]) => {
for (const entry of entries) {
if (Number.isFinite(entry.duration) && entry.duration >= 0) {
count++;
totalMs += entry.duration;
}
}
};
const observer = new PerformanceObserver(list => collect(list.getEntries()));
observer.observe({ type: "longtask", buffered: false });
return {
supported: true,
read,
stop: () => { collect(observer.takeRecords()); observer.disconnect(); }
};
}
References and further reading
Bring your next engineering question.
Need help isolating a commerce performance problem?
Independent guidance from GCG. Znode is a trademark of its owner. Examples use fictional data and are not official platform documentation. Suggest a correction.