中文
Shopify knowledge · Guide

Taming Layout Thrashing in Horizon: On-Demand Measurement, Batched Reads and Writes, Frame-Level Scheduling

A set of techniques from the Horizon 4.2.0 source for avoiding forced synchronous layout: measuring header heights before first paint only when needed, getting sizes from IntersectionObserver, batching reads and writes in the variant picker, a ResizeNotifier that skips the first callback, a Scheduler that waits for transitions, yielding to the main thread, and composing the fly-to-cart arc with CSS animations.
Historical material
Read it together with the applicable versions and sources stated in the article.

This article is based on Horizon 4.2.0, with source pinned to 5acd1b6 (verified 2026-10-07). Conclusions are limited to that commit; this is a static source analysis and nothing was tested in a store.

Layout thrashing is when JS alternates between "write styles, read sizes," with every read forcing the browser to recalculate layout immediately. Horizon has no unified framework for this, but a consistent approach shows up across several files. Here it is organized into seven points.

1. Measure the header before first paint, but only when someone needs it

Problem: a few CSS rules depend on the header height at first paint, such as a hero sized to the remaining viewport or a transparent header's negative margin. If measurement waits until header.js loads, the first frame uses the 60px fallback from sections/header.liquid and the content then visibly jumps.

Approach: layout/theme.liquid inlines a window.measureHeaderHeights function right after the header group, which measures during parsing and writes --header-height and --header-group-height on body.

But measuring mid-parse forces layout, so the function doesn't run by default; it's opt-in:

  • When the header is transparent, every template needs the values at first paint, so it's called globally right away;
  • Otherwise, blocks that genuinely depend on the variables at first paint render snippets/measure-header-heights.liquid themselves to trigger it. There are currently three: the hero section; the _product-details block (only when its height is fill); and the product media gallery (only with constrain_to_viewport).

The snippet also offers parameters to narrow the condition, at most one at a time:

{% render 'measure-header-heights', only_when_first_section: true %}
{% render 'measure-header-heights', only_on_desktop: true %}

only_when_first_section checks whether the section containing document.currentScript is the first child of #MainContent, so only a hero at the very top of the page triggers measurement; only_on_desktop only triggers at 750px and up.

Inside, a measured flag in a closure ensures it runs only once. After load, header.js's ResizeObservers take over updating the values.

The same script has a contrasting case: --top-row-height is only used by header underlays and submenus and isn't needed at first paint. So it's measured inside requestAnimationFrame and written on the header element rather than on body; the comment explains that this way the write only invalidates the header subtree.

Measurement has a cost; when to measure and where to scope the variable should follow from who needs it and when.

2. Get sizes from IntersectionObserver

getBoundingClientRect() forces synchronous layout. An IntersectionObserver callback comes with entry.boundingClientRect, computed by the browser during its own rendering pipeline, so reading it doesn't trigger extra layout.

The fly-to-cart animation (assets/fly-to-cart.js) needs the positions of its start (the product image) and end (the cart icon). Instead of calling getBoundingClientRect, it does:

const io = new IntersectionObserver((entries) => {
  // pull the boundingClientRect for source and destination out of entries
  if (sourceRect && destinationRect) this.#animate(sourceRect, destinationRect);
  io.disconnect();
});
io.observe(this.source);
io.observe(this.destination);

After observe, the browser delivers an initial callback with the current geometry. It disconnects as soon as it's done.

The overflow list (assets/overflow-list.js) uses the same technique: it observes the list's first and last items, and when they approach the viewport (rootMargin extended 640px vertically and 360px horizontally), uses the item height from the callback as the list's initial height for the first calculation. The comment puts it as "get their height from the IntersectionObserver for free (without reflows)."

3. Batch reads and writes

assets/variant-picker.js sets width variables for each option group's "selected pill" (--pill-width-current, --pill-width-previous) to drive a sliding animation. The code is split into a read-phase method (reading offsetWidth) and a write-phase method (writing CSS variables), and the batch call does all reads first, then all writes:

updateVariantPickerCss() {
  // Batch all reads first across all fieldsets to avoid layout thrashing
  const measurements = fieldsets.map((_, i) => this.#getFieldsetMeasurements(i)).filter(Boolean);
  // Batch all writes after all reads
  for (const m of measurements) this.#applyFieldsetMeasurements(m);
}

Done as "read once, write once" per group, N option groups could force up to N layouts; batched, it takes one.

4. ResizeNotifier: skip the first callback

export class ResizeNotifier extends ResizeObserver {
  #initialized = false;
  constructor(callback) {
    super((entries) => {
      if (this.#initialized) return callback(entries, this);
      this.#initialized = true;
    });
  }
}

A native ResizeObserver delivers an initial callback after observe(), even if the element's size hasn't changed. Many components have already done their initial layout in connectedCallback, so that first callback is wasted, and the callback usually reads sizes again. ResizeNotifier simply swallows the first callback, and its name says exactly what it means: notify only on real changes. disconnect() resets the flag, so after observing again the first callback is skipped again. The variant picker and the overflow list both use it.

Note that the flag belongs to the whole observer, not to each element: an element observed after the first batch was swallowed has its initial callback passed through to the component.

5. Frame-level scheduling: merge tasks, stay out of transitions

The Scheduler in assets/utilities.js:

schedule = async (task) => {
  this.#queue.add(task);
  if (!this.#scheduled) {
    this.#scheduled = true;
    if (viewTransition.current) await viewTransition.current; // wait for the in-page transition
    requestAnimationFrame(this.flush);
  }
};
flush = () => {
  for (const task of this.#queue) setTimeout(task, 0);
  this.#queue.clear();
  this.#scheduled = false;
};

Three details:

  • The queue is a Set, so the same function reference added several times in one scheduling cycle runs once;
  • It waits for the View Transition to finish first. viewTransition.current is the finished promise of the in-page transition (see the in-page section of cross-document View Transitions). Changing the DOM mid-transition disturbs the snapshots and competes with the animation for the main thread;
  • requestAnimationFrame plus setTimeout(0): align to the next frame first, then run the task after that frame renders. Layout reads in the task happen right after the browser finished layout, so a recalculation usually isn't needed.

slideshow.js uses it to batch reads and writes during initialization and to update each slide's aria-hidden by visibility. It's also exposed globally as Theme.utilities.scheduler.

6. Yield to the main thread

export const yieldToMainThread = () => {
  if ('yield' in scheduler) return scheduler.yield();
  return new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0)));
};

The scheduler here is the browser global, not the Scheduler from the previous section. scheduler.yield() is preferred: after yielding, the continuation resumes ahead of other queued tasks of the same priority. Without it, it falls back to rAF plus setTimeout.

The variant picker shows a typical use: after a variant switch, the URL is updated with history.replaceState, placed after yieldToMainThread(). The URL update doesn't change what the user sees, so there's no need to cram it into the same task as the click handling; splitting it out helps shorten that interaction's INP.

7. Fly-to-cart: compose the arc with CSS

The fly-to-cart parabola isn't computed frame by frame in JS. fly-to-cart.js only writes four CSS variables (start coordinates and travel distances, plus width and height with useSourceSize) and leaves the animation to CSS (snippets/fly-to-cart-styles.liquid):

fly-to-cart {
  translate: var(--start-x, 0) var(--start-y, 0);
  animation-name: travel-x, travel-y, travel-scale;
  animation-timing-function: var(--x-timing), var(--y-timing), var(--scale-timing);
  animation-composition: accumulate;
}
@keyframes travel-x { to { translate: var(--travel-x, 0) 0; } }
@keyframes travel-y { to { translate: 0 var(--travel-y, 0); } }

Horizontal and vertical movement are two independent animations, each with its own cubic-bezier, stacked onto the start position via animation-composition: accumulate. For the main product form, the X curve has a large negative control point (cubic-bezier(0.7, -5, 0.98, 0.5)), so it first pulls back the other way and then accelerates toward the target; the Y curve rises quickly at first and then eases. Combined, they form an arc that backs off first and then is flung toward the cart. The main form (--main), quick add (--quick) and sticky add-to-cart (--sticky) mainly differ in their easing curves and durations.

After writing the variables, JS first calls yieldToMainThread(), then waits for all of this.getAnimations() to finish before removing the element, with no timer guessing at the animation length.

Things worth questioning

  • There are two copies of calculateHeaderGroupHeight. The inline script and utilities.js each implement it, with a "keep them in sync" comment. As with the View Transitions detection logic, it's the price of inline scripts being unable to import modules.
  • Each overflow-list reflow still forces one layout. IntersectionObserver only saves it the initial height measurement. The real layout calculation moves the "More" button to the front with order: -1 and enables wrapping, then reads getBoundingClientRect for it and for every item; items whose top is greater than the "More" button's have been pushed to a second row. Writes come first and reads after, so a single reflow forces only one layout, but it has to be redone on every size or child change. The idea itself is clever: let the browser's flex wrapping answer "does it fit?"
  • overflow-list.js defines a schedule getter but never uses it. #handleChange writes rAF plus setTimeout directly. It looks like a leftover from a refactor.
  • The measurement snippet declares a top-level const. The inline script for only_when_first_section uses const section = .... A top-level const in a classic script goes into the global lexical scope shared by all scripts, so when a page has two hero sections, the second script throws on redeclaration and doesn't run. The second hero isn't the first section anyway, so it's functionally harmless, but it adds a console error and could collide with another script's global of the same name. This is a static inference from JS semantics, not reproduced in a browser.

Summary

TechniqueProblem it solves
On-demand pre-paint measurementFirst-paint content jumps, without making every page pay for measurement
Sizes from IntersectionObserverAvoids forced layout when reading positions once
Batched reads and writesAvoids N forced layouts during batch updates
ResizeNotifierDrops the pointless initial callback
SchedulerDedupes within a cycle, avoids transitions, runs after layout
yieldToMainThreadSplits long tasks to improve INP
CSS animation compositionComplex trajectories without per-frame JS

None of these techniques is new, but Horizon applies them in the right places, mostly with comments explaining why, and they're worth reading alongside the source.

Horizon's LICENSE.md forbids distributing themes derived from its code; this article only analyzes the source, so implement any borrowed ideas yourself.

Source baseline