Error! NO_LCP: как 4 пикселя сломали PageSpeed, и как мы ловили их всю ночь
PageSpeed перестал измерять LCP: Error! NO_LCP. Три часа, восемь гипотез, проба через PerformanceObserver - и виновником оказались 4 пикселя паддинга в карусели.
Обычная проверка сайта клининговой компании в PageSpeed Insights. Жду привычные цифры, а вижу вот это:
Largest Contentful Paint Error! NO_LCP
Total Blocking Time Error! NO_LCP
Главная метрика скорости - та самая, по которой Google оценивает сайты, - просто не измерялась. Не “плохая”, не “красная”. Её не было вообще. При этом остальное выглядело прилично: FCP 1,4 секунды, оценка 96 из 100. И до кучи пять аудитов диагностики падали со словом “Ошибка”: “Уменьшите размер CSS - Ошибка”, “Удалите неиспользуемый JS - Ошибка”.
Прогнали ещё раз. И ещё. Мобильная версия, десктопная - везде NO_LCP.
Первая версия была удобной: это Google глючит. Накануне сервис честно отвечал “Too many render requests”, а пять сломанных аудитов намекали, что замерный движок не может собрать данные. Проверили независимыми инструментами. Локальный Lighthouse той же версии 13.4.1, что у Google, - LCP измеряется, 1,9 секунды. SpeedVitals с сервером в Германии - Grade A, 94%, LCP 2,1. Полевые данные CrUX за 28 дней - LCP 1,7, Core Web Vitals пройдены. Три системы из четырёх меряют нормально. Значит, глючит именно движок PageSpeed, можно расходиться?
Не тут-то было. Прогнали через PSI второй сайт - мой личный, без сложной начинки. Всё измерилось идеально. Значит, дело не в сервисе Google. Что-то именно на этом сайте ломает замер.
Дальше - классический перебор подозреваемых. Каждая гипотеза выглядела железной, каждая давала частичное улучшение, и ни одна не была корнем.
Бегущая лента из 22 логотипов партнёров - известная причина проблем с LCP, постоянно движущийся крупный элемент. Отложили старт на 2 секунды. NO_LCP на месте.
Полупрозрачная hero-картинка: фото первого экрана стояло с opacity 0.3, а новые версии Chrome исключают полупрозрачные изображения из кандидатов в LCP. Сделали картинку непрозрачной, приглушённый вид собрали оверлеем - пересчитали математику цветов, попиксельно идентично. Локальный LCP стал красивым, 1,6 секунды. В PSI - NO_LCP.
Коллтрекинг. Включили в Lighthouse реальное замедление сети, как у Google, - и LCP локально взлетел до 6,8 секунды. В трейсе девять событий LargestContentfulPaint::Invalidate: кандидаты сбрасывались раз за разом. Корреляция по стекам вызовов указала на скрипт подмены телефонных номеров - он переписывает номера в DOM, и каждая переписка сбрасывала кандидата. Отключили аналитику тестовым режимом: LCP упал с 6,7 до 3,9. Лучше - но NO_LCP в PSI никуда не делся. Забегая вперёд: коллтрекинг был невиновен, он лишь усугублял чужую проблему.
Шрифты. Свап веб-шрифта перерисовывает заголовки, поздний repaint мог перебивать LCP. Поставили font-display: optional и метрически совместимый фолбэк. Мимо.
Раздутый трейс. Наш весил 25 МБ - анимации молотили 60 fps весь замер, а у движка Google есть лимиты, при переполнении события теряются. Переделали ленту на IntersectionObserver, чтобы крутилась только когда видима, - трейс похудел на треть. Локально score 95. В PSI - угадайте что.
Счёт к трём часам ночи: пять гипотез, пять частичных улучшений (сайт объективно стал быстрее!), ноль решений.
Тут случилось ключевое решение ночи - перестать интерпретировать отчёты Lighthouse и напрямую спросить браузер, что он думает про LCP. Взяли свежий Chrome, канареечную сборку, максимально близкую к той, что крутится у Google, запустили через puppeteer и инъектировали в страницу простейшую пробу:
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
log({time: e.startTime, size: e.size, element: e.element});
}
}).observe({type: 'largest-contentful-paint', buffered: true});
Контрольный сайт выдал две записи LCP - параграф, потом секция. Норма. Наш сайт - записей почти нет. В лучшем прогоне одна: крошечный логотип в шапке, 8016 квадратных пикселей. Огромный заголовок H1, hero-картинка на треть экрана - браузер их будто не видел.
LCP-поток не сбоил. Он умирал в первую секунду загрузки. А по спецификации наблюдение LCP необратимо останавливается ровно от двух вещей: действия пользователя или скролла.
Добавили в пробу слушатель скроллов и заблокировали вообще все скрипты на странице. Чистый HTML+CSS, ни одной строки JS.
scrolls: 4, первый на 1193 мс
Страница без единого скрипта скроллила сама себя. Четыре scroll-события на 1,2 секунде - ровно в момент применения стилей. Вот он, убийца LCP. Осталось узнать имя.
Расширили пробу - логировать, какой элемент скроллится:
{"at": 1139, "target": "DIV.works-track", "left": 3}
{"at": 1154, "target": "DIV.works-track", "left": 4}
works-track - карусель “Наши работы” с фотографиями до и после уборки. Смотрим её CSS:
.works-track {
overflow-x: auto;
scroll-snap-type: x mandatory; /* вот оно */
padding: 4px 4px 12px; /* и вот оно */
}
Механика бага. Из-за паддинга 4 пикселя первый слайд стоит в четырёх пикселях от края скролл-контейнера. А scroll-snap-type: mandatory требует, чтобы слайд был прижат ровно к снап-точке. В момент применения стилей браузер сам доскролливает карусель на эти 4 пикселя - никакого JS, чистый CSS. И такое scroll-событие - программное, внутреннего контейнера, на 4 пикселя! - навсегда останавливает наблюдение LCP в новых версиях Chrome. Всё. Больше ни один элемент не может стать LCP. PageSpeed разводит руками: Error! NO_LCP.
На моём втором сайте каруселей со снапом нет - потому он и мерился без проблем. А коллтрекинг с шрифтами лишь толкались локтями в уже мёртвом LCP-потоке.
Фикс - одна строка:
.works-track {
scroll-padding-inline: 4px; /* снап-точка = фактическая позиция слайда */
}
Снап-точка теперь учитывает паддинг, первый слайд изначально на месте, браузеру нечего доскролливать. Проверка пробой: ноль scroll-событий, три здоровые записи LCP - логотип, заголовок H1, hero-картинка на свои законные 314 000 квадратных пикселей.
Цифры до и после. Было: Error! NO_LCP во всех замерах PSI, CLS 0.447 в красной зоне, пять аудитов диагностики с “Ошибкой”. Стало: LCP 0,9 секунды на десктопе и 2,9 на мобайле - измеряется, CLS 0.008-0.01 (упал в 50 раз), Performance 89-93, все аудиты живые, полевые Core Web Vitals пройдены. Бонусом сайт объективно стал быстрее: непрозрачный hero-кандидат, картинка с CDN, экономная лента, шрифты без сдвигов, дочищенный critical CSS.
Затраты: около трёх часов расследования, восемь гипотез, полтора десятка замеров четырьмя инструментами, две улики из сырых трейсов Chrome. Решение - одна строка CSS против четырёх пикселей.
Что из этого стоит унести с собой. Связка scroll-snap-type: mandatory с паддингом на контейнере - мина: браузер сам доскролливает к снап-точке при загрузке и убивает LCP-замер, поэтому scroll-padding, равный паддингу, нужен всегда. Не гадайте по отчётам - спрашивайте браузер: десять минут с PerformanceObserver через puppeteer дали больше, чем два часа интерпретации Lighthouse. Частичное улучшение - ловушка: каждая ложная гипотеза что-то улучшала и создавала иллюзию верного пути, а корень нашёлся только изоляцией - страницей без единого скрипта, которая всё равно скроллила сама себя. И держите под рукой контроль: второй сайт, который мерился нормально, оказался самым ценным фактом всей ночи - он исключил версию “у Google всё сломалось” и заставил искать причину у себя.
Могло ли это уронить сайт и SEO?
Короткий ответ: ещё нет - но мы обезвредили мину до взрыва.
Нам повезло с механикой. Google ранжирует сайты не по лабораторным замерам, а по полевым данным CrUX - метрикам реальных посетителей за 28 дней. А у реального человека первый же тап или скролл останавливает наблюдение за LCP до того, как баг успевает сработать. Поэтому всё время, пока лаборатория показывала “Error!”, полевые показатели оставались зелёными: LCP 1,7 секунды, CLS 0, Core Web Vitals пройдены. Позиции не пострадали.
Но называть это “безобидным глюком” было бы враньём самому себе. Бомба тикала сразу по четырём направлениям.
Мы летели вслепую. С мёртвым LCP невозможно заметить настоящую деградацию: замедлился бы сервер, потяжелели картинки, подрядчик залил кривой скрипт - PageSpeed показал бы всё ту же “Error!”, и проблема жила бы незамеченной месяцами.
Реальные дефекты уже копились. Параллельно с загадкой NO_LCP мы нашли и починили настоящие сдвиги макета: шапка сайта прыгала на загрузке (CLS 0,36 - это много), блок с гарантиями перестраивался на глазах у посетителя. Полевой CLS держался на нуле в основном за счёт вернувшихся посетителей с прогретым кэшем. Но сайт как раз наращивает поисковый трафик - то есть долю новых посетителей с холодным кэшем, которые видят все скачки. Ещё месяц-два роста, и полевой CLS пополз бы вверх. А это уже не лаборатория - это прямой фактор ранжирования.
Chrome обновляется. Ужесточённая логика LCP, которая ловила наш авто-скролл, сначала живёт в тестовых сборках браузера - именно поэтому баг проявился в замерах Google раньше, чем у пользователей. Но тестовые сборки становятся стабильными. Не почини мы карусель, через несколько версий Chrome та же логика доехала бы до реальных браузеров - и до полевых метрик.
И репутация. Любой аудитор, подрядчик или маркетолог клиента, открыв PageSpeed и увидев “Error! NO_LCP” с пятью сломанными аудитами, сделал бы очевидный вывод: “сайт сломан”. Доказывать обратное с полевыми графиками в руках можно, но осадочек остаётся.
Отсюда, пожалуй, главный практический вывод всей истории: зелёные полевые метрики - не повод игнорировать сломанную лабораторию. Полевые данные показывают прошлое - последние 28 дней у существующей аудитории. Лаборатория - будущее: что увидит новый посетитель и новый браузер. Когда они противоречат друг другу, это не “глюк одного из них” - это разрыв, в котором прячется проблема.
P.S. Расследование велось в паре с ИИ-ассистентом (Claude), который гонял замеры, парсил 25-мегабайтные трейсы Chrome и писал пробы. Гипотезы проваливали вместе, победу тоже делим пополам.
Вопрос-ответ