How we can use Alpine.js to improve our site performance

Version note: All examples target Alpine.js v3.x (tested on v3.13). The Alpine.store() API, x-effect, Alpine.data(), and plugin system differ significantly from v2, if you are on v2, upgrade before applying tips #5, #6, #7.

Benchmark environment: All measurements were taken in Chrome DevTools (Performance tab + Memory panel), using Chrome V. 146. Mobile simulations used DevTools and “Slow 3G” network.

Section navigation

TipChangePrimary impact
1x-ifx-show for frequent togglesLayout / paint cost
2Defer setup with $nextTickTime-to-interactive
3Move large arrays out of x-dataMemory / reactivity overhead
4Replace $watch with x-effectReactivity overhead
5Split and slim Alpine storesMemory / reactivity overhead
6Defer component init with x-defer ⚠️ Hyvä onlyTotal Blocking Time (TBT)
7Register reusable components with Alpine.data()Memory / init overhead
8Optimise transition and animation costPaint / composite cost

## 1 Prefer `x-show` over `x-if` when toggling frequently

Why it matters

x-show toggles visibility via display: none, while x-if physically adds and removes DOM nodes. Destroying and recreating DOM is expensive. In a Lighthouse trace on a page with a frequently toggled modal, switching from x-if to x-show reduced scripting cost for that interaction from ~14 ms to under 1 ms.

We can use

  • x-show f.e. for dropdowns, modals, tooltips, tabs, any UI toggled by user action
  • x-if for blocks that rendered once or twice per session (f.e. a server-error message, a step that only appears after form submission, blocks that are available to specific customer groups)
<!--  Avoid when removes and recreates the DOM node on every toggle -->
<div x-if="open">...</div>

<!--  Use when toggles CSS display only, DOM node stays alive -->
<div x-show="open">...</div>

Accessibility checklist for x-show and x-if

Because x-show keeps elements in the DOM while hiding them visually, assistive technology can still discover them.

Focus management: hidden content shouldn’t receive keyboard focus. We can add tabindex="-1" to the root element or use the inert attribute (supported in all modern browsers) to remove the entire subtree from the tab order.

<!--  inert prevents focus and hides from screen readers when closed -->
<div
  x-show="open"
  x-bind:inert="!open"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
>
  <h2 id="modal-title">Confirm action</h2>
  ...
</div>

Aria-expanded : when x-show controls a disclosure widget (accordion, dropdown, nav menu), the trigger element must reflect open/closed state.

<button
  @click="open = !open"
  x-bind:aria-expanded="open"
  aria-controls="panel-id"
>
  Toggle section
</button>
<div id="panel-id" x-show="open" x-bind:inert="!open">...</div>

Aria-hidden : use only when inert is not available and you need to hide content from screen readers (inert is the modern approach).

<!-- fallback when inert is not available -->
<div x-show="open" x-bind:aria-hidden="!open">...</div>

Focus return: when a modal or overlay is closed, focus must return to the element that opened it. Capture the trigger reference before opening and restore it on close.

<div
  x-data="{
    open: false,
    trigger: null,
    openModal() { this.trigger = document.activeElement; this.open = true; },
    closeModal() { this.open = false; this.$nextTick(() => this.trigger?.focus()); }
  }"
>
  <button @click="openModal()">Open</button>
  <div x-show="open" x-bind:inert="!open" role="dialog" aria-modal="true">
    <button @click="closeModal()">Close</button>
  </div>
</div>

Accessibility note: x-if removes the element entirely, inert is not needed. But, if the removed element held focus, you still need to return focus to a sensible target.

Performance note: inert has no measurable rendering cost and is preferable to maintaining multiple aria-* bindings by hand.


## 2 Use `x-init` with `$nextTick` to defer expensive setup

Why it matters

Placing expensive setup inside x-init with $nextTick pushes it past Alpine’s current DOM update cycle, reducing the amount of synchronous work on the critical path and improving time-to-interactive.

Important distinction: $nextTick defers work within Alpine’s lifecycle, not beyond the main thread.
For true lazy initialization tied to viewport visibility, we can use tip #6 instead.

When to use this pattern

For example when you need fetching remote data, initialising third-party libraries (e.g. chart renderers, date pickers), and computing derived state from large datasets.

<!-- Avoid when fetchHeavyData() runs synchronously during init -->
<div
  x-data="{ loaded: false }"
  x-init="fetchHeavyData()"
></div>

<!-- Use when deferred past Alpine's current update cycle -->
<div
  x-data="{ loaded: false }"
  x-init="$nextTick(() => fetchHeavyData())"
></div>

For example a product carousel that fetches recommended items on mount:

<div
  x-data="{ items: [], loading: true }"
  x-init="$nextTick(async () => {
    items = await fetchRecommended();
    loading = false;
  })"
>
  <template x-if="loading"><p>Loading…</p></template>
  <template x-if="!loading">
    <ul>
      <template x-for="item in items" :key="item.id">
        <li x-text="item.name"></li>
      </template>
    </ul>
  </template>
</div>

If the DOM subtree is large and rarely used, x-ifmay still be cheaper overall.

Accessibility note

While data is loading, always provide an accessible loading state. Use role="status" and aria-live="polite" so screen readers announce when content becomes available without interrupting the user.

<!-- accessible loading indicator -->
<div
  x-data="{ items: [], loading: true }"
  x-init="$nextTick(async () => { items = await fetchRecommended(); loading = false; })"
>
  <p
    role="status"
    aria-live="polite"
    x-show="loading"
  >
    Loading recommendations…
  </p>
  <ul x-show="!loading" aria-label="Recommended products">
    <template x-for="item in items" :key="item.id">
      <li x-text="item.name"></li>
    </template>
  </ul>
</div>

## 3 Keep large datasets out of `x-data`

Why it matters

Every value inside x-data is wrapped in an ES Proxy and watched for changes. Storing a 1 000-item array there means Alpine tracks mutations across the entire structure. In a benchmark comparing a product listing with 500 items stored inside vs. outside x-data, moving the array out of Alpine’s reactivity system reduced memory allocated for the component from ~2.4 MB to ~0.3 MB and cut initial render time by ~35%.

Benchmark details: Measured on a page rendering 500 product cards. Memory was recorded via the Chrome DevTools Memory panel (heap snapshot, before/after component init). Render time was the “Scripting” entry in a Lighthouse performance trace, averaged across 5 runs.

<!-- Avoid when proxies the entire array, watches every item -->
<div x-data="{ list: hugeArray }"></div>

<!-- Use when  the data keep outside Alpine's reactivity system -->
<script>
  const hugeArray = await fetchItems(); // plain JS — not reactive
</script>

<div x-data="{ selectedId: null, page: 1 }" x-init="renderList(hugeArray)">
  ...
</div>

Accessibility note

When rendered lists update dynamically (pagination, filters, infinite scroll), announce the change to screen reader users.

<!-- announce list updates -->
<div x-data="{ page: 1, total: 0 }">
  <p role="status" aria-live="polite" aria-atomic="true" class="sr-only">
    Showing page <span x-text="page"></span> of <span x-text="total"></span>
  </p>
  <!-- list content -->
</div>
/* Visually hidden but accessible to screen readers */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

## 4 Use `$watch` sparingly. Prefer `x-effect`

Why it matters

Each $watch call registers a manual subscription that Alpine must maintain and evaluate. Too many watchers degrade performance, especially on data that changes frequently. x-effect automatically tracks its own reactive dependencies. It re-runs only when the values it actually reads change, with no manual subscription management.

Key difference

$watchx-effect
Dependency trackingManual. You name the property.Automatic. Reads during execution.
Runs on mountNoYes
Best forOne-off reactions, avoiding init runSide effects tied to reactive reads
// Manual watcher. Easy to miss dependencies
$watch('val', v => doWork(v))
<!--  x-effect . Automatically tracks val and any other reads inside -->
<div x-effect="doWork(val)"></div>

When $watch is still the right choice: If your effect must not run on component init (e.g. a form validation that should only trigger after the user edits a field), use $watch with a mounted guard rather than bending x-effect to skip the first run.

//  Guard pattern: skip the first run with a mounted flag
let mounted = false;
$watch('email', v => {
  if (!mounted) { mounted = true; return; }
  validateEmail(v);
});

⚠️ Be careful: • Any reactive value read inside x-effect becomes a dependency • Avoid heavy logic inside effects • Prefer pure functions + minimal side effects

## 5 Use stores for shared state, but keep them lean

Why it matters

Alpine global stores are great for sharing state across components without prop drilling. The catch: the entire store object is reactive, so a large store creates proportionally large reactivity overhead across every component that reads from it.

When to split a store

Split when:

  • The store holds more than ~50 reactive items, or
  • Unrelated components read different parts of the store (splitting reduces unnecessary re-renders), or
  • A derived value is being kept in sync manually inside the store definition.

Best practices

  • Store only what needs to be globally reactive (f.e flags, small collections)
  • Keep computed or derived values outside the store definition; calculate them in plain functions
  • Prefer multiple small, domain-specific stores over one large application store

More details about Alpine.store you can find on the previous article


## 6 Use `x-defer` to delay component initialization (⚠️ Hyvä only)

x-defer is a plugin shipped with the Hyvä Magento theme and is not available in vanilla Alpine.js. If you are not using Hyvä, see the vanilla fallback at the end of this section.

Why it matters

x-defer delays when a component’s JavaScript runs. Placed on the same element as x-data, it defers initialization until a specific condition (reducing initial load work, improving responsiveness, and reducing Total Blocking Time (TBT)).

In Hyvä-based projects, applying x-defer="intersect" to below-the-fold components has been shown to reduce TBT by 20–40% on mobile hardware, depending on component complexity.

Benchmark details: TBT was measured with Lighthouse (mobile preset, 4× CPU throttle) on a Hyvä 1.3 category page with 8 below-the-fold components. Baseline TBT was ~ 620 ms; after applying x-defer="intersect" to all 8, TBT dropped to ~ 390 ms (~ 37% reduction). Results vary with component complexity and count.

Strategies

StrategyTriggerBest for
intersectElement enters the viewportMost below-the-fold components
interactFirst user action (scroll, click, keypress)Non-critical UI dependent on user intent
idleBrowser idle (falls back after ~4 s)Background and low-priority components
event:nameA named custom event firesModals, mini-carts, action-triggered UI
<!-- Hyvä / x-defer  Initialize only when the user scrolls to this section -->
<div x-data x-defer="intersect"></div>

<!-- Hyvä / x-defer  Initialize only when the cart toggle event fires -->
<div x-data x-defer="event:toggle-cart"></div>

Vanilla Alpine.js fallback

If you are not using Hyvä, replicate viewport-based deferral with an Intersection Observer. This achieves the same effect as x-defer="intersect" using only standard browser APIs:

<!-- vanilla Alpine.js: defer until element enters the viewport -->
<div
  x-data="{ ready: false }"
  x-init="
    new IntersectionObserver(([e], obs) => {
      if (!e.isIntersecting) return;
      ready = true;
      obs.disconnect();
    }).observe($el)
  "
>
  <template x-if="ready">
    <heavy-component></heavy-component>
  </template>
</div>

Why x-if and not x-show here?. We want heavy-component to stay out of the DOM until the user scrolls to it. x-show would insert the component immediately and just hide it, defeating the purpose.


## 7 Register reusable components with `Alpine.data()`

⚠️ Alpine.js v3.x only. Alpine.data() was introduced in v3 and is not available in v2.

Why it matters

When you define component logic inline in x-data="{ ... }", Alpine creates a fresh JavaScript object for every element that uses that pattern. On a page with dozens of instances: product cards, accordion rows, table items this means repeated object allocation and repeated proxy wrapping at init time. Alpine.data() registers a named factory function once; every instance shares the same prototype chain, reducing both memory overhead and initialization cost.

In a benchmark comparing 100 inline x-data product cards against 100 Alpine.data() registered cards, the registered approach reduced total heap allocated during init by ~28% and cut component initialization time by ~20 ms.

Benchmark details: 100 card components, each with 6 reactive properties. Measured via Chrome DevTools Memory panel (allocation timeline). Init time measured as the “Scripting” cost in a Lighthouse trace, averaged across 5 runs, on Chrome 123.

Register the factory before Alpine initializes (before alpine.js is loaded, or inside a document.addEventListener('alpine:init', ...) handler):

// Register once
document.addEventListener('alpine:init', () => {
  Alpine.data('productCard', () => ({
    open: false,
    quantity: 1,
    addToCart() {
      // ...
    },
  }));
});
<!--  Use on any number of elements  -->
<div x-data="productCard">
  <button @click="addToCart()">Add to cart</button>
  <span x-text="quantity"></span>
</div>

When to use Alpine.data() vs inline x-data

ScenarioRecommendation
Component appears once per pageInline x-data is fine
Component appears 5+ timesRegister with Alpine.data()
Logic is shared across different templatesAlpine.data() — single source of truth
Rapid prototyping / simple one-off stateInline x-data for convenience

Combining with stores

Alpine.data() components can still read from global stores via $store. This gives you the best of both worlds: per-instance reactive state via the registered component, and shared cross-component state via stores.

Alpine.data('cartButton', () => ({
  get total() {
    return Alpine.store('cart').items.length; // reads from the global store
  },
  addItem(id) {
    Alpine.store('cart').items.push({ id });
  },
}));

Accessibility note

When registering components with Alpine.data(), centralise your ARIA attribute management inside the factory so every instance stays consistent. Manage aria-expanded, aria-controls, aria-selected, and focus handling in one place rather than duplicating them across templates.

//  ARIA state managed centrally in the factory
document.addEventListener('alpine:init', () => {
  Alpine.data('accordion', () => ({
    open: false,
    toggle() { this.open = !this.open; },
    get triggerAttrs() {
      return {
        'aria-expanded': this.open,
        'aria-controls': 'accordion-panel',
      };
    },
    get panelAttrs() {
      return {
        id: 'accordion-panel',
        role: 'region',
      };
    },
  }));
});
<!-- consistent ARIA on every instance -->
<div x-data="accordion">
  <button x-bind="triggerAttrs" @click="toggle()">Section title</button>
  <div x-bind="panelAttrs" x-show="open" x-bind:inert="!open">
    Panel content
  </div>
</div>

## 8 Optimise transition and animation cost *(new)*

Why it matters

Alpine’s x-transition directive hooks into the browser’s style recalculation pipeline. Used particularly on properties like width, height, top, or left, it forces layout on every animation frame, leading to janky transitions and elevated Cumulative Layout Shift scores. Confining transitions to opacity and transform keeps animation on the GPU compositor thread, where it runs at 60 fps without triggering layout.

Benchmark details: A height-based collapse transition on a 200-item accordion page produced an average frame time of ~28 ms (dropping below 60 fps). Replacing with an opacity + transform transition brought frame time to ~6 ms. Measured in Chrome DevTools Performance tab.

Animate only opacity and transform. Never animate width, height, margin, padding, top, left, or display.

<!--  Avoid: height transition triggers layout recalculation -->
<div
  x-show="open"
  x-transition:enter="transition-all duration-300"
  x-transition:enter-start="opacity-0 max-h-0"
  x-transition:enter-end="opacity-100 max-h-96"
>
<!--  Prefer: opacity + transform stays on the compositor thread -->
<div
  x-show="open"
  x-transition:enter="transition-all duration-200 ease-out"
  x-transition:enter-start="opacity-0 -translate-y-1"
  x-transition:enter-end="opacity-100 translate-y-0"
  x-transition:leave="transition-all duration-150 ease-in"
  x-transition:leave-start="opacity-100 translate-y-0"
  x-transition:leave-end="opacity-0 -translate-y-1"
>

Using x-collapse for height animations

When a genuine height animation is needed (accordions, collapsible panels), use the @alpinejs/collapse plugin rather than a CSS height animation. It calculates the target height once via JavaScript and uses a single style update, avoiding continuous layout recalculation during the animation.

<!-- with @alpinejs/collapse plugin -->
<div x-show="open" x-collapse>
  Panel content
</div>

Respecting prefers-reduced-motion

Users who have enabled reduced motion in their OS settings expect animations to be suppressed. Always respect this preference.

<!-- disable transitions for users who prefer reduced motion -->
<style>
  @media (prefers-reduced-motion: reduce) {
    [x-transition] { transition: none !important; }
  }
</style>

Or apply the preference in Alpine directly:

// check preference before adding transition classes
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

Alpine.data('modal', () => ({
  open: false,
  get transitionClasses() {
    return prefersReduced ? {} : {
      enter: 'transition-all duration-200 ease-out',
      enterStart: 'opacity-0 -translate-y-1',
      enterEnd: 'opacity-100 translate-y-0',
    };
  },
}));

These 8 tips address the most common Alpine.js performance bottlenecks, from DOM mutation cost to reactivity overhead to animation frame budget. Quick-wins with the highest payoff:

Tip 1: swap x-if for x-show on any frequently toggled element. The scripting cost drops from ~14 ms to under 1 ms with a one-line change, and the accessibility checklist (inert, aria-expanded, focus return) covers everything you need to do it safely.

Tip 6: apply x-defer="intersect" to below-the-fold components in Hyvä projects. Measured TBT reduction of ~37% on a category page with eight deferred components.

Tip 7: register repeated components with Alpine.data() instead of inline x-data. On 100 card instances, heap allocation dropped ~28% and init time fell ~20 ms.

Where to be careful:

$nextTick (Tip 2) defers work within Alpine’s update cycle only, it does not move work off the main thread. For true lazy init tied to viewport entry, combine it with an IntersectionObserver (see Tip 6 vanilla fallback).

Keeping large arrays outside x-data (Tip 3) requires managing rendering manually. The pattern works well when you control the render function; it adds complexity if the data needs to be filtered or sorted reactively.

x-defer (Tip 6) delays when Alpine initialises a component, which means its content is not available to assistive technology until the user scrolls to it. For content that screen reader users may need early, prefer x-defer="idle" or keep the component initialised and use x-show to hide it visually.

Further reading: Alpine.js documentation · Alpine.js x-defer plugin for Hyvä

This article was updated on April 28, 2026