The storefront is a fashion catalog on Shopify collection pages in Next.js. Shoppers tap size and color chips while Algolia InstantSearch (the React search UI) refreshes the grid. A virtualizer keeps long lists from mounting every card at once. OpenTelemetry on the client logs an algolia.search span when a search completes. Filter taps started to feel sticky. INP, the metric for how long the main thread blocks after a tap, climbed even when the visible hits did not change.
Shoppers did not think "object identity." They thought the site was cheap. The virtualizer was already heavy. One extra effect per render is how a clothing PLP starts to feel like a spreadsheet.
The logger rode every client section
The first cut was the section error boundary. It statically imported the OpenTelemetry logger on every client section. A render failure is rare. The logger is not cheap. Every section paid for it on the default graph, including the InstantSearch block that already struggled with INP.
I moved reportSectionRenderFailed behind import(). Next.js then kept the happy path thin. The logger loads when a section actually fails. That is the right time to pay for it. Until then, the InstantSearch block should not import a failure reporter it almost never calls.
A default empty object reopened the span
The second cut was useAlgoliaObservability. It opened an algolia.search span when InstantSearch returned results. The hook listed additionalAttributes and results as effect dependencies. It also defaulted additionalAttributes to {}. A default empty object is a new object every call. InstantSearch handed us a new results object on every render, even when queryID had not changed.
The effect retriggered. The span opened again. Filters and the virtualizer already had enough to do. I added a span open on top of every render. Object identity and value identity diverge here. INP notices the difference.
I almost left results in the dependency array so the effect would see the latest hits. InstantSearch still hands you a new object when queryID is unchanged, so that subscribe is the bug. Defaulting additionalAttributes to {} looks tidy in the signature and allocates on every call. Memoizing at the call site is cheaper than pretending {} is a constant.
Refs hold the latest bag; the effect watches queryID
I pulled results out of the dependency array. During render the hook writes the latest additionalAttributes and results into refs. The effect only reacts when queryID (plus index, locale, and component name) actually changes. If the search id is missing or unchanged, the span stays shut. Memoize the attrs object at the call site or you allocate a new {} anyway.
In the demo below, click Same query, new objects once. The unstable deps counter on the left should climb while the stable queryID counter on the right stays flat, because the query id did not change. Then click New queryID and confirm both counters bump once, because a real new search should log.
Effect fires vs object identity
Unstable deps
Effect depends on results and additionalAttributes.
0
effect fires
Stable queryID deps
Latest values via useEffectEvent. Effect depends on queryID.
0
effect fires
Same query, new objects should only bump the left counter. New queryID bumps both.
export function useAlgoliaObservability({ indexName, results, locale, componentName, additionalAttributes }: Options) {
const lastQueryIDRef = useRef<string | undefined>(undefined);
const additionalAttributesRef = useRef(additionalAttributes);
additionalAttributesRef.current = additionalAttributes;
const resultsRef = useRef(results);
resultsRef.current = results;
useEffect(() => {
const current = resultsRef.current;
if (!current?.queryID || current.queryID === lastQueryIDRef.current) return;
lastQueryIDRef.current = current.queryID;
// open the algolia.search span from current + additionalAttributesRef.current
}, [results?.queryID, indexName, locale, componentName]);
}One limitation: when queryID is missing, the effect returns and you get no span. That is the point of the guard. Do not fall back to depending on results just to log a search that Algolia has not identified yet. You will log every render instead.
A clothing PLP can survive a heavy list. It cannot survive a heavy list plus a span that thinks every filter tap is a new search.
Happy coding! Sander