Все статьи
Кейсы

Как я оптимизировал сайт до 100 баллов в PageSpeed Insights

Я несколько дней последовательно оптимизировал niki-losk.online: изображения, CSS, анимации, кеширование, серверную часть и даже отправку заявок в Telegram. В итоге мобильная проверка PageSpeed Insights показывает 100 баллов по производительности, доступности, рекомендациям и SEO. В этой статье разберу, что именно было сделано, что реально ускорило сайт и почему часть проблем оказалась вообще не в коде.

Техническая оптимизация сайта для быстрой загрузки и максимальных показателей PageSpeed Insights

С чего всё началось

После того как основная версия niki-losk.online была собрана, я решил отдельно заняться скоростью.

Визуально сайт уже работал нормально: были страницы услуг, адаптивная версия, изображения, анимации, формы заявок. Но я хотел не просто посмотреть на него глазами, а понять, что происходит внутри при реальной загрузке страницы.

Поэтому работа началась не с попыток наугад удалить CSS или сильнее сжать картинки, а с аудита.

Я попросил Codex проверить весь проект и найти реальные точки, которые можно оптимизировать:

  • изображения;
  • CSS;
  • JavaScript;
  • анимации;
  • Flask;
  • Gunicorn;
  • Nginx;
  • кеширование;
  • HTTP/2;
  • TTFB;
  • загрузку страниц услуг;
  • мобильную версию;
  • формы заявок.

В итоге получился достаточно большой список небольших проблем. По отдельности большинство из них не делали сайт критически медленным, но вместе они влияли на скорость и качество загрузки.

Что было до оптимизации

Сам сайт уже работал на связке:

Nginx → Gunicorn → Flask

То есть архитектуру с нуля переделывать не пришлось.

Но внутри проекта накопилось несколько вещей, которые можно было сделать лучше.

Например, на главной отдельно подключались:

  • home.css;
  • lead-form.css;
  • contact-bar.css;
  • reveal.css;
  • corporate-home.css.

На страницах услуг было около пяти CSS-файлов.

Изображения уже были в WebP, но этого оказалось недостаточно. Телефон всё равно мог скачать файл шириной 960 px, даже если на экране изображение отображалось примерно в 320 px.

Кроме этого:

  • не у всех изображений были width и height;
  • часть изображений не использовала responsive-варианты;
  • изображения ниже первого экрана могли начинать загружаться слишком рано;
  • hero-анимации создавали лишнюю нагрузку на мобильных устройствах;
  • HTML не имел отдельной короткой cache-политики;
  • /favicon.ico возвращал 404;
  • существовал горизонтальный overflow примерно на 8 px;
  • Telegram API находился непосредственно в пути отправки формы.

Последний пункт особенно интересный.

Когда пользователь отправлял заявку, сервер ждал ответа Telegram примерно 380 ms. Всё это время один Gunicorn worker оставался занят внешним запросом.

То есть сам сайт мог выполнить свою работу практически мгновенно, но продолжал ждать сторонний сервис.

Сначала исправил саму верстку

Одной из первых проблем оказался горизонтальный overflow.

На мобильном документ шириной 375 px фактически получался примерно на 8 px шире экрана.

Отдельная похожая проблема была и на desktop: hero использовал 100vw.

Это распространённая мелочь. 100vw учитывает ширину scrollbar, поэтому блок может оказаться немного шире реальной доступной области страницы.

Можно было сделать просто:

overflow-x: hidden

Но это не исправление.

Такой подход просто скрывает всё, что вышло за экран. Если внутри действительно есть неправильно расположенный интерфейс, браузер его обрежет.

Поэтому я исправил сами размеры и отступы контейнеров.

Для desktop hero вместо проблемного расчёта ширины использовал контейнерную ширину.

После этого проверил мобильные размеры:

  • 320 px;
  • 375 px;
  • 430 px;

и desktop-разрешения от 1024 до 1920 px.

В результате горизонтальная прокрутка исчезла полностью.

WebP оказался только первым этапом оптимизации изображений

До этой работы изображения на сайте уже использовали WebP.

Но просто перевести JPEG или PNG в WebP недостаточно.

Представим, что карточка на телефоне отображается шириной около 317 px. Если в HTML указан один файл 960×540, телефон всё равно скачает именно его.

Поэтому я подготовил отдельные responsive-варианты.

Для карточек главной:

  • 480 px;
  • 768 px;
  • 960 px.

Для крупных изображений страниц услуг:

  • 640 px;
  • 960 px;
  • 1440 px.

После этого подключил srcset и sizes.

Теперь браузер сам решает, какой файл ему нужен под конкретную ширину экрана.

Это особенно заметно по страницам услуг.

Например, четыре изображения одной страницы в исходных вариантах 1440 px занимают около 313 KB.

В версиях 960 px — около 188 KB.

В мобильных 640 px — около 106 KB.

При этом визуально пользователь получает практически ту же картинку.

Разделил изображения первого экрана и всё остальное

После responsive images я отдельно настроил приоритет загрузки.

Не все изображения на странице одинаково важны.

Если человек только открыл страницу, браузеру не нужно срочно скачивать изображение карточки, которая находится через несколько экранов ниже.

Поэтому изображения нижних блоков получили:

  • loading="lazy";
  • fetchpriority="low";
  • decoding="async".

На главной таких изображений сейчас 12.

Но с hero всё наоборот.

Главную картинку нельзя бездумно ставить на lazy loading. Это может ухудшить LCP, потому что браузер специально откладывает загрузку самого важного визуального элемента страницы.

Поэтому hero страниц услуг работает через:

  • loading="eager";
  • fetchpriority="high";
  • responsive preload;
  • imagesrcset;
  • imagesizes.

То есть браузер ещё при чтении <head> понимает: вот это изображение понадобится на первом экране, начинай грузить его сразу.

При этом мобильное устройство не обязано скачивать тяжёлый desktop-вариант.

Добавил фиксированные размеры изображений

Ещё одна небольшая, но правильная оптимизация — width и height.

Для карточек используются реальные пропорции:

960×540

Для визуалов услуг:

1440×810

Оба формата — 16:9.

Браузер теперь знает геометрию элемента ещё до того, как изображение физически загрузилось.

Это уменьшает риск layout shift: страница не должна сначала построиться без картинки, а потом резко раздвинуть блоки после её появления.

Собрал CSS главной

Следующим этапом был CSS.

Изначально главная использовала пять отдельных CSS-файлов.

Я собрал основные стили в:

home.bundle.css

А для страниц услуг появился:

service.bundle.css

На услугах получилось сократить примерно 5 CSS-запросов до 1.

Текущий service.bundle.css весит:

  • 26 755 B без сжатия;
  • около 5 914 B gzip.

Для главной основной bundle сейчас составляет:

  • 27 007 B raw;
  • около 6 499 B gzip.

Само по себе небольшое количество файлов сегодня не всегда критично благодаря HTTP/2, но CSS остаётся render-blocking ресурсом, поэтому уменьшать лишние зависимости всё равно полезно.

Critical CSS: первый экран отдельно от остального сайта

При объединении CSS появилась другая задача.

Если весь дизайн находится в одном bundle, браузеру нужно сначала скачать его, разобрать и только потом нормально показать первый экран.

Но человеку при открытии сайта сначала нужны только:

  • header;
  • hero;
  • базовая структура первого экрана.

Поэтому эти стили я вынес в отдельный critical CSS и вставил непосредственно в HTML.

Основной CSS загружается отдельно.

В итоге браузеру не нужно ждать весь дизайн нижних секций, чтобы показать пользователю hero.

Одна оптимизация сломала hero

Во время работы был и неудачный эксперимент.

Я попробовал агрессивнее удалить из bundle CSS, который уже находился внутри critical CSS.

Размер внешнего CSS действительно удалось снизить:

с 35 220 B до 12 698 B.

На бумаге результат отличный.

Но вместе с дублями изменился порядок CSS-каскада и пострадали @keyframes.

В результате hero стал отображаться неправильно.

Я откатил эту оптимизацию и восстановил безопасную сборку.

Сейчас home.bundle.css весит около 27 KB.

Для меня это хороший пример того, почему оптимизация не должна превращаться в соревнование за минимальное количество байтов.

Если экономия 10–20 KB ломает интерфейс, такая оптимизация не нужна.

Упростил анимации на телефонах

Hero на главной специально сделан живым: там есть плавающие элементы, мягкие световые эффекты и другие небольшие движения.

На desktop это выглядит нормально.

Но несколько бесконечных CSS-анимаций одновременно — лишняя нагрузка для слабого телефона.

Поэтому desktop сохранил более полную анимацию, а mobile получил упрощённый вариант.

Постоянное движение части карточек на телефонах отключено.

Кроме этого, используется:

prefers-reduced-motion

Если пользователь в системе попросил уменьшить количество движения, сайт отключает анимации.

В итоге не пришлось полностью отказываться от визуальных эффектов ради производительности.

Вынес JavaScript формы

Логика формы раньше находилась inline внутри страницы.

Я вынес её в отдельный:

lead-form.js

и подключил через defer.

Сейчас файл весит примерно:

  • 2 554 B raw;
  • 1 157 B gzip.

defer позволяет браузеру продолжать разбирать HTML, пока JavaScript скачивается в фоне.

Кроме производительности, это просто делает структуру проекта понятнее: одна логика формы может использоваться на разных страницах, а не копироваться в HTML.

Самое интересное изменение — Telegram больше не тормозит форму

Заявки с niki-losk.online приходят мне в Telegram.

Изначально схема выглядела примерно так:

пользователь отправляет форму → Flask принимает заявку → сервер отправляет запрос Telegram → ждёт ответа → отвечает пользователю.

Тест показал, что Telegram-запрос занимал примерно 380 ms.

Причём проблема вообще не в Flask.

Сервер уже сделал основную работу, но продолжал держать Gunicorn worker ради внешнего API.

Я изменил порядок.

Теперь:

  1. Flask принимает заявку.
  2. Заявка сохраняется в SQLite.
  3. Пользователь получает успешный HTTP-ответ.
  4. Telegram-уведомление отправляется отдельно в фоне.
  5. Если Telegram временно недоступен, ошибка фиксируется в логах, но сама заявка уже сохранена.

Для одного небольшого сайта я не стал ради этого поднимать Redis, Celery и отдельную тяжёлую очередь.

Используется простой фоновый daemon thread.

Это как раз тот случай, когда решение должно соответствовать масштабу проекта.

Настроил кеширование

Static-файлы сайта получили длинный кеш:

public, max-age=31536000, immutable

То есть CSS, JS, изображения и другие версионированные ресурсы браузер может хранить до года.

HTML работает иначе.

Его нельзя просто закешировать на год, потому что содержимое страниц может измениться.

Поэтому для публичных HTML-страниц используется:

max-age=300, must-revalidate

То есть короткий кеш примерно на пять минут с проверкой актуальности.

API, административная часть и другие динамические маршруты под эту политику не попадают.

Проверил HTTP/2, gzip и Brotli

На сервере работает HTTP/2.

Gzip также уже включён.

Например, текущий HTML главной весит примерно:

47 522 B raw

после gzip:

около 11 004 B

То есть даже без изменения самого HTML передаваемый объём уменьшается примерно в четыре раза.

Я также рассматривал Brotli.

Но установленный Nginx не содержит нужного Brotli-модуля.

Для подключения пришлось бы менять или пересобирать Nginx.

Ради небольшой дополнительной экономии на уже достаточно лёгком сайте я решил не создавать такой риск.

Gzip остаётся рабочим и нормальным решением.

Убрал 404 favicon

Ещё одна совсем маленькая вещь.

Некоторые браузеры автоматически делают запрос:

/favicon.ico

Даже если favicon уже указан другим способом.

Раньше этот запрос получал 404.

В Nginx я добавил отдельную обработку /favicon.ico на существующий файл.

Теперь запрос возвращает:

  • 200;
  • image/x-icon;
  • immutable cache.

На скорость это влияет минимально, но в серверных логах больше нет бессмысленной ошибки.

Убрал ненужную копию проекта

На корпоративном домене существовал маршрут:

niki-losk.online/dom-puera

Но сам актуальный проект находится здесь:

Дом Пуэра

Мне не нужна была отдельная копия одного и того же проекта на втором домене.

Поэтому теперь .online/dom-puera делает постоянный 301 redirect на актуальный адрес.

Заодно внутренние ссылки были обновлены.

Это уже не столько оптимизация скорости, сколько нормальная архитектура сайта: не нужно поддерживать две одинаковые версии проекта.

Почистил старые static-файлы

После нескольких итераций разработки в проекте остались файлы старых разделов и предыдущих версий.

Перед удалением я проверил, используются ли они текущими маршрутами и шаблонами.

В результате было удалено:

144 файла

Размер папки static уменьшился:

с 69 MB

до 26 MB

То есть из активного static-каталога убрано примерно:

43,6 MiB

При этом перед очисткой был сделан архив для возможности отката.

На скорость главной это почти не влияет напрямую, потому что браузер не скачивает неиспользуемые файлы.

Но проект становится чище, меньше размер резервных копий и проще дальнейшее обслуживание.

Насколько быстрым оказался сам сервер

После всех изменений я отдельно проверил серверную часть.

Nginx сейчас записывает:

  • request_time;
  • upstream_response_time.

По 640 успешным запросам главной получились такие показатели.

Nginx request time

  • median: 4 ms;
  • p95: 7 ms;
  • максимум: 149 ms.

Gunicorn upstream

  • median: 4 ms;
  • p95: 8 ms;
  • максимум: 148 ms.

Прямой HTTPS-запрос непосредственно с самого сервера даёт TTFB около:

11–13 ms

То есть Flask, Gunicorn и Nginx сами по себе работают очень быстро.

Почему тогда с моего компьютера TTFB был больше 600 ms

Вот здесь обнаружился самый интересный момент всего аудита.

Внешний запрос показывал:

  • после прогрева DNS: TTFB примерно 646–653 ms;
  • полный запрос: 774–781 ms;
  • при первом DNS-запросе TTFB доходил примерно до 1,04 секунды.

Сначала можно подумать, что приложение тормозит.

Но внутри сервера Gunicorn отвечает примерно за 4 ms.

Основная задержка находится между пользователем и сервером.

Внешний TTFB включает:

  • DNS;
  • физическое расстояние;
  • установку TCP-соединения;
  • TLS handshake;
  • только после этого работу приложения.

В моём тесте один TLS-этап занимал примерно 390 ms.

Получается важный вывод:

быстрый backend ещё не гарантирует маленький cold TTFB для человека, который физически находится далеко от origin-сервера.

Следующий уровень оптимизации здесь — уже не CSS и не Flask.

Нужен CDN или edge cache, который сможет отдавать публичный контент ближе к пользователю.

Что получилось по весу главной

После оптимизаций текущая главная имеет примерно следующие значения:

  • HTML — 47 522 B raw / 11 004 B gzip;
  • основной CSS — 27 007 B raw / 6 499 B gzip;
  • JavaScript формы — 2 554 B raw / 1 157 B gzip;
  • 12 карточек главной используют responsive images и lazy loading.

Если принудительно скачать все 12 карточек:

desktop-варианты 960 px:

589 748 B

mobile-варианты 480 px:

239 184 B

То есть только responsive images снижают потенциальный объём этих файлов примерно в 2,5 раза на мобильном устройстве.

Но в реальной загрузке браузер не скачивает их все сразу из-за lazy loading.

Что показывает PageSpeed Insights

После завершения основной работы я отдельно проверил сайт через Google PageSpeed Insights.

На мобильной версии результат получился максимальным:

  • Производительность — 100
  • Специальные возможности — 100
  • Рекомендации — 100
  • Поисковая оптимизация — 100

Для меня здесь важнее не сама зелёная цифра.

PageSpeed можно пытаться «проходить» ради теста, но задача была другой: сделать так, чтобы оптимизации имели понятный смысл и для реального пользователя.

Responsive images уменьшают трафик.

Lazy loading не мешает hero.

Critical CSS ускоряет первый экран.

Упрощённые мобильные анимации снижают нагрузку.

Фоновый Telegram освобождает worker.

Кеш ускоряет повторные посещения.

А исправленный overflow просто делает сайт нормальным на телефоне и компьютере.

И уже как итог всех этих изменений PageSpeed Insights показывает максимальные оценки.

Что я бы не стал оптимизировать любой ценой

После этой работы у меня сформировалось несколько простых правил.

Не нужно ломать Nginx ради Brotli

Brotli потенциально дал бы ещё небольшую экономию текстовых файлов.

Но ради этого пришлось бы вмешиваться в рабочую серверную конфигурацию.

Для текущего масштаба сайта выгода не оправдывает риск.

Не нужно удалять CSS только ради красивой цифры

Я уже получил ситуацию, когда bundle стал существенно меньше, но сломался hero.

Лучше стабильные 27 KB, чем 12 KB с неправильным интерфейсом.

Не нужно ставить lazy loading на всё подряд

Hero должен загружаться первым.

Lazy нужен для контента ниже первого экрана.

Не нужно маскировать проблемы верстки

overflow-x: hidden убрал бы полосу прокрутки за секунду.

Но сама ошибка осталась бы внутри.

Не нужна тяжёлая инфраструктура без необходимости

Для фоновой отправки одного Telegram-уведомления я не стал разворачивать отдельный Redis и Celery.

Архитектура должна соответствовать реальной задаче.

Как сейчас устроена загрузка сайта

Если сильно упростить, путь запроса сейчас выглядит так:

Пользователь → HTTPS / HTTP/2 → Nginx → Gunicorn → Flask → HTML

Nginx принимает HTTPS-соединение и занимается TLS, static-файлами, кешированием и проксированием.

Gunicorn работает на локальном:

127.0.0.1:8083

и запускает два sync worker.

Flask формирует HTML через Jinja и при необходимости работает с SQLite.

Для пользователя вся эта серверная часть обычно занимает единицы миллисекунд.

После этого уже начинают играть роль сеть, браузер, изображения, CSS и географическое расположение клиента.

Что дало наибольший эффект

Если выделять изменения, которые я считаю наиболее полезными, я бы расположил их так.

1. Responsive images

Телефон больше не обязан скачивать desktop-картинки.

2. Lazy loading и правильные приоритеты

Первый экран больше не конкурирует с контентом, который пользователь ещё даже не видит.

3. CSS bundling и critical CSS

Браузеру проще быстрее показать hero.

4. Оптимизация формы

Сторонний Telegram API больше не находится в критическом пути ответа пользователю.

5. Кеширование

Повторное посещение требует значительно меньше загрузки ресурсов.

6. Работа с анимациями

Desktop остался визуально живым, а мобильная версия стала легче.

Что ещё можно улучшить

Сейчас главное оставшееся ограничение находится уже за пределами самого приложения.

Origin-сервер физически далеко от части пользователей.

Поэтому следующий серьёзный этап — CDN или edge cache.

Например, публичные HTML-страницы и static можно отдавать через ближайший edge-узел, а запросы форм продолжить отправлять непосредственно на origin.

Это потенциально позволит уменьшить cold TTFB без дальнейшего урезания дизайна сайта.

Но на текущем этапе сама техническая часть сайта уже работает быстро, а PageSpeed Insights подтверждает это максимальными оценками.

Коротко по теме

  • Я не пытался ускорить сайт одной «волшебной» настройкой — результат получился из большого количества небольших изменений.
  • WebP недостаточно: responsive images, srcset и sizes дают реальную экономию мобильного трафика.
  • Hero и изображения ниже первого экрана должны иметь разные стратегии загрузки.
  • Flask и Gunicorn оказались быстрыми — основная задержка холодного внешнего запроса связана с сетью и географией сервера.
  • После оптимизации мобильная проверка PageSpeed Insights показывает 100 баллов по всем четырём основным категориям.

Нужен быстрый сайт для бизнеса?

Я занимаюсь разработкой сайтов и стараюсь смотреть не только на то, как страница выглядит, но и на то, как она работает после запуска.

Можно посмотреть мои услуги и кейсы.

Если вам нужен сайт, который нормально работает на компьютере и телефоне, оставьте заявку. Я посмотрю задачу и предложу понятный следующий шаг.

Продолжить чтение