Every value in an IntersectionObserver threshold array is a potential callback per target per scroll, so an array like [0, 0.01, …, 1] on fifty targets can deliver thousands of entries during one scroll — use only the thresholds your code actually compares against, and move continuous, ratio-driven visuals to CSS scroll-driven animations.
Problem / Scenario Context
A product listing uses threshold: Array.from({ length: 101 }, (_, i) => i / 100) on every card so it can fade each card in proportionally to how much of it is visible, and separately log a "viewed" event at 50%. On a page with 60 cards, a single fling through the grid produces over 3,000 entries, the callback runs in dozens of tasks per second, and scrolling on mid-range Android phones drops frames — the exact scroll-listener cost that switching to observers was supposed to remove.
Thresholds are the observer's resolution knob, and resolution costs. The Callback Throttling & Debouncing topic discusses reducing callbacks in general; this page is about thresholds specifically, complementing how IntersectionObserver threshold works in practice.
Mechanics Explanation
During each rendering update, the browser computes every observed target's intersection ratio and finds the index of the highest threshold at or below it. If that index differs from the previous one, it queues an entry. So:
- Entries per target per scroll ≈ number of thresholds crossed. A target that scrolls from fully hidden to fully visible crosses every threshold in the array once — but because the check happens once per frame, fast scrolls can skip several thresholds in one step and produce fewer entries than thresholds.
- Entries are delivered in tasks after paint. More thresholds mean more frequent, larger batches, each a main-thread task competing with input.
- Each entry is an allocated object with three
DOMRectReadOnlys. Thousands per scroll is measurable garbage.
The computation itself is cheap — the browser computes ratios for every observed target every frame regardless. The cost of thresholds is almost entirely in delivery: entry allocation, task scheduling and your callback.
Comparison Table: What Each Use Actually Needs
| Use | Thresholds needed | Why |
|---|---|---|
| Lazy loading | [0] |
only "starts touching" matters |
| One-shot reveal | [0] or [0.15] |
one crossing triggers it |
| Impression at 50% | [0, 0.5] |
enter/leave plus the rule |
| Autoplay with hysteresis | [0, 0.4, 0.6] |
enter and leave bands |
| Scroll spy | [0] with a narrow rootMargin band |
the band does the work |
| Opacity follows visibility | none — CSS view() timeline |
continuous and visual |
| Progress through a section | none — CSS timeline, or per-section steps | continuous |
Minimal Reproducible Example
const dense = Array.from({ length: 101 }, (_, i) => i / 100);
let entries = 0;
const io = new IntersectionObserver((es) => {
entries += es.length;
for (const e of es) {
(e.target as HTMLElement).style.opacity = String(e.intersectionRatio); // continuous visual
if (e.intersectionRatio >= 0.5) logView(e.target); // discrete rule
}
}, { threshold: dense });
document.querySelectorAll('.card').forEach((c) => io.observe(c));
setInterval(() => { console.log('entries/s', entries); entries = 0; }, 1000);
declare function logView(el: Element): void;
Fling through the grid: the log shows thousands of entries per second and the trace shows back-to-back observer tasks.
Production-Safe Solution
Split the two jobs: the continuous visual moves to CSS; the discrete rule keeps the observer with the thresholds it needs.
@supports (animation-timeline: view()) {
.card {
animation: card-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%; /* fade while entering */
}
@keyframes card-in { from { opacity: 0.2; } to { opacity: 1; } }
}
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}
const viewed = new Set<Element>();
const io = new IntersectionObserver((entries, obs) => {
for (const e of entries) {
if (e.intersectionRatio < 0.5 || viewed.has(e.target)) continue;
viewed.add(e.target);
logView(e.target);
obs.unobserve(e.target); // one-shot: no further entries at all
}
}, { threshold: [0.5] });
document.querySelectorAll('.card').forEach((c) => io.observe(c));
The observer now delivers at most one meaningful entry per card, plus the initial entry, and unobserves after the view is logged — so the steady-state cost during scrolling is zero. Browsers without scroll timelines simply show cards at full opacity, which is a perfectly good fallback for a decorative effect.
If a continuous effect must be driven from script (a canvas illustration whose frame depends on visibility), use a few thresholds plus interpolation in requestAnimationFrame between entries, rather than a threshold per percent.
Auditing Existing Threshold Arrays
In an existing codebase, the question for every threshold array is: which of these values does the callback actually compare against? A quick audit:
- Search for
threshold:and list each observer's array. - Read the callback and list every ratio it compares (
>= 0.5,=== 1,> 0). - Keep only those values (plus
0if the callback needs to know when the target leaves). - If the callback uses
intersectionRatiocontinuously (assigns it to a style, feeds it to an animation), that is a candidate for a CSS timeline instead. - Measure entries per second before and after on a representative page.
Arrays generated with Array.from({ length: 101 }) are almost always one of two things: a continuous visual that belongs in CSS, or a lookup of one or two ratios that needed only those values.
Verification Steps
- Count entries per second during a fling before and after the change.
- Record a trace and confirm observer tasks during scroll are rare and short.
- Check the discrete rule still fires exactly at the intended ratio.
- Verify the CSS effect in a supporting browser and the fallback in one without support.
- Emulate reduced motion and confirm the fade is disabled.
Common Mistakes to Avoid
- Generating 100 thresholds "for smoothness". It turns the observer into a scroll listener.
- Driving opacity or transforms from
intersectionRatio. Continuous visuals belong in CSS timelines. - Leaving one-shot targets observed. Unobserve after the rule fires.
- Comparing against ratios that are not thresholds. The callback may never see the value.
FAQ
Do more thresholds make intersection computation slower?
Barely. The browser computes each target's ratio every frame either way; thresholds only decide whether the change is reported. The cost is in delivering entries and running your callback.
Why do I get fewer entries than thresholds on a fast scroll?
Intersections are checked once per rendering update. A fast scroll can jump over several thresholds between frames, and only the new threshold index is reported.
Is a threshold of 1 reliable?
Not always: sub-pixel rounding can keep the ratio at 0.999, and targets larger than the root can never reach 1. Use 0.99, or compute visibility relative to the maximum possible ratio.
What is the cheapest configuration?
threshold: [0] with unobserve after the first useful entry. That delivers at most two entries per target over its lifetime.
Can I throttle entry delivery instead of reducing thresholds?
Only IntersectionObserver v2's delay option rate-limits delivery, and it requires trackVisibility. For ordinary observers, reducing thresholds is the only way to reduce entries.
How do I keep a smooth effect in browsers without scroll timelines?
Accept a static fallback for decorative effects, or use a few thresholds and interpolate between entries in requestAnimationFrame. A dense threshold array is the most expensive way to get smoothness.
Related
- Optimizing IntersectionObserver for 1000 List Items — large target counts
- CSS Scroll-Driven Animations vs IntersectionObserver — where continuous effects belong
- isIntersecting vs intersectionRatio: Which to Check — reading entries correctly
↑ Back to Callback Throttling & Debouncing for Observer APIs