Оптимизация Total Blocking Time (TBT) сайта
Total Blocking Time — сумма «заблокированных» миллисекунд между FCP и TTI. Задача длиннее 50 мс в main thread считается Long Task; всё, что сверх этих 50 мс, суммируется в TBT. Если у вас три задачи по 80 мс — TBT составит (80-50)×3 = 90 мс. Это Core Web Vital-смежная метрика: TBT в лабораторных условиях коррелирует с INP в реальных.
Хорошее значение TBT по Lighthouse: менее 200 мс (десктоп), менее 300 мс (мобильный симулятор с 4× CPU throttling).
Диагностика: находим Long Tasks
Первый шаг — найти, что именно создаёт Long Tasks. Chrome DevTools, вкладка Performance: записываем загрузку страницы, смотрим красные полосы над main thread.
Программно через Long Task API:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log({
name: entry.name,
duration: entry.duration,
startTime: entry.startTime,
attribution: entry.attribution,
});
}
});
observer.observe({ type: 'longtask', buffered: true });
attribution покажет, в каком фрейме или скрипте возникла задача. Отправляем данные в аналитику для field monitoring:
observer.observe({ type: 'longtask', buffered: true });
// В обработчике
fetch('/api/longtasks', {
method: 'POST',
body: JSON.stringify({
tasks: list.getEntries().map(e => ({
duration: e.duration,
script: e.attribution[0]?.name || 'unknown',
url: location.href,
})),
}),
keepalive: true,
});
Причина №1: тяжёлый JavaScript при старте
Самая частая причина высокого TBT — большой JS-бандл, который парсируется и выполняется синхронно при загрузке. Каждый мегабайт JS требует ~1 секунды парсинга на среднем мобильном устройстве.
Решение: code splitting + lazy loading. Пример для React:
// До: всё грузится сразу
import HeavyChart from './HeavyChart';
import DataTable from './DataTable';
// После: грузим только при необходимости
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Показать график</button>
{showChart && (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
Для роутов — React.lazy + react-router:
const ProductPage = lazy(() => import('./pages/ProductPage'));
const CheckoutPage = lazy(() => import('./pages/CheckoutPage'));
// Все страницы загружаются только при переходе
Причина №2: синхронные сторонние скрипты
Google Tag Manager, чаты, пиксели рекламных сетей — каждый сторонний скрипт может создавать Long Tasks. Особенно если загружен синхронно.
Диагностика в Chrome DevTools: Performance → Bottom-up → группировка по домену. Видим, сколько времени main thread тратит на каждый сторонний скрипт.
Решения:
<!-- Вместо синхронного: -->
<script src="https://widget.example.com/chat.js"></script>
<!-- Используем async или defer: -->
<script src="https://widget.example.com/chat.js" async></script>
<!-- Или отложенная загрузка после взаимодействия: -->
<script>
function loadChat() {
const s = document.createElement('script');
s.src = 'https://widget.example.com/chat.js';
document.head.appendChild(s);
}
// Загружаем после первого взаимодействия пользователя
['click', 'scroll', 'keydown'].forEach(event => {
window.addEventListener(event, loadChat, { once: true });
});
</script>
Для GTM: отложенный запуск триггера «DOM Ready» вместо «Page View» для некритичных тегов.
Причина №3: тяжёлые вычисления в main thread
Сортировка больших массивов, сложные DOM-манипуляции, синхронные XHR — всё это блокирует main thread.
Web Workers для вычислений:
// worker.js
self.onmessage = function(e) {
const { data, operation } = e.data;
let result;
switch (operation) {
case 'sort':
result = heavySort(data);
break;
case 'filter':
result = complexFilter(data);
break;
}
self.postMessage(result);
};
// main.js
const worker = new Worker('/worker.js');
worker.postMessage({ data: largeArray, operation: 'sort' });
worker.onmessage = (e) => {
setTableData(e.data);
};
Scheduler API (Chrome 94+) для разбивки задач:
async function processItems(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk);
// Отдаём управление браузеру между чанками
if (i + CHUNK_SIZE < items.length) {
await scheduler.yield();
}
}
}
Полифил для браузеров без scheduler.yield():
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0));
}
Причина №4: тяжёлый hydration в SSR/SSG
Next.js, Nuxt, Gatsby — при hydration фреймворк повторно обходит весь DOM и привязывает обработчики событий. На больших страницах это одна большая Long Task.
Решения:
- Partial hydration / Islands architecture — гидратируем только интерактивные компоненты
- Progressive hydration — откладываем hydration для off-screen компонентов
-
React 18
startTransition— помечаем некритичные обновления как переходы
import { startTransition } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
function handleSearch(newQuery) {
// Обновление результатов — не срочное
startTransition(() => {
setResults(fetchResults(newQuery));
});
}
// ...
}
Измерение TBT в полевых условиях
TBT нельзя измерить из реального браузера напрямую, но INP (Interaction to Next Paint) — его production-аналог. Используем web-vitals:
npm install web-vitals
import { onINP, onFCP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
id: metric.id,
url: location.href,
});
navigator.sendBeacon('/api/vitals', body);
}
onINP(sendToAnalytics);
onFCP(sendToAnalytics);
onLCP(sendToAnalytics);
Контрольный чеклист оптимизации TBT
- Разбили JS-бандл: initial chunk < 150 kB gzip, роуты ленивые
- Сторонние скрипты: все некритичные — с
deferили отложенной загрузкой - Тяжёлые вычисления вынесены в Web Worker
- Длинные синхронные циклы разбиты через
scheduler.yield() - React:
startTransitionдля некритичных обновлений,useDeferredValueдля фильтрации - Проверили рекламные сети: AdSense, Yandex Ads часто дают 200–500 мс TBT
Сроки
Диагностика и составление плана оптимизации — 1–2 рабочих дня. Code splitting + ленивая загрузка типового React-приложения — 3–5 рабочих дней. Полный цикл: диагностика, оптимизация, настройка мониторинга INP в продакшне — 1,5–3 недели в зависимости от сложности приложения.







