I kept reopening an analytics span on every filter tap because a fresh empty object retriggered the effect. Stabilize refs and key off the query id.
Fashion catalog on Next.js. Algolia InstantSearch ran the hits. Shopify owned the cart. OpenTelemetry logged the search span. If a filter chip felt sticky, the shopper did not think "object identity." They thought the site was cheap.
A tap on size or color that hangs. INP that looks like a frozen grid. The virtualizer was already heavy. One extra effect per render is how a clothing PLP starts to feel like a spreadsheet.
I was on Shopify collection product listing pages in a Next.js catalog. Algolia InstantSearch drove the hits. A virtualizer kept the long list from mounting every card. Quick filters sat above the grid. On paper that stack is fine. In the field, every chip tap felt sticky. INP was paying for work that should have been a no-op.
I am not rewriting the virtualizer in this post. The list was already heavy. Two extra cuts sat on top of it, and they were cheaper to remove than a virtualizer rewrite.
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 Algolia 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.
The second cut was useAlgoliaObservability. It opened an algolia.search span when Algolia InstantSearch returned results. The hook took 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. We added a span open on top of every render. That is how object identity shows up as INP.
The fix is boring. Keep additionalAttributes and results in refs. Update the refs during render. Depend only on results?.queryID, indexName, locale, and componentName. When queryID is missing or unchanged, return. Read the latest objects from the refs inside the effect. At the call site, memoize { 'search.has_filters': canClearRefinements } so we do not allocate a new attrs object for no reason.
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]);
}Object identity is not value identity. INP notices the difference. Click "same query, new objects" in the demo. The unstable side fires the effect. The stable side does not. Click "new queryID" and both fire. That is the lesson I wanted in the hook.
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.
Happy coding!
Sander