Shopify's Liquid partials preview lets themes name server-rendered regions and swap them without reloading the document. We forked Horizon 4.1.3 on a preview store to explore that with @shopify/partial-rendering: header, footer, and cart chrome stay in the DOM; only the main content region updates when a buyer navigates. The fork is open source on GitHub.
The result feels closer to an app than a traditional storefront: View Transitions between collection and product pages, prefetch on hover and scroll so clicks often reuse warmed HTML, and no full reload on the happy path while server-rendered HTML stays the source of truth.
It also surfaces a problem full page loads hide: analytics and tracking were designed around document lifecycle, not region lifecycle. Standard storefront events update after a swap. Web pixel customer events often do not. This article covers what we found building on the pattern in Shopify Liquid Partials, the workaround we prototyped, and why we think soft navigation should stay a lab experiment until Shopify owns measurement on partial routes.
This work depends on Liquid July '26, StorefrontRenderer, and the agentic editor preview. It is experimental. Do not treat the pixel bridge as a production default.
Introduction
Merchants evaluating partial-based soft navigation usually ask about speed first: less header flicker, fewer repeated script initialisations, smoother transitions between collection and product pages. We benchmarked that trade-off on the liquid-july preview store and found repeat navigation roughly 24× faster than full reloads, with a modest first-load cost from the extra runtime. That visible win aligns with the persistent shell architecture we describe in high-performance theme work.
The harder question arrives when marketing opens Meta Events Manager or GA4 and route-level counts stop matching server-side reality. Soft navigation updates the URL and #MainContent correctly. shopify:page:view fires in the standard-events inspector. App pixels in WPM sandboxes often never see page_viewed for those in-theme routes.
That gap is separable from the navigation UX itself. Partials can feel fast and remain server-rendered while attribution silently drifts. This article maps the architecture, explains why two event buses exist, documents what we tried before reaching for platform internals, and states what we want from Shopify instead of permanent theme-side interception.
You can browse the behaviour on our liquid-july preview storefront (password: ocontis). Clone or inspect the theme in the horizon-partials repository. We also posted a shorter experiment summary on the Shopify Developer Community forums for feedback from other theme builders.
If your stack already struggles with script churn in global chrome, read common theme mistakes that kill conversion before adding interceptors. Partials change where HTML lands; they do not reduce the cost of twelve competing analytics snippets in the header.
Architecture overview
At a high level, the theme splits the page into a persistent shell and a swappable content region. Platform scripts from content_for_header initialise once per document. After that, soft navigation replaces only the content partial and runs a post-apply lifecycle. Analytics split across two buses; only one of them updates automatically on a region swap.
Mermaid diagram
The dashed path is the part we dislike: intercepting Web Pixels Manager before Shopify freezes the global, then manually publishing customer events after each navigation.
How soft navigation works
A custom element, <partial-navigation>, listens for clicks on in-scope links (data-partial-nav or anything inside a scoped header/footer container). It prevents the default navigation, pushes the URL with the History API, and fetches fresh server HTML for the page-content partial in parallel with any missing template stylesheets.
Before the click, the theme tries to warm that fetch. Header nav links prefetch on connect. Visible product cards prefetch when they enter the viewport. Hover (debounced), pointerdown, and keyboard focus do the same. Results sit in a short-lived cache (24 entries, 30 seconds) so the navigation path often reuses prefetched HTML and parsed head metadata.
partials.apply() swaps the region while preserving focus, form state, and scroll where possible. A View Transition wrapper adds polish when the merchant enables it. Transitions animate [data-partial-page-root] inside #MainContent, not the full <main> element, so the persistent header shell does not participate in the snapshot and layout stays stable. On popstate, the same flow runs for back and forward.
Timing matters for transitions. The View Transition snapshot captures DOM state during partials.apply() only. Head sync, scroll reset, script re-exec, and analytics replay run in an afterTransition callback so they do not change the DOM mid-animation. That keeps morph and slide transitions smooth while still doing full lifecycle work after the swap.
After every apply (post-transition), the theme syncs theme-owned head metadata (title, meta tags marked data-partial-head-managed), re-executes section scripts, fires shopify:section:load, updates nav aria-current in the persistent header, closes the mobile menu drawer when it is still open, aligns cart badge counts, and dispatches standard page view events. Because the header is not re-fetched on navigation, shell UX must be updated in JavaScript: active link state and drawer visibility are two examples. Cart mutations are separate: Shopify.actions emits shopify:cart:lines-update, and the theme refreshes only the nested header-cart partial.
Rapid clicks abort stale fetches via AbortController. Duplicate navigations to the same URL share one in-flight promise. If a fetch fails, the theme falls back to a full page load rather than leaving the buyer on stale content.
None of that is exotic. It follows the partials model Shopify is previewing and aligns with Declarative Partial Updates thinking on the web platform: named regions, server HTML, client-side replacement without giving up Liquid as the source of truth.
Persistent shell gotchas
Keeping the header in the DOM is the point of partial navigation, but it creates UX work a full reload handles for free. Two examples from our Horizon fork:
Active nav state. Liquid renders
aria-current="page"on first paint. After a soft nav, the theme walks header links and updatesaria-currentfromwindow.locationwithout re-fetching header HTML (syncHeaderNavActiveState()inpartial-page-lifecycle.js).Mobile menu drawer. Tapping a nav link inside an open drawer navigates correctly, but the drawer element never unmounts. Without an explicit close after
partials.apply(), buyers arrive on the new page with the menu still covering the viewport. The lifecycle callsheader-drawer.close()when the drawer is open, using the same animation path as the close button.
Header height CSS variables are measured once at load and on resize, not on every partial apply. Re-measuring the header after each swap caused hero layout jumps when page transitions were enabled.
Performance: baseline vs partials
We measured buyer-perceived performance on 1 August 2026 against the horizon-partials repo on liquid-july.myshopify.com, using shopify theme dev locally. The focus is how long until content appears and how responsive navigation feels, not asset weight or bundle size alone.
Two commits were compared:
| Label | Commit | Description |
|---|---|---|
| Baseline | cdf8796 | Horizon 4.1.3 with standard full-page navigation |
| Partials | c058b73 | Same theme + Liquid partials, <partial-navigation>, prefetch, view transitions |
Navigation timing measures click until #MainContent shows the new product page. Baseline uses a full document navigation. Partials swap only the page-content region while header, footer, and cart chrome stay mounted.
Summary. Headline numbers from five runs per scenario (median reported):
| Metric | Baseline (full reload) | Partials (soft nav) | Change |
|---|---|---|---|
| Route change: time to new content | 1,187 ms | 49 ms | ~24× faster (−96%) |
| Route change: DOM ready (baseline only) | 1,144 ms | n/a | n/a |
| First visit: Largest Contentful Paint | 7,577 ms | 8,891 ms | +1,315 ms |
| First visit: First Contentful Paint | 6,227 ms | 6,716 ms | +489 ms |
| First visit: Total Blocking Time | 29 ms | 114 ms | +85 ms |
| First visit: Cumulative Layout Shift | 0.00006 | 0.00006 | No change |
| First visit: Lighthouse performance score | 61 | 58 | −3 |
Partial updates trade a small first-load cost for dramatically faster in-store browsing. After the shell mounts, collection → product feels near-instant instead of waiting ~1.2 seconds per full reload.
In-app navigation is the primary shopper win: what buyers feel when clicking a product card or header link after the store is already open.
#MainContent| Run | Baseline | Partials |
|---|---|---|
| 1 | 1,249 ms | 55 ms |
| 2 | 1,187 ms | 49 ms |
| 3 | 1,050 ms | 46 ms |
| 4 | 1,120 ms | 50 ms |
| 5 | 1,214 ms | 45 ms |
| Median | 1,187 ms | 49 ms |
Median ~1.1 seconds saved per navigation. Header, footer, cart drawer, and open UI state persist: no full chrome re-render or white flash.
On full reload, the browser re-fetches and re-parses the entire document. Median DOMContentLoaded on the product page was ~1,144 ms, aligned with the navigation duration above. Every link click repeats that work even though most of the page (header, footer, scripts) is identical.
With partials, only page-content is fetched and swapped. Measured end-to-end from click to visible product markup in #MainContent: ~49 ms median. Prefetch on hover and viewport entry can make subsequent clicks feel even snappier; these numbers are cold-click measurements without relying on prefetch cache.
First visit (cold load) is the trade-off side. First impression when landing on /collections/all with an empty cache, measured with Lighthouse Performance (mobile throttling simulation):
| Core Web Vital / metric | Baseline | Partials | Notes |
|---|---|---|---|
| Performance score | 61 | 58 | Slight regression from partial-navigation modules |
| First Contentful Paint (FCP) | 6.2 s | 6.7 s | +0.5 s |
| Largest Contentful Paint (LCP) | 7.6 s | 8.9 s | +1.3 s |
| Total Blocking Time (TBT) | 29 ms | 114 ms | +85 ms main-thread blocking |
| Cumulative Layout Shift (CLS) | 0.00006 | 0.00006 | Identical: no layout instability added |
| Speed Index | 6.2 s | 6.7 s | Tracks FCP closely on this page |
The partials commit adds partial-navigation.js, lifecycle sync, view transitions, and pixel bridge code. That extra JavaScript runs once on first load and shows up as slightly higher TBT and LCP. CLS is unchanged, so the shell layout does not shift when partials are enabled.
These cold-load numbers are dominated by local dev-server latency and theme asset volume on a development store. Treat absolute seconds as directional, not production SLA. The relative comparison on the same machine, same store, minutes apart is the meaningful signal: first load is modestly slower; repeat navigation is orders of magnitude faster.
Methodology. We measured route change latency (link activation until new markup appears in the main content area) and Core Web Vitals on first paint (FCP, LCP, TBT, CLS via Lighthouse). We intentionally excluded transfer sizes and bundle analysis, Theme Editor / design mode (partials are disabled there by design), production CDN edge caching, and prefetch hit rate (the benchmark uses direct clicks; prefetch is a separate optimisation layer).
| Detection flag | Baseline | Partials |
|---|---|---|
<partial-navigation> present | No | Yes |
| Partial-nav links | 0 | 40 |
| Navigation mode | Full document | Partial region swap |
For browse-heavy DTC stores where shoppers open several products per session, the in-app navigation win dominates. If SEO landing pages are the primary entry and first-visit LCP is the constraint, defer or lazy-load partial modules on templates that never soft-navigate. Re-run on a production theme preview over HTTPS with CDN caching before treating Core Web Vitals as launch criteria.
The two event buses
We initially assumed dispatching shopify:page:view after the content swap would be enough for downstream tracking. Standard storefront events are documented, inspectable with shopify theme dev --standard-events-inspector, and exactly what themes should emit when the active page changes.
That covers apps and agents on the standard bus. It does not cover web pixels.
| Layer | Examples | Listeners |
|---|---|---|
| Standard storefront events | shopify:page:view, shopify:collection:view | Apps, agents, standard-events inspector |
| Web pixel customer events | page_viewed, collection_viewed, product_viewed | All web pixels: custom and app (Meta, Google, Klaviyo, session replay, etc.) |
These are not the same pipe. shopify:page:view after a swap shows in the inspector overlay. It does not automatically reach pixel sandboxes.
On a full page load, content_for_header injects inline Trekkie bootstrap and loads Web Pixels Manager (WPM). That sets ShopifyAnalytics.meta, calls ShopifyAnalytics.lib.page() and lib.track(), and initialises WPM once. Pixel iframes subscribe inside WPM and receive page_viewed on first paint. The theme also dispatches shopify:page:view from page-view-event.js.
On partial navigation, the URL and #MainContent update, but WPM does not re-init. Trekkie's shim queue may still accept late page and track calls, yet that path feeds Monorail parity telemetry, not the WPM publish() call that delivers to pixels.
We verified with headless browser probes: after soft nav, ShopifyAnalytics.meta.page.pageType updated correctly and Trekkie shim pushes fired, but pixel sandbox page_viewed did not appear the way it does on full load. If you debug pixels, remember sandbox logs show in the pixel iframe console, not the top-level DevTools tab.
What we tried first
Trekkie replay from fetched HTML. After each navigation we fetch the destination URL, parse inline scripts containing var meta = … and ShopifyAnalytics.lib.page(…) / lib.track(…), merge meta, and replay the calls Shopify would run on a full load. This updates analytics state and emits Monorail events. It does not publish page_viewed to pixel sandboxes.
Shopify.analytics.publish('page_viewed', …). On the storefront, Shopify.analytics.publish is wired to publishCustomEvent only. Standard event names return false. Public docs do not describe a theme API for standard pixel events on soft nav.
Re-running content_for_header. Platform head scripts assume a once-per-document lifecycle. Re-executing them risks double pixel registration and fighting non-configurable globals. We scoped head sync to theme-owned nodes instead.
Reverse-engineering WPM
The useful detail came from reading the minified WPM bundle and the inline wpmLoader in content_for_header.
On init, WPM returns an API with publish (standard customer events) and publishCustomEvent. The loader then assigns only the latter to the public surface:
var api = window.webPixelsManager.init(config);
Shopify.analytics.publish = api.publishCustomEvent; // api.publish discarded
The standard-event publish that pixels need is thrown away. window.webPixelsManager is defined with configurable: false, so you cannot replace it after the fact.
Trekkie maps page_rendered to page_viewed for Shopify.evids, but post-init lib.page() goes through a shim handler that produces Monorail payloads, not O.publish('page_viewed', …). There is no documented hook for themes to emit customer events after a region swap.
Our workaround (and why we dislike it)
We needed WPM's publish before the loader discards it. The only reliable hook we found runs before content_for_header:
snippets/partial-wpm-init-capture.liquidwrapsObject.defineProperty. When Shopify defineswindow.webPixelsManager, we wrapinitand stashapi.publishaswindow.__shopifyWpmPublish.assets/partial-pixel-events.jsruns after each partial apply: parse destination bootstrap from fetched HTML, replay Trekkie for parity, readview-event-payloadfromcollection-component/product-componentin the swapped markup, call__shopifyWpmPublish('page_viewed', …)and template events where applicable, and publishhorizon_partials:navigationas a custom event for debugging.
In testing, standard pixel events fire after soft nav. Enable localStorage.setItem('horizon-partials-pixel-debug', 'true') to see confirm logs in the main console.
We would not ship this approach on a merchant store without explicit measurement trade-offs. It intercepts a platform global, parses undocumented inline script shapes, and assumes WPM payload contracts stay stable. Shopify can change loader order, property descriptors, or Trekkie bootstrap overnight. One CDN deploy could silence every app pixel on soft nav for themes using this bridge, with no deprecation notice. We document it so teams understand the gap, not because we recommend copying it blindly.
Stores where app sprawl already complicates the header have enough fragile integration points without adding another one.
What works without the bridge
Standard storefront events are under theme control. Collection and product view events flow from collection-component and product-component built with @shopify/events. Cart follows documented patterns. Lifecycle stays server-rendered. The gap is specific: customer events for web pixels on route changes that are not full document loads.
What we want from Shopify
Partials are a developer preview. Soft navigation fits the platform direction. Tracking should not be an afterthought.
First-party support could take any of these shapes:
Bridge standard storefront events to pixel sandboxes when partial navigation occurs:
shopify:page:view→page_viewed, template view events →product_viewed/collection_viewed, with correct payloads and consent checks.Document a supported API such as
Shopify.analytics.publishStandardEvent(name, data)callable afterpartials.apply()without re-initing WPM.Emit pixel events from StorefrontRenderer when serving partial HTML responses.
Any of these removes the need for Object.defineProperty interception and HTML script parsing in theme code.
Until then, teams choosing soft navigation should plan measurement explicitly: accept stale pixel counts on in-app routes, fall back to full navigation where attribution matters, or use custom events knowing app pixels will not see them unless they subscribe to custom names.
Conclusion
Liquid partials plus soft navigation deliver a credible app-like storefront while keeping Liquid as the source of truth for HTML. On our preview benchmarks, repeat route changes dropped from a 1,187 ms median full reload to 49 ms for a partial swap, with CLS unchanged and a modest first-load regression from the navigation runtime. Prefetch and post-transition lifecycle timing make the UX feel fast without sacrificing server-rendered correctness. Shell affordances (nav active state, mobile drawer close, stable header metrics) need explicit lifecycle hooks because the chrome never reloads. That combination is worth pursuing on the preview when browse flows matter more than cold-landing LCP alone.
The pixel gap is real, separable, and solvable at the platform layer. Our bridge proves events can fire after a region swap if you reach WPM's internal publish function. It also proves themes should not have to.
If you are experimenting with partials, use the standard-events inspector, switch DevTools to the pixel sandbox iframe when debugging web pixels, and treat any theme-side WPM intercept as temporary. Source and a live demo are in the open-source horizon-partials repo and on the liquid-july preview store (password: ocontis). Join the Shopify Developer Community thread if you are building on partials and want to compare notes. For layout boundaries, partial strategy, and analytics ownership on an established store, see our Shopify theme development capability or book a stack assessment to map preview features against what your stack can maintain and measure long term.
Experimental open-source fork of Horizon 4.1.3. Requires Liquid July '26 feature preview.



