中文
Shopify knowledge · Guide

Horizon's Keyed Lazy Hydration: Updating Only Nodes Marked with data-hydration-key

A Horizon 4.2.0 source walkthrough of morph's hydration mode: matching only by data-hydration-key, the no-insert, no-remove contract, three use cases in product recommendations, the cart drawer and the header, the header branch that leaves featured products out of the first render, and the limits of this two-pass rendering.
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.

Section refresh and morph covered how Horizon uses the Section Rendering API plus morph for partial refreshes. This article covers a pattern it adds on top of morph: hydration mode, which the source comments call "keyed lazy hydration." Involved:

"Hydration" here isn't the same thing as React SSR hydration. React attaches client state to server HTML; Horizon asks the server for HTML again after the page loads and updates only a few agreed-upon pieces of it.

The problem

Some content isn't worth rendering in the first paint, or can't be obtained there at all:

  • Product recommendations: Liquid's recommendations object only has data when rendered through the dedicated recommendations request (recommendations.performed is true); in a normal page render it's empty;
  • Heavy off-screen content: for example featured products pulled per collection into the mega menu, invisible unless the user opens the menu, yet slowing down server rendering on every page;
  • Content inside stateful containers: the <dialog> around the cart drawer carries runtime state such as open state and the focus trap, and a refresh should only swap its contents.

Morphing the whole section in full mode works too, but it touches every node in the section: initialized components, the user's focus and open states all rely on morph's various protections to survive. Hydration mode takes a different approach: touch only marked nodes and ignore everything else.

The contract

The source comment states the rules plainly. Summarized:

  1. Only elements with data-hydration-key="<non-empty>" take part in the update;
  2. Old and new DOM are matched only by the key value, with no fallback matching, to avoid updating the wrong node;
  3. Only existing targets are updated: a key present in the new HTML but missing from the old DOM is ignored, not inserted; one present in the old DOM but missing from the new HTML isn't removed either;
  4. Once matched, a normal morph runs inside the target with childrenOnly: false forced, so the target element's own attributes are updated too.
function morphHydrationByKey(oldRoot, newRoot, options) {
  // collect every keyed element in oldRoot (including itself), grouped by key
  // for every keyed element in newRoot:
  //   take the first old element with the same key (shift); skip if none
  //   morph(oldTarget, newTarget, { ...options, hydrationMode: false, childrenOnly: false })
}

Taking matches with shift() means that when a key appears several times, elements are paired one-to-one in document order.

Rule 3 is the core of the design: page structure is decided by the first render; hydration only fills in content. Component instances, listeners and focus attached outside are unaffected, and layout outside the target containers doesn't change.

Three use cases

1. Product recommendations: lazy load plus hydration

The root element of sections/product-recommendations.liquid carries data-hydration-key="product-recommendations-{{ section.id }}"; the block version, blocks/product-recommendations.liquid, uses block.id. assets/product-recommendations.js fetches the recommendations endpoint itself when the component is about to enter the viewport (an IntersectionObserver with 400px of bottom margin), then calls:

this.dataset.recommendationsPerformed = 'true';
morphSection(sectionId, result.data, { mode: 'hydration', injectStylesheet: true });

injectStylesheet: true looks for <style data-section-stylesheet> in the response and, if found, replaces or inserts it in the section wrapper. Hydration mode only processes keyed nodes, so the style tag needs this separate channel. A repository-wide search for data-section-stylesheet only hits section-renderer.js; no Liquid file in the theme outputs it, and the source doesn't say where it comes from.

2. Cart drawer: swap only the inner part

In snippets/cart-drawer.liquid, the drawer's inner container carries data-hydration-key="cart-drawer-inner". When assets/component-cart-items.js refreshes the cart, it picks the mode by whether it's in the drawer:

mode: this.isDrawer ? 'hydration' : 'full'

The cart page uses full; the drawer uses hydration. The outer <dialog> doesn't take part in the morph, so its open state and focus trap aren't interrupted.

At the end of sections/header.liquid, when request.design_mode is false, it outputs:

import { hydrate } from '@theme/section-hydration';
const url = new URL(window.location.href);
url.searchParams.delete('page');
hydrate('{{ section.id }}', url);

Once the DOM is ready, hydrate calls renderSection(id, { cache: false, url, mode: 'hydration' }) inside requestIdleCallback, and when it finishes it sets data-hydrated="true" on the section so later calls skip it. The targets live in blocks/_header-menu.liquid, three of them: header-menu (desktop menu), header-drawer-mobile (mobile drawer) and header-menu-mobile-navigation-bar (mobile navigation bar).

The first render really does leave something out on purpose. snippets/mega-menu-list.liquid and snippets/header-drawer.liquid both treat section.index == blank as "this is a section render," and only then set eager_loading to true and output heavy content such as featured products. The comment says the purpose is to keep the initial render lightweight and minimize the impact on TTFB, with the second render filling in the rest.

The script explicitly deletes the page parameter, and the source doesn't say why. hydrate passes cache: false, so it has nothing to do with the cache key. One plausible explanation: mega-menu-list.liquid outputs featured products with paginate, which reads page from the URL, so removing it keeps the menu on the first page even on a paginated collection page. That is an inference, not tested.

Design points worth learning from

1. "No insert, no remove" makes hydration predictable. The risk of a normal morph is that you don't know which nodes it will touch. Hydration mode confines the impact to marked subtrees; when reviewing a template, data-hydration-key alone tells you which regions will be replaced.

2. Match only by key, no fallback. Many diff algorithms fall back to positional matching when keys don't match. Horizon deliberately refuses: better not to update than to update the wrong thing.

3. The caller chooses the mode, not the template. The same cart template uses full on the page and hydration in the drawer. The template only marks which regions may be hydrated; the choice is left to the context.

4. One merge entry point. All three use cases end up in morphSection's hydration mode. The header and the cart fetch through renderSection and share its per-section cancellation; product recommendations do their own fetch, with a per-instance cache and abort, and only hand the merge to morphSection.

Limitations

  • Keys must be unique, stable values. Including section.id or block.id is the usual practice. If a loop accidentally outputs duplicate keys, shift() pairs them in order, and the result depends on whether both sides have the same DOM order.
  • The first render must output the target container. Without the container, hydration can't find a target and that content is simply discarded, with no warning.
  • One extra request. The header is on every page, so every page view makes one extra idle-time section request in exchange for leaving featured products out of the first render. Whether it's worth it depends on how much server rendering the first paint saves.
  • The duplicate guard is written after the request completes. data-hydrated is only set after await renderSection(), so two hydrate calls fired before completion both pass the check; per-section cancellation makes the earlier one yield to the later one.

Summary

Keyed lazy hydration essentially splits one server render into two passes: the first paint renders the skeleton and essential content, and the expensive or context-dependent parts are filled in when idle or visible. Its value isn't in the algorithm but in the contract: one attribute declares "this may be replaced," and everything else is left alone.

In Liquid themes, looping over collections and products is often the bulk of server rendering time, and Horizon's own header comment names TTFB as the motivation. The pattern is worth borrowing.

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