Питання на співбесіді: Продуктивність Livewire
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Після кожного запиту Livewire не замінює HTML компонента повністю, а морфить DOM: порівнює старе й нове дерево елемент за елементом і змінює лише відмінності. Так зберігаються фокус, введений текст, стан Alpine.
Проблема зі списками. Без підказок морфінг зіставляє елементи за позицією. Видалили перший елемент списку - і алгоритм вирішує, що змінився вміст кожного елемента, а останній зник:
@foreach ($todos as $todo)
<li>
<input type="checkbox" wire:model="done.{{ $todo->id }}">
<span x-data="{ editing: false }">{{ $todo->title }}</span>
</li>
@endforeach
Наслідки: стан Alpine (editing), фокус і введення «переїжджають» на сусідні рядки, анімації спрацьовують не там, а вкладені Livewire-компоненти отримують чужі дані.
wire:key дає кожному елементу стабільний ідентифікатор - морфінг зіставляє елементи за ключем:
@foreach ($todos as $todo)
<li wire:key="todo-{{ $todo->id }}">...</li>
@endforeach
@foreach ($posts as $post)
<livewire:post-card :$post :wire:key="$post->id" />
@endforeach
Правила:
- ключ - ідентифікатор даних (
id), а не індекс циклу.$loop->indexне допомагає: після видалення елемента індекси зсуваються так само; - ключ має бути унікальним у межах компонента. Якщо на сторінці два списки з тих самих моделей, додайте префікс:
"comment-{{ $id }}","reply-{{ $id }}"; - для вкладених Livewire-компонентів у циклі ключ обов'язковий - без нього стан дочірніх компонентів змішується.
Налаштування smart_wire_keys (у Livewire 4 увімкнено за замовчуванням) допомагає з ключами глибоко вкладених компонентів, але не звільняє від wire:key у циклах.
Ще один випадок - залежні поля: <select> міст, що змінюється разом з обраною областю, потребує wire:key="{{ $regionId }}", щоб значення коректно скинулося.
Альтернатива морфінгу для складних випадків - wire:replace: замінити вміст елемента повністю замість порівняння.
wire:poll періодично надсилає запит до компонента й оновлює його - найпростіший спосіб показувати свіжі дані без WebSocket.
<div wire:poll>
Нових замовлень: {{ $this->newOrdersCount }}
</div>
<div wire:poll.15s="refreshStatus">
Статус імпорту: {{ $status }}
</div>
За замовчуванням - кожні 2,5 секунди з повторним рендером компонента; з назвою методу - викликається цей метод.
Чому це дорого: тисяча відкритих вкладок з інтервалом 2,5 с - 400 запитів на секунду на сервер, кожен з гідрацією компонента, запитами до бази й рендером. Для PHP-FPM це сотні зайнятих процесів лише на опитування.
Як зменшити навантаження:
- довший інтервал. Найефективніший і найпростіший крок:
.10s,.1mзамість типових 2,5 с; .visible- опитувати лише тоді, коли елемент видно на екрані. Блок унизу довгої сторінки не опитується, поки до нього не прокрутять;- фонові вкладки Livewire пригальмовує автоматично - на 95%. Модифікатор
.keep-aliveвимикає це, і без справжньої потреби його не варто ставити; - легкий метод опитування: перевірити дешеву ознаку змін (кількість,
max(updated_at)) і не перебудовувати важкі дані, якщо нічого не змінилося; - острівець (
@island) з опитуванням - оновлюється лише ця частина компонента, а не весь шаблон; #[Isolate]- щоб повільне опитування одного компонента не затримувало запити інших.
Livewire 4: опитування більше не блокує інші дії - клік користувача не чекає на запит опитування в черзі.
Коли опитування - неправильний інструмент:
- оновлення потрібні миттєво (чат, спільне редагування) - WebSocket (Laravel Reverb + Echo), і Livewire слухає події трансляції через
#[On('echo:...')]; - оновлення рідкісні, а відвідувачів багато - теж краще події, ніж тисячі порожніх запитів;
- зміна станеться один раз (завершення імпорту) - опитування з зупинкою: перестаньте рендерити
wire:poll, коли статус фінальний.
Компонент з повільним запитом (статистика, звіт, зовнішній API) затримує всю сторінку: сервер не відправить HTML, поки не виконає mount() кожного компонента.
Два способи відкласти:
<livewire:revenue-chart lazy /> {{-- коли стане видимим на екрані --}}
<livewire:revenue-chart defer /> {{-- одразу після завантаження сторінки --}}
lazy- компонент завантажується, коли потрапляє в область видимості (прокрутили до нього). Для блоків унизу сторінки, які можуть і не знадобитися;defer- завантажується одразу після першого показу сторінки, незалежно від прокрутки. Для того, що потрібно завжди, але не повинно затримувати першу відповідь.
Сторінка приходить швидко з заглушкою на місці компонента, а сам компонент підвантажується окремим запитом.
Заглушка (placeholder) - що бачить користувач, поки компонент вантажиться:
@placeholder
<div class="h-64 animate-pulse rounded bg-gray-100"></div>
@endplaceholder
<div>
Дохід цього місяця: {{ $amount }}
</div>
У класових компонентах - метод placeholder(). Кореневий тег заглушки й компонента має збігатися (<div> і <div>). Добра заглушка має той самий розмір, що й готовий блок, - інакше сторінка «стрибатиме».
Зробити відкладеним завжди - атрибути на класі #[Lazy] чи #[Defer]. Для цілої сторінки - Route::livewire(...)->lazy().
Об'єднання запитів. За замовчуванням кожен відкладений компонент вантажиться окремим паралельним запитом. Десять віджетів на дашборді - десять запитів. Можна об'єднати в один:
<livewire:revenue lazy.bundle />
Але тоді повільний віджет затримає швидкі - об'єднання доречне для схожих за швидкістю компонентів.
Що варто знати:
- параметри, передані відкладеному компоненту (
:user="$user"), серіалізуються й чекають у браузері до завантаження - тож моделі в них перезавантажуються з бази; - SEO: вміст відкладеного компонента відсутній у початковому HTML;
- у тестах -
Livewire::withoutLazyLoading(), щоб бачити справжній вміст, а не заглушку; - для частини компонента, а не цілого компонента, - острівець
@island(lazy: true).
Обчислювана властивість - метод з атрибутом #[Computed], до якого звертаються як до властивості. Результат кешується в межах одного запиту.
use Livewire\Attributes\Computed;
new class extends Component {
public string $search = '';
#[Computed]
public function users()
{
return User::query()
->where('name', 'like', "%{$this->search}%")
->with('team')
->limit(20)
->get();
}
};
@foreach ($this->users as $user)
<li wire:key="{{ $user->id }}">{{ $user->name }} ({{ $user->team->name }})</li>
@endforeach
<p>Знайдено: {{ $this->users->count() }}</p> {{-- повторний доступ - без нового запиту --}}
У шаблоні - лише через $this->: $this->users, а не $users.
Чим краще за публічну властивість (public $users заповнена в mount()):
- не потрапляє в знімок стану - не їде в браузер і назад з кожним запитом;
- не перезавантажується з бази на кожен запит гідрації - лише коли шаблон чи код справді звертається до неї;
- завантажені зв'язки (
with('team')) працюють, бо запит виконується повністю щоразу, а не відновлюється за ключами; - завжди актуальна - похідна від поточного стану (
$search), її не треба оновлювати вручну.
Ліниве виконання - головна перевага: якщо дані потрібні лише в гілці @if, запит виконається лише тоді, коли умова істинна. А в острівці - лише коли рендериться саме острівець.
Скинути кеш у межах запиту, якщо дані змінилися:
public function addUser(): void
{
User::create([...]);
unset($this->users); // наступне звернення виконає запит заново
}
Кешування між запитами - #[Computed(persist: true)] (на компонент, година за замовчуванням) чи #[Computed(cache: true)] (спільно для всіх екземплярів) - але з обережністю щодо персональних даних.
Правило: публічні властивості - для стану, який змінює користувач (фільтри, введені значення, обраний id). Дані, що з них виводяться, - обчислювані властивості.
Острівець - частина шаблону компонента, що оновлюється ізольовано: дія всередині острівця перерендерює лише його, а не весь компонент.
<div>
<h1>Дашборд</h1>
@island
<div>
Дохід: {{ $this->revenue }}
<button type="button" wire:click="$refresh">Оновити</button>
</div>
@endisland
<livewire:recent-orders />
</div>
Клік «Оновити» перерахує й перемалює лише блок доходу. Оскільки revenue - обчислювана властивість, дорогий запит виконується тільки при рендері острівця.
Можливості:
- ліниве завантаження частини компонента:
@island(lazy: true)чи@island(defer: true)з@placeholderусередині; - іменовані острівці і виклик з будь-якого місця компонента:
@island(name: 'stats') ... @endisland
<button wire:click="$refresh" wire:island="stats">Оновити статистику</button>
- дописування замість заміни - для «Завантажити ще» й нескінченної стрічки:
wire:island.append="feed"чи.prepend; always: true- острівець оновлюється разом з кожним рендером компонента;skip: true- не рендерити, доки не викличуть явно.
Важлива поведінка за замовчуванням: коли рендериться весь компонент, острівці пропускаються. Змінили фільтр поза острівцем - острівець, що від нього залежить, сам не оновиться (потрібен always: true або явний виклик за назвою).
Чим краще за дочірній компонент:
- немає окремого файлу, стану, параметрів і подій для синхронізації - острівець бачить властивості й методи компонента;
- не потрібен
#[Reactive]для передачі змін.
Коли все ж дочірній компонент:
- повторне використання в кількох місцях;
- власні хуки життєвого циклу, власна авторизація в
mount(); - складний ізольований стан.
Обмеження:
- острівці не можна ставити в
@foreach,@ifта інші керуючі конструкції - вони не мають доступу до змінних циклу чи умови; - запити острівців ідуть паралельно і змінюють той самий стан компонента: якщо кілька запитів у польоті одночасно, «перемагає» відповідь, що прийшла останньою. Дії, що змінюють спільні властивості, варто тримати поза паралельними острівцями.
Після кожної дії Livewire за замовчуванням рендерить шаблон компонента заново й відправляє HTML у браузер. Для дій, що нічого не змінюють на екрані, це зайва робота: виконання Blade, обчислювані властивості й запити в шаблоні, розмір відповіді, морфінг DOM.
#[Renderless] - дія без рендеру:
use Livewire\Attributes\Renderless;
new class extends Component {
public Post $post;
#[Renderless]
public function trackRead(): void
{
$this->post->increment('reads');
}
};
<div wire:intersect.once="trackRead">...</div>
Інші способи:
$this->skipRender()усередині методу - коли рендер треба пропустити за умовою:
public function saveDraft(): void
{
$this->post->update(['draft' => $this->draft]);
if (! $this->showSavedAt) {
$this->skipRender(); // показувати нічого не треба - рендер зайвий
}
}
- модифікатор
.renderlessу шаблоні - точково, без зміни класу:wire:click.renderless="trackClick",wire:model.live.renderless="draft".
Типові застосування:
- аналітика й лічильники (перегляди, кліки, час на сторінці);
- збереження чернетки чи налаштувань, які вже відображені в браузері (стан у полі чи в Alpine);
- дії, результат яких обробляє JavaScript (повернене значення з
await $wire.method()).
Пастки:
- зміни властивостей у такій дії не з'являться на екрані - шаблон не рендерився. Якщо дія змінює те, що показується, рендер потрібен;
- помилки валідації в дії без рендеру теж не відобразяться через
@error- для таких випадків потрібен рендер чи обробка в JavaScript; - стан компонента все одно оновлюється - новий знімок повертається в браузер, тож наступні дії бачитимуть змінені значення.
Поєднання з #[Async] - класична пара для «запустив і забув»: дія не стає в чергу за іншими й не перемальовує компонент.
Тести: ->call('trackRead')->assertRenderSkipped().
Черга дій у межах компонента. Livewire за замовчуванням виконує дії одного компонента послідовно: поки один запит у польоті, наступні дії чекають. Так стан лишається передбачуваним - кожна дія бачить результат попередньої.
Групування між компонентами (bundling). Якщо кілька компонентів сторінки оновлюються одночасно (подія, на яку реагують три компоненти, кілька опитувань), Livewire об'єднує їхні оновлення в один HTTP-запит. Менше з'єднань, менше навантаження, а також це основа для механізмів, що потребують узгодження компонентів (реактивні параметри, #[Modelable]).
Мінус групування: спільний запит відповідає тоді, коли закінчив найповільніший компонент. Повільний віджет з викликом зовнішнього API затримує оновлення решти сторінки.
#[Isolate] - запити компонента не групуються з іншими і йдуть паралельно:
use Livewire\Attributes\Isolate;
new #[Isolate] class extends Component {
public function refreshShipping(): void
{
$this->status = Carrier::trackParcel($this->trackingNumber); // 2-3 секунди
}
};
Коли ізолювати:
- дорогі операції (складні запити, зовнішні API, важкі обчислення);
- кілька компонентів з
wire:pollз різними інтервалами; - компоненти, що слухають ті самі події, де один повільний не повинен гальмувати інших;
- компонент не взаємодіє з іншими через реактивні параметри.
Ціна ізоляції - більше паралельних запитів і процесів PHP. Ізолювати все підряд - отримати десятки запитів замість одного.
Пов'язані механізми:
- ліниві компоненти ізольовані за замовчуванням - кожен вантажиться окремим паралельним запитом;
lazy.bundle/#[Lazy(bundle: true)]об'єднує їх; #[Async]/.async- не стосується групування між компонентами, а виводить дію з черги свого компонента: вона виконується одразу, паралельно з іншими діями;- Livewire 4 зробив опитування й
wire:model.liveнеблокувальними: швидке введення в полі пошуку не чекає завершення попереднього запиту.
Діагностика: вкладка Network у DevTools - видно, скільки запитів livewire/update іде, які компоненти в кожному (у тілі запиту - масив компонентів) і скільки триває кожен.
Звичайна пагінація Laravel перезавантажує сторінку на кожен перехід. У Livewire-компоненті перехід між сторінками відбувається без перезавантаження, а номер сторінки все одно записується в URL (?page=2).
use Livewire\Attributes\Computed;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
#[Computed]
public function posts()
{
return Post::query()
->where('title', 'like', "%{$this->search}%")
->with('author')
->latest()
->paginate(20);
}
};
<input wire:model.live.debounce.300ms="search">
@foreach ($this->posts as $post)
<article wire:key="{{ $post->id }}">...</article>
@endforeach
{{ $this->posts->links() }}
Ключові моменти:
- трейт
WithPaginationобов'язковий - без нього посилання пагінації ведуть на звичайні URL з перезавантаженням; - пагінатор - в обчислюваній властивості чи в
render(), а не в публічній властивості: пагінатор не серіалізується як стан компонента; resetPage()при зміні фільтра - інакше користувач на 5-й сторінці, змінивши пошук, побачить порожню 5-ту сторінку нових результатів;- методи навігації:
setPage(3),nextPage(),previousPage().
Кілька пагінаторів на сторінці конфліктують за параметр page - потрібна власна назва:
return Invoice::paginate(10, pageName: 'invoices-page');
$this->resetPage(pageName: 'invoices-page');
Швидкість на великих таблицях:
paginate()виконує ще йCOUNT(*)по всьому набору - на мільйонах рядків це дорого;simplePaginate()- лише «Попередня / Наступна», без підрахунку;cursorPaginate()- курсор у URL замість номера сторінки, не сповільнюється на глибоких сторінках (немаєOFFSET), але не дає перейти на довільну сторінку.
Прокрутка після переходу. Посилання пагінації Livewire після кожного переходу прокручують до початку сторінки. Якщо список - не перший блок на сторінці, краще прокручувати до нього: {{ $this->posts->links(data: ['scrollTo' => '#posts']) }}, або вимкнути прокрутку ('scrollTo' => false).
Без номера сторінки в URL - трейт WithoutUrlPagination поряд з WithPagination (наприклад, для списку в модальному вікні).
Нескінченна стрічка замість сторінок - острівець з wire:island.append і wire:intersect на «маркері» внизу списку.
За замовчуванням дії одного компонента виконуються по черзі: поки йде запит, наступні чекають. Асинхронна дія виконується одразу, паралельно з іншими, - не чекає черги й нікого не блокує.
use Livewire\Attributes\Async;
new class extends Component {
#[Async]
public function trackClick(string $url): void
{
Analytics::track('external-link', ['url' => $url, 'user' => auth()->id()]);
}
};
<a href="{{ $url }}" target="_blank" wire:click.async="trackClick('{{ $url }}')">Перейти</a>
Або точково в шаблоні: wire:click.async, wire:intersect.async.
Чому стан і асинхронність несумісні. Кожен запит стартує зі знімка стану, який браузер мав на момент відправки. Паралельні запити не бачать результатів одне одного:
#[Async] // так не можна
public function increment(): void
{
$this->count++;
}
П'ять швидких кліків - п'ять запитів, кожен починає з count = 0, кожен повертає 1. Лічильник показує 1 замість 5. Зміни губляться, а остаточний стан визначає відповідь, що прийшла останньою, - не обов'язково остання за часом дія.
Безпечні сценарії:
- чисті побічні ефекти: аналітика, журнали, постановка джоби в чергу, сповіщення - дія не змінює властивостей, що відображаються;
- дані для JavaScript: результат повертається в Alpine і зберігається там, а не в стані компонента:
<div x-data="{ suggestions: [] }">
<input x-on:input.debounce="suggestions = await $wire.fetchSuggestions($event.target.value)">
</div>
Правило: якщо асинхронна дія присвоює значення публічній властивості, що впливає на шаблон, - це помилка дизайну.
Захист від гонитви на сервері асинхронність не скасовує: дві паралельні дії, що змінюють один запис у базі, потребують атомарних операцій (increment()), транзакцій чи блокувань, як і будь-які паралельні HTTP-запити.
Зв'язок з іншими механізмами:
- з
#[Renderless]- класичне поєднання для «запустив і забув»; #[Isolate]- про групування між компонентами, а#[Async]- про чергу всередині компонента;- острівці теж ходять паралельно, і застереження про спільний стан для них те саме.
Звичайна обчислювана властивість кешується лише в межах одного запиту. Для дорогих даних є кешування довше - через кеш Laravel.
persist: true - кеш для цього екземпляра компонента між його запитами (типово 3600 секунд):
#[Computed(persist: true, seconds: 600)]
public function stats()
{
return Order::query()->where('shop_id', $this->shopId)->selectRaw('...')->first();
}
Користувач клацає фільтри, а важка статистика не перераховується на кожен клік.
cache: true - кеш спільний для всіх екземплярів компонента в застосунку:
#[Computed(cache: true, key: 'homepage-popular-posts', seconds: 300)]
public function popularPosts()
{
return Post::popular()->limit(10)->get();
}
Одна й та сама головна сторінка для всіх - запит раз на п'ять хвилин замість кожного рендеру.
Головний ризик - cache: true з персональними даними:
#[Computed(cache: true)] // помилка!
public function myOrders()
{
return auth()->user()->orders()->latest()->get();
}
Ключ кешу не залежить від користувача. Перший, хто відкрив сторінку, «заповнить» кеш своїми замовленнями, а наступні побачать його дані. Це витік персональних даних, а не просто баг.
Правила:
cache: true- лише для даних, однакових для всіх (публічні списки, довідники, загальна статистика);- якщо дані залежать від користувача, мови, параметрів - ключ має їх містити (
key: "orders-user-{$id}"неможливо в атрибуті, тож для таких випадків - звичайнийCache::rememberу методі з явним ключем) або використовуватиpersist: true, прив'язаний до екземпляра; - інвалідація: кеш живе до закінчення терміну. Після зміни даних
unset($this->stats)скидає лише кеш у межах запиту; для збережених значень потрібне явне очищення через кеш Laravel чи коротший термін; - серіалізація: у кеш потрапляють моделі й колекції - перевірте, що драйвер кешу й налаштування
serializable_classesдозволяють такі об'єкти; - авторизація до кешу: перевірка прав має відбуватися до звернення до кешованого значення, а не всередині методу, який при влучанні в кеш не виконується.
Коли краще не використовувати ці опції: якщо запит швидкий (кілька мілісекунд) - звернення до кешу через мережу (Redis) може коштувати стільки ж, а складність і ризик застарілих даних додаються.
Інколи дія потрібна не для того, щоб змінити стан і перемалювати компонент, а щоб отримати дані для JavaScript: варіанти автодоповнення, точки для карти, дані для графіка. Атрибут #[Json] робить дію таким ендпойнтом.
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchCities(string $query): array
{
return City::query()
->where('name', 'like', "{$query}%")
->limit(10)
->get(['id', 'name'])
->toArray();
}
};
<div x-data="{ query: '', cities: [] }">
<input x-model="query" x-on:input.debounce.300ms="cities = await $wire.searchCities(query)">
<template x-for="city in cities" :key="city.id">
<button type="button" x-text="city.name" x-on:click="$wire.cityId = city.id"></button>
</template>
</div>
Що відрізняє #[Json]-дію:
- повернене значення приходить у JavaScript як результат Promise від
$wire.method(); - помилки валідації не потрапляють у
@error, а відхиляють Promise зі структурою{ status: 422, errors: { поле: [...] } }:
try {
const result = await $wire.save();
} catch (e) {
if (e.status === 422) showErrors(e.errors);
}
- компонент не перерендерюється заради результату - дані живуть в Alpine.
Навіщо це замість окремого API-маршруту:
- не потрібні маршрут, контролер, CSRF-налаштування й автентифікація для API - дія працює в контексті компонента з його станом (
$this->countryId) і поточним користувачем; - логіка лишається поруч із компонентом, який її використовує.
Що варто пам'ятати:
- це публічний ендпойнт: будь-хто з доступом до сторінки може викликати метод з довільними аргументами. Обмежуйте результат (
limit), перевіряйте права, не повертайте зайвих полів моделей (паролі, приховані атрибути -toArray()поважає$hidden, але явнийget(['id', 'name'])надійніший); - частота: debounce на введенні обов'язковий, інакше кожна літера - запит;
- паралельність: такі дії часто поєднують з
#[Async], щоб вони не стояли в черзі за іншими діями компонента. Але тоді результат має зберігатися лише в JavaScript, не у властивостях; - для великих обсягів даних (тисячі точок) варто подумати про окремий кешований ендпойнт - відповідь дії Livewire теж містить знімок стану компонента.
Кожна дія Livewire - це повний цикл на сервері: відтворення компонента зі знімка, хуки, дія, рендер шаблону, новий знімок. Повільно може бути на будь-якому етапі, тому спершу - виміряти.
Інструменти:
- DevTools → Network, запити
livewire/update: тривалість, розмір запиту й відповіді (великий знімок видно одразу), скільки компонентів у кожному запиті; - Laravel Debugbar / Telescope / Pulse - SQL-запити, їх кількість і дублікати на один запит Livewire;
- профайлер (Xdebug, Blackfire, SPX) - де саме йде час: гідрація, дія чи рендер Blade.
Найчастіші причини:
1. Моделі й колекції в публічних властивостях. Відновлюються з бази на кожному запиті, без завантажених зв'язків - і шаблон отримує N+1. Рішення: обчислювані властивості з with().
2. Важкі запити в render(), boot(), hydrate(). Виконуються на кожен клік, навіть коли дія їх не стосується. Рішення: #[Computed] (виконується лише при зверненні), острівці, кешування.
3. Перерендер усього компонента заради дрібниці. Перемикач «улюблене» в рядку таблиці на 200 рядків перебудовує всю таблицю. Рішення: острівець, #[Renderless] для дій без зміни вигляду, окремий компонент для рядка.
4. Зайві запити від wire:model.live. Запит на кожну паузу у введенні в кожному полі. Рішення: звичайний wire:model (дані підуть з відправкою форми), .blur, більший debounce.
5. Великий знімок стану. Масиви на тисячі елементів, вкладені структури в публічних властивостях їдуть в обидва боки з кожним запитом. Рішення: тримати в стані лише ідентифікатори й значення фільтрів.
6. Багато компонентів на сторінці. Сотні дрібних компонентів у циклі - сотні знімків у HTML і накладні витрати на кожен. Рішення: Blade-компоненти для статичних частин, Livewire лише там, де справді потрібна інтерактивність.
7. Повільний компонент гальмує інші. Групування запитів змушує чекати найповільнішого. Рішення: #[Isolate], lazy/defer.
8. Опитування. wire:poll з коротким інтервалом на популярній сторінці множить навантаження на кількість відкритих вкладок.
Не лише сервер: повільний морфінг величезного DOM (тисячі рядків) теж відчутний - тут допомагають пагінація й віртуалізація, а не оптимізація PHP.
Перевірка після виправлення - ті самі вимірювання: кількість SQL-запитів на одну дію і розмір відповіді мають помітно зменшитися.