How to use Alpine.js to improve your site performance

ℹ️ 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.

In this article, let’s discuss what practical techniques can be used to improve performance when working with Alpine.js, explore why it’s often better to use x-show instead of x-if for frequently toggled elements , how defer expensive initialization logic using x-init together with $nextTick to improve time-to-interactive and why keeping large datasets outside of x-data helps reduce memory usage and reactivity overhead.

Check how to manage reactivity more efficiently by using $watch sparingly and favoring x-effect where appropriate, as well as how to structure shared state with lightweight stores . For Hyvä specific going to discuss how to use x-defer to reduce Total Blocking Time .

Here are best practices for registering reusable components via Alpine.data() and ways to optimize transitions and animations to minimize paint and compositing costs


Prefer x-show over x-if when toggling frequently

x-show toggles visibility via display: none, while x-if physically adds and removes DOM nodes. Destroying and recreating DOM is expensive. Trace of a page with a frequently toggled modal, switching from x-if to x-show reduced the scripting cost for that interaction from ~350 ms to under 6.6 ms (based on benchmark test data).

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. 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.

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 content should be hidden from screen readers. inert is the modern approach and should be preferred.

<!-- 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 }">
  <button 
    @click="trigger = $el; open = true"
  >
    Open
  </button>

  <div 
    x-show="open"
    x-bind:inert="!open"
    role="dialog"
    aria-modal="true"
  >
    <button 
      @click="open = false; $nextTick(() => trigger?.focus())"
    >
      Close
    </button>
  </div>
</div>

ℹ️ Accessibility x-if removes the element entirely, inert is not needed. But, if the removed element held focus, focus should be returned to an appropriate target.

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

---
config:
  layout: elk
---
graph LR
    A[Element Toggled Often?]
    A -->|Yes| B["Use x-show (e.g., dropdowns, modals)"]
    A -->|No| C["Use x-if (e.g., rare/conditional blocks, error messages, user-specific content)"]

    classDef yesPath stroke:#4ade80,fill:#f0fdf4,color:#1e1b4b
    classDef noPath stroke:#f87171,fill:#fef2f2,color:#1e1b4b
    classDef decision stroke:#38bdf8,fill:#f0f9ff,color:#1e1b4b

    class A decision
    class B yesPath
    class C noPath

Use x-init with $nextTick to defer expensive setup

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.

ℹ️ $nextTick defers work within Alpine’s lifecycle, not beyond the main thread.
For true lazy initialization tied to viewport visibility, we can use defer component init with x-defer ( Hyvä only ) instead.

This pattern can be used when fetching remote data, initializing third-party libraries (e.g., chart renderers, date pickers), or 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>

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

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>

Keep large datasets out of x-data

x-data is fully reactive (Vue-style Proxies), so Alpine tracks every change. Putting large arrays there can cause unnecessary overhead.

In tests with 10 000 items, moving data outside x-data is ~3.6x faster, ~7.5x less heap memory (based on benchmark test)


Use $watch sparingly. Prefer x-effect

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

Feature$watchx-effect
Dependency trackingManual watcher (easy to miss dependencies)
$watch('val', v => doWork(v))
Automatically tracks val and any reactive reads
<div x-effect="doWork(val)"></div>
Runs on mountNoYes
Best forOne-off reactions, avoiding initial runSide effects tied to reactive data

⚠️ 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 with minimal side effects

Use stores for shared state, but keep them lean

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

  • 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 manually kept in sync inside the store definition.

Best practices

  • Store only what needs to be globally reactive (e.g. 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 exist in my article about using Alpine.store in the Hyvä theme


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.

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 on mobile hardware, depending on component complexity.

Strategies

StrategyTriggerBest for
intersectElement enters the viewport
<div x-data x-defer="intersect"></div>
Most below-the-fold components
interactFirst user action (scroll, click, keypress)
<div x-data x-defer="event:toggle-cart"></div>
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

Vanilla Alpine.js fallback

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


Register reusable components with Alpine.data()

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

When component logic is defined 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 10000 inline x-data product cards against 10000 Alpine.data() registered cards, the registered approach reduced total heap allocated during init by ~28% and cut component initialization time by ~20 ms on my machine.

⚠️ Alpine.data() can reduce repeated inline component definitions and improve maintainability. In larger pages with many repeated components, it may reduce memory pressure and initialization overhead, but real results should be measured in your own app.

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

---
config:
  layout: elk
---
graph TD
    A[Component Usage Scenario] --> B{Frequency & Context}
    B -->|Appears Once Per Page| C[Inline x-data]
    B -->|Appears 5+ Times| D[Register with Alpine.data]
    B -->|Logic Shared Across Templates| E[Alpine.data - Single Source of Truth]
    B -->|Rapid Prototyping / Simple State| F[Inline x-data for Convenience]
    C --> G[✓ Fine for isolated components]
    D --> H[✓ Better maintainability & DRY]
    E --> I[✓ Centralized logic management]
    F --> J[✓ Quick development]
    style C fill:#f0fdf4,stroke:#4ade80,color:#1e1b4b
    style D fill:#f0f9ff,stroke:#38bdf8,color:#1e1b4b
    style E fill:#fdf4ff,stroke:#e879f9,color:#1e1b4b
    style F fill:#fff7ed,stroke:#fb923c,color:#1e1b4b
    style G fill:#f0fdf4,stroke:#4ade80,color:#1e1b4b
    style H fill:#f0f9ff,stroke:#38bdf8,color:#1e1b4b
    style I fill:#fdf4ff,stroke:#e879f9,color:#1e1b4b
    style J fill:#fff7ed,stroke:#fb923c,color:#1e1b4b

Combining with stores

Alpine.data() components can still read from global stores via $store. This provides 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 });
  },
}));

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


Optimise transition and animation cost

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.

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

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',
    };
  },
}));

Where to be careful

$nextTick 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 .

Keeping large arrays outside x-data requires managing rendering manually. The pattern works well when the render function is controlled, it adds complexity if the data must be filtered or sorted reactively.

x-defer 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 May 1, 2026