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.

Entries Delivered per Fling by Threshold CountA line chart of entries delivered during one fling through a grid of sixty cards as the number of thresholds grows. With one threshold about one hundred and twenty entries are delivered. With five about four hundred. With twenty-one about fourteen hundred. With one hundred and one about three thousand two hundred, limited by frame rate because fast scrolls skip thresholds.0875175026253500020406080100thresholds in the arrayentries per fling (60 cards)entries delivered

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

TypeScript
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.

CSS
@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; }
}
TypeScript
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.

Entries per Fling, Before and AfterA bar chart of entries delivered during one fling through a sixty-card grid. With one hundred and one thresholds for both fade and logging, about three thousand two hundred entries were delivered. With the fade moved to a CSS view timeline and a single fifty percent threshold with unobserve, about one hundred and twenty were delivered on the first pass and none afterwards.One fling through 60 cards101 thresholds, fade + log~3,200 entriesCSS fade + threshold 0.5 + unobserve~120, then 0

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:

  1. Search for threshold: and list each observer's array.
  2. Read the callback and list every ratio it compares (>= 0.5, === 1, > 0).
  3. Keep only those values (plus 0 if the callback needs to know when the target leaves).
  4. If the callback uses intersectionRatio continuously (assigns it to a style, feeds it to an animation), that is a candidate for a CSS timeline instead.
  5. 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.

Auditing a Threshold ArrayFive steps. Find every threshold option in the codebase. Read each callback and list the ratios it compares against. Reduce the array to those values plus zero if leaving matters. Move any continuous use of intersectionRatio to a CSS scroll-driven animation. Measure entries per second before and after.1FindEvery threshold option in the codebase.2Read the callbackWhich ratios does it actually compare?3ReduceKeep those values, plus 0 if leaving matters.4Move continuous usesintersectionRatio → style? Use a view() timeline.5MeasureEntries per second before and after.

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.


↑ Back to Callback Throttling & Debouncing for Observer APIs