The problem: A Laravel Blade page with a large table, dashboard, or repeated card list — each item wrapped in its own x-data — starts feeling sluggish: typing lags, scrolling stutters, and DevTools Performance shows long scripting time on every interaction.
Environment: Laravel 11, Alpine.js 3.x (Livewire-bundled), Blade views rendering 100+ repeated components, Vite 5.
The Error
// No thrown error — this is a perf profile symptom.
// In DevTools > Performance, a single click or keystroke shows:
Scripting: 340ms (Recalculate Style: 120ms, Layout: 90ms)
// on a page that should react in under 16ms per frame
// Or, if watchers are involved:
[Violation] 'input' handler took 210ms
Why It Happens
Alpine performance problems on large Laravel pages almost always trace back to one of these:
1. Too many independent reactive scopes. Every x-data is its own Alpine component with its own reactivity tracking. 200 rows in a table each with their own x-data="{ open: false }" means 200 separate reactive systems Alpine has to manage, even if 190 of them are doing nothing at any given moment.
2. Watchers on frequently-changing values. An $watch callback that does expensive work (DOM reads, network calls, heavy computation) fires on every single change to the watched value — if that value changes on every keystroke, the watcher runs on every keystroke too.
3. Large x-for lists without stable keys. Without a proper :key, Alpine (like most reactive frameworks) can't efficiently diff the list on update, so it may re-create more DOM nodes than necessary when the underlying array changes.
4. Livewire re-rendering the entire list on every small update. If the list is Livewire-driven and a single item's state change triggers a full component re-render, every Alpine instance inside that list gets torn down and rebuilt, not just the one that changed.
The Fix
For #1 and #3 — use a single x-data scope at the list's root and track per-item state with an index/id-keyed object instead of one x-data per row. Always give x-for a real, stable :key:
<div x-data="{
openRows: {},
toggle(id) { this.openRows[id] = !this.openRows[id] }
}">
<template x-for="row in rows" :key="row.id">
<div>
<button @click="toggle(row.id)" x-text="row.name"></button>
<div x-show="openRows[row.id]" x-text="row.details"></div>
</div>
</template>
</div>
For #2 — debounce expensive watcher work instead of running it on every change. Alpine ships a built-in .debounce modifier for event listeners, but for $watch you wrap it manually:
<div x-data="{
search: '',
debounceTimer: null,
}"
x-init="$watch('search', value => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// expensive work here, e.g. filtering a large array
console.log('filtering for:', value);
}, 300);
})"
>
<input type="text" x-model="search">
</div>
If it's a plain input event rather than a $watch, use Alpine's built-in modifier instead, which is slightly cheaper:
<input type="text" @input.debounce.300ms="filterResults($event.target.value)">
For #4 — add a stable wire:key per row so Livewire's diffing only touches the row that actually changed, not the whole list:
<template x-for="row in rows" :key="row.id">
<div wire:key="row-{{ '{{ $row->id }}' }}">
...
</div>
</template>
Step-by-Step
- Open DevTools > Performance, record an interaction (typing, clicking a row), and check whether Scripting time is dominated by many small component updates or one large blocking function.
- If you have one
x-dataper repeated row, refactor to a single parentx-datawith per-item state tracked by id/index in an object. - Confirm every
x-forhas a real, unique:key(not the array index, unless the list never reorders or filters). - For any
$watchdoing non-trivial work, wrap it in a debounce; for simple input handlers, use Alpine's built-in.debouncemodifier instead. - For Livewire-backed lists, add a stable
wire:keyper row so single-item updates don't force a full list re-render. - Re-record the same interaction in DevTools Performance and confirm scripting time dropped meaningfully.
x-data blocks into one parent scope can accidentally create naming collisions if two rows' logic used the same local variable name assuming isolation. Audit for shared variable names (like a generic open or value) before merging scopes, and namespace per-row state by id as shown above rather than by a shared flat variable.Not sure whether your bottleneck is Alpine or Livewire itself? Ask on the Contact page with your Performance tab screenshot.