Taming Layout Thrashing in Horizon: On-Demand Measurement, Batched Reads and Writes, Frame-Level Scheduling
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
herosection; the_product-detailsblock (only when its height isfill); and the product media gallery (only withconstrain_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.currentis thefinishedpromise 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; requestAnimationFrameplussetTimeout(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 andutilities.jseach 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: -1and enables wrapping, then readsgetBoundingClientRectfor it and for every item; items whosetopis 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.jsdefines aschedulegetter but never uses it.#handleChangewrites rAF plus setTimeout directly. It looks like a leftover from a refactor.- The measurement snippet declares a top-level
const. The inline script foronly_when_first_sectionusesconst section = .... A top-levelconstin 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
| Technique | Problem it solves |
|---|---|
| On-demand pre-paint measurement | First-paint content jumps, without making every page pay for measurement |
| Sizes from IntersectionObserver | Avoids forced layout when reading positions once |
| Batched reads and writes | Avoids N forced layouts during batch updates |
| ResizeNotifier | Drops the pointless initial callback |
| Scheduler | Dedupes within a cycle, avoids transitions, runs after layout |
| yieldToMainThread | Splits long tasks to improve INP |
| CSS animation composition | Complex 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.