Оптимизация Long Tasks для улучшения отзывчивости сайта
Long Task — любая задача в main thread браузера, выполнение которой занимает более 50 миллисекунд. Пока выполняется такая задача, браузер не может обработать пользовательский ввод: клик, скролл, нажатие клавиши. Пользователь видит «замёрзший» интерфейс. 50 мс — это порог восприятия: задержка до 50 мс не ощущается, всё что выше — уже «тупит».
Long Tasks — первопричина плохих значений TTI, TBT и INP. Понять, что именно их вызывает, и разобраться — это не быстрая работа. Здесь нет одного волшебного трюка.
Диагностика: профилирование в Chrome DevTools
Открываем DevTools → Performance → начинаем запись → имитируем загрузку или взаимодействие → останавливаем.
На треке Main видим красные треугольники на задачах длиннее 50 мс. Кликаем по задаче — в нижней панели появляется call tree: какие функции были вызваны и сколько времени заняла каждая.
Ключевые метрики в трейсе:
- Total blocking time — сумма (duration - 50ms) по всем Long Tasks
- Scripting в Summary — доля времени на выполнение JS
- Rendering — перерисовка DOM
- Painting — пиксельная отрисовка
Для production-профилирования без DevTools:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Long Task обнаружена
console.log({
duration: entry.duration,
startTime: entry.startTime,
// attribution — в каком скрипте/iframe
attributions: entry.attribution?.map(a => ({
containerType: a.containerType,
containerSrc: a.containerSrc,
name: a.name,
})),
});
// Отправляем в мониторинг
navigator.sendBeacon('/api/longtasks', JSON.stringify({
duration: entry.duration,
startTime: entry.startTime,
url: location.href,
userAgent: navigator.userAgent,
}));
}
});
observer.observe({ type: 'longtask', buffered: true });
Паттерн 1: разбивка задачи на чанки с scheduler.yield()
Самый прямой способ устранить Long Task — разбить её на части и между частями отдавать управление браузеру.
Старый способ через setTimeout(0):
function processLargeArray(items) {
const CHUNK_SIZE = 100;
let index = 0;
function processChunk() {
const end = Math.min(index + CHUNK_SIZE, items.length);
for (let i = index; i < end; i++) {
processItem(items[i]);
}
index = end;
if (index < items.length) {
// Отдаём управление браузеру и планируем следующий чанк
setTimeout(processChunk, 0);
}
}
processChunk();
}
Современный способ через scheduler.yield() (Chrome 115+):
async function processLargeArrayModern(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(processItem);
// Yield: браузер получает шанс обработать ввод
if (i + CHUNK_SIZE < items.length) {
await scheduler.yield();
}
}
}
Полифил для браузеров без scheduler.yield():
function yieldToMain() {
// scheduler.yield() если есть, иначе MessageChannel для минимальной задержки
if ('scheduler' in self && 'yield' in scheduler) {
return scheduler.yield();
}
return new Promise(resolve => {
const channel = new MessageChannel();
channel.port1.onmessage = resolve;
channel.port2.postMessage(undefined);
});
}
Паттерн 2: Web Workers для CPU-intensive вычислений
Вычисления, которые не работают с DOM, нужно выносить в Worker. Типичные кандидаты: парсинг больших JSON, криптография, сложные фильтры/сортировки, обработка файлов.
// heavy-worker.js
self.onmessage = function({ data: { type, payload } }) {
let result;
switch (type) {
case 'SORT_PRODUCTS':
result = payload.sort((a, b) => {
// сложная многокритериальная сортировка
return complexSort(a, b);
});
break;
case 'PARSE_CSV':
result = parseCSV(payload);
break;
case 'FILTER_CATALOG':
result = filterWithComplexRules(payload.items, payload.filters);
break;
}
self.postMessage({ type: type + '_DONE', result });
};
// main.js
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = Array.from({ length: poolSize }, () => new Worker(workerScript));
this.queue = [];
this.available = [...this.workers];
}
run(type, payload) {
return new Promise((resolve, reject) => {
const task = { type, payload, resolve, reject };
if (this.available.length > 0) {
this._dispatch(task);
} else {
this.queue.push(task);
}
});
}
_dispatch({ type, payload, resolve, reject }, worker = this.available.pop()) {
worker.onmessage = ({ data }) => {
resolve(data.result);
this.available.push(worker);
if (this.queue.length > 0) {
this._dispatch(this.queue.shift());
}
};
worker.onerror = reject;
worker.postMessage({ type, payload });
}
}
const pool = new WorkerPool('/heavy-worker.js', 2);
// Использование
const sortedProducts = await pool.run('SORT_PRODUCTS', products);
Паттерн 3: requestIdleCallback для некритичных задач
Аналитика, предзагрузка, сохранение состояния — всё некритичное откладываем до простоя браузера:
function scheduleNonCritical(work) {
if ('requestIdleCallback' in window) {
requestIdleCallback((deadline) => {
// Выполняем работу, пока есть время
while (deadline.timeRemaining() > 0 && work.length > 0) {
const task = work.shift();
task();
}
// Если не успели — планируем следующий раз
if (work.length > 0) {
scheduleNonCritical(work);
}
}, { timeout: 2000 });
} else {
// Полифил: setTimeout с задержкой
setTimeout(() => work.forEach(t => t()), 1);
}
}
// Использование
scheduleNonCritical([
() => preloadNextPageData(),
() => sendAnalyticsBatch(),
() => syncLocalStorage(),
]);
Паттерн 4: React — startTransition и useDeferredValue
React 18 добавил механизм явного разграничения срочных и несрочных обновлений.
startTransition для некритичных обновлений состояния:
import { startTransition, useState } from 'react';
function SearchPage() {
const [inputValue, setInputValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
function handleInput(e) {
const value = e.target.value;
// Обновление инпута — срочное (пользователь видит набор)
setInputValue(value);
// Обновление результатов — несрочное (можно прерваться)
startTransition(() => {
setSearchQuery(value);
});
}
return (
<>
<input value={inputValue} onChange={handleInput} />
{/* SearchResults может быть тяжёлым, но не блокирует ввод */}
<SearchResults query={searchQuery} />
</>
);
}
useDeferredValue для списков:
import { useDeferredValue, memo } from 'react';
const MemoizedList = memo(function ExpensiveList({ items, filter }) {
// Тяжёлая фильтрация и рендер
const filtered = items.filter(item => matchesComplexFilter(item, filter));
return <div>{filtered.map(item => <Item key={item.id} {...item} />)}</div>;
});
function FilteredCatalog({ products, filter }) {
// Деферированное значение фильтра — обновляется с задержкой
const deferredFilter = useDeferredValue(filter);
const isStale = filter !== deferredFilter;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<MemoizedList items={products} filter={deferredFilter} />
</div>
);
}
Паттерн 5: виртуализация длинных списков
Рендеринг тысячи DOM-элементов — одна большая Long Task. Виртуализация рендерит только видимые элементы.
С @tanstack/react-virtual:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // примерная высота строки
overscan: 5, // рендерим 5 строк сверх видимой области
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ListItem item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
Измерение результата
До и после оптимизации измеряем в лабораторных условиях (Lighthouse, 4× CPU throttle, 3G) и в полевых (INP через web-vitals):
import { onINP } from 'web-vitals/attribution';
onINP(({ value, attribution }) => {
const { interactionType, interactionTarget, processingStart, processingEnd } = attribution;
console.log({
inp: value,
interaction: interactionType,
element: interactionTarget,
processingTime: processingEnd - processingStart,
});
});
Типичный результат после полного цикла оптимизации: TBT снижается с 1–3 секунд до < 300 мс, INP улучшается с 400–600 мс до < 200 мс.
Сроки
Диагностика Long Tasks через DevTools + профилирование production через Long Task API — 2–3 рабочих дня. Полный цикл оптимизации: code splitting, Web Workers, React transitions, виртуализация — 2–4 недели для сложного SPA. Простые сайты с преимущественно серверным рендерингом — 3–7 рабочих дней.







