Полгода назад я закрывал технический долг по производительности на клиентском проекте. Обычная история: интернет‑магазин, мобильный Performance в PageSpeed — 29, владелец каждую неделю спрашивает, когда будет зелёная зона. Мы сделали всё, что делают в таких случаях. Перегнали изображения в WebP и AVIF, вынесли критический CSS в инлайн, убрали три подключения Google Fonts на self‑host, порезали неиспользуемый JavaScript, настроили кэширование на уровне Nginx, подключили CDN. Работы на два месяца.

Мобильный Performance стал 31.

Я открыл трейс в DevTools, развернул Main thread и минут пять просто смотрел. Больше половины длинных задач на главном потоке не имели никакого отношения к сайту. Это был чат.

Дальше начинается то, ради чего написана статья. Я задал себе вопрос, который до этого не задавал ни разу за десять лет работы: а сколько вообще стоит чат на сайте? Не в рублях в месяц — в миллисекундах. И обнаружил, что честного ответа нет ни у кого. Вендоры публикуют вес загрузочного скрипта, обычно 8–15 КБ, и это правда ровно настолько, насколько правдива фраза «билет на самолёт стоит 40 долларов» без учёта багажа, сборов и трансфера. Lighthouse показывает общий счёт по странице и не выставляет его отдельным подрядчикам. Владелец сайта видит красную цифру и не знает, чья она.

Поэтому я посчитал сам. Ниже — метод, который позволяет это сделать воспроизводимо, что из него получилось, и как в итоге выглядит архитектура виджета, который не съедает главный поток.

Почему цену чата нельзя просто посмотреть

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

Отчёт «Third‑party usage» в Lighthouse ближе всего к решению и всё равно не решает задачу. Он показывает, сколько байт и сколько времени главного потока приходится на каждого стороннего подрядчика, но не отвечает на вопрос, что случится с метриками, если подрядчика убрать. Между «виджет занял 900 мс главного потока» и «без виджета TBT упадёт на 900 мс» лежит пропасть: задачи перераспределяются, ресурсы освобождаются, очередь разгружается неравномерно.

Второе препятствие тоньше. Метрика, которая реально портится от чата, в лаборатории не измеряется вообще. С марта 2024 года Core Web Vital, отвечающий за отзывчивость, — это INP, Interaction to Next Paint. INP измеряет задержку между действием пользователя и следующей отрисовкой, то есть требует настоящего взаимодействия настоящего человека. В лабораторном прогоне взаимодействия нет. Lighthouse отдаёт вместо него TBT, Total Blocking Time — суммарное время длинных задач сверх 50 мс за период загрузки. Между TBT и INP есть связь, но нет равенства, и любой разговор о влиянии виджета на отзывчивость обязан начинаться с этой оговорки.

Отсюда конструкция метода: если мы не можем разобрать общий счёт на составляющие, мы можем получить два счёта и вычесть один из другого.

Метод: двойной прогон с блокировкой

Lighthouse умеет блокировать запросы по маске — флаг ‑blocked‑url‑patterns. Это открывает прямую дорогу к контрфактическому замеру.

Для каждого URL прогон выполняется дважды в идентичных условиях. Первый раз — страница как есть. Второй раз — та же страница, но домены чат‑виджета заблокированы. Разница между прогонами и есть цена виджета на этой конкретной странице. Не оценка, не экстраполяция веса скрипта, а измеренная дельта.

Три детали, без которых метод даёт красивые и неверные числа.

Медиана обязательна. Lighthouse шумит, особенно на слабой машине и на страницах с рекламой. Разброс между двумя подряд идущими прогонами одного и того же URL легко достигает пары сотен миллисекунд TBT, а это порядок измеряемого эффекта на лёгких сайтах. Минимум три прогона на конфигурацию, медиана, а не среднее — среднее вытягивает единственный выброс.

Блокировка задевает не только чат. Если вендор раздаёт ассеты с общего CDN, где лежит что‑то ещё, маска срубит лишнее и дельта окажется завышенной. Проверяется диффом списка сетевых запросов между прогонами: всё, что исчезло и не принадлежит виджету, — повод сузить маску.

Блокировка домена не равна отсутствию виджета. Если чат вшит в основной бандл сайта или проксируется через собственный домен, метод его просто не увидит. Это систематически смещает любую выборку в сторону готовых SaaS‑решений, и при публикации данных об этом надо говорить прямо.

Скрипт, который делает всё перечисленное, получился на полторы сотни строк. Ядро — определение виджета по сетевым запросам:

const SIGNATURES = [
  { id: 'jivo',    name: 'Jivo',    patterns: ['*jivosite.com*', '*jivo.ru*'] },
  { id: 'crisp',   name: 'Crisp',   patterns: ['*crisp.chat*'] },
  { id: 'tawk',    name: 'Tawk.to', patterns: ['*tawk.to*'] },
  { id: 'chatra',  name: 'Chatra',  patterns: ['*chatra.io*', '*chatra.com*'] },
  // ...
];


function detectWidget(lhr) {
  const items = lhr.audits['network-requests']?.details?.items
[];
  const urls = items.map((i) => i.url);
  const hits = [];
  for (const sig of SIGNATURES) {
    const res = sig.patterns.map(globToRegExp);
    const matched = urls.filter((u) => res.some((r) => r.test(u)));
    if (!matched.length) continue;
    const bytes = items
      .filter((i) => res.some((r) => r.test(i.url)))
      .reduce((s, i) => s + (i.transferSize
0), 0);
    hits.push({ ...sig, requests: matched.length, bytes });
  }
  return hits.sort((a, b) => b.bytes - a.bytes);
}

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

Сам замер выглядит так:

async function measure(url, outDir) {
  const base = [];
  for (let i = 0; i < RUNS; i++) base.push(await runLighthouse(url, outDir));

  const widgets = detectWidget(base[0]);
  if (!widgets.length) return { url, widget: null, baseline: collect(base) };

  const w = widgets[0];
  const blocked = [];
  for (let i = 0; i < RUNS; i++) blocked.push(await runLighthouse(url, outDir, w.patterns));

  const b = collect(base);
  const a = collect(blocked);
  const delta = {};
  for (const k of Object.keys(METRICS)) delta[k] = b[k] - a[k];

  return { url, widget: w, baseline: b, withoutWidget: a, delta };
}

Что показали первые замеры

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

Два прогона, мобильный форм‑фактор, стандартный троттлинг Lighthouse:

Сайт

Виджет

Вес виджета

Запросов

ΔLCP

ΔTBT

ΔCLS

Performance с виджетом → без

tawk.to

Tawk.to

3095 КБ

150

7 мс

2498 мс

0.004

29 → 58

crisp.chat

Crisp

1092 КБ

105

970 мс

12 954 мс

0.000

60 → 92

Сразу оговорюсь про статус этих чисел, потому что дальше на них строятся выводы. Это два наблюдения, по одному прогону на конфигурацию, на маркетинговых сайтах вендоров, которые грузят собственный продукт в демонстрационном режиме и наверняка тяжелее среднего клиента. Как выборка это не значит ничего. Единственное, что здесь показано, — метод даёт разделяемый сигнал, а порядок величины таков, что списать его с шумом нельзя.

И этого достаточно, чтобы увидеть главное.

Основная цена чата лежит не в LCP, а в TBT. Посмотрите на первую строку: разница в отрисовке крупнейшего элемента — семь миллисекунд, статистический ноль. Разница в блокировке главного потока — две с половиной секунды. Виджет не мешает странице выглядеть загруженной. Он мешает ей отвечать.

Это ровно та поломка, которую пользователь чувствует каждым касанием, а владелец сайта в отчёте не видит, потому что смотрит на скорость загрузки. Страница отрисовалась, картинки на месте, всё выглядит готовым — а тап по фильтру каталога отрабатывает через полторы секунды. Человек тапает второй раз, получает двойное срабатывание, закрывает вкладку. В аналитике это отказ с хорошими показателями загрузки, и никто никогда не свяжет его с чатом.

Второе, что бросается в глаза, — вес. Три мегабайта и сто пятьдесят запросов на «виджет чата» — это не опечатка. Загрузочный скрипт весом 12 КБ, который честно указан в документации, тянет ядро, ядро тянет UI, UI тянет шрифты, иконочный набор, эмодзи‑спрайты, аватарки операторов и звук уведомления. Каждое звено — отдельная цепочка DNS, TLS и RTT к чужому домену, и preconnect лечит только первое звено.

Как выглядит виджет, который столько не стоит

После этих замеров я переписал клиентский виджет с нуля. Ниже — решения, которые дали основной эффект. Ни одно из них не новое, вместе они дают виджет, который на слабом Android не блокирует поток дольше нескольких десятков миллисекунд до момента, когда пользователь сам захотел написать.

Facade вместо виджета

Ключевое наблюдение: подавляющее большинство посетителей никогда не откроет чат. Они платят за загрузку интерфейса, историей сообщений, сокетом и аватарками ради кнопки, по которой не нажмут. Значит, до нажатия ничего этого грузить не нужно.

На странице живёт статичная кнопка — размеченный HTML и немного CSS, никакого JavaScript в критическом пути. Реальный виджет монтируется по намерению: наведение курсора, касание, или простой таймер на холостом ходу, если хочется, чтобы к моменту клика всё уже было готово.

const facade = document.getElementById('chat-facade');
let mounted = false;

function mount() {
  if (mounted) return;
  mounted = true;
  const s = document.createElement('script');
  s.src = 'https://cdn.example.com/chat/widget.js';
  s.async = true;
  document.head.appendChild(s);
}

facade.addEventListener('pointerenter', mount, { once: true });
facade.addEventListener('click', mount, { once: true });

// Догрузка на холостом ходу, чтобы клик не ждал сети.
// requestIdleCallback появился в Safari поздно — нужен фолбэк.
if ('requestIdleCallback' in window) {
  requestIdleCallback(mount, { timeout: 8000 });
} else {
  setTimeout(mount, 8000);
}

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

Резервирование места

Кнопка, всплывающая на второй секунде, — это вклад в CLS и раздражение. Лечится тем, что место под неё резервируется на этапе вёрстки: фиксированные размеры контейнера, position: fixed, никакого влияния на поток документа. То же касается подмены телефонного номера, если на сайте есть коллтрекинг, — там сдвиг возникает из‑за разной ширины строки:

<a class="tel" data-ct href="tel:+375170000000"
   style="display:inline-block;min-width:11ch">
  +375 (17) 000-00-00
</a>

min‑width в ch резервирует ширину под формат номера заранее. Подмена после этого не двигает ничего.

Бюджет вместо «поставим и посмотрим»

Самое скучное и самое действенное решение. До установки любого стороннего кода фиксируется бюджет: столько‑то килобайт передачи и столько‑то миллисекунд TBT на весь сторонний JavaScript вместе взятый. Виджет, который не влезает, не ставится — или ставится в facade‑обёртке.

Бюджет проверяется в CI тем же Lighthouse, и это превращает разговор с заказчиком из спора о вкусах в проверку числа. Аргумент «чат нужен для конверсии» перестаёт работать против аргумента «этот чат стоит две секунды отзывчивости», когда обе стороны видят замер.

Self‑host там, где это возможно

Каждый сторонний домен в критическом пути — это дополнительный DNS‑lookup, дополнительное TLS‑рукопожатие и RTT, который на мобильной сети легко превращается в сотни миллисекунд до первого байта. Часть ассетов виджета — шрифт, иконки, стили — прекрасно живёт на собственном домене. Остаётся сокет и API, но это уже происходит после того, как пользователь открыл чат, и на метрики загрузки не влияет.

Отдельная история: чат как источник данных

Второе, ради чего стоит держать чат на сайте, — не переписка, а атрибуция. Это единственная точка, где посетитель добровольно идентифицирует себя, не покидая сессию. Звонок эту связь рвёт: человек ушёл в телефон, и для аналитики это два несвязанных события. Форма сжимает всё в одно событие без контекста.

Здесь важно сразу обозначить границу возможного, потому что вокруг неё много маркетингового вранья.

Связать сделку с конкретным поисковым запросом детерминированно нельзя. Google перевёл поиск на HTTPS ещё в начале десятых, и запрос перестал приходить в document.referrer — это архитектурное решение, а не ограничение конкретной системы аналитики. С тех пор стало только хуже: дефолтная политика реферера в Chrome, strict‑origin‑when‑cross‑origin, отдаёт при кросс‑доменном переходе один только origin. Никакой клиентский код не восстановит то, чего браузер не отправил.

Что можно построить — это вероятностную привязку. По связке «посадочная страница + дата + устройство + страна» данные Search Console сопоставляются с сессиями, и получается распределение вероятных запросов, а не один запрос. Разница принципиальная, и если инструмент рисует вам в отчёте «эта сделка пришла по запросу X» — он либо угадывает, либо врёт.

Идентификатор посетителя должен быть first‑party и серверный. Safari режет куки, выставленные из JavaScript через document.cookie, до семи дней. Для B2B‑цикла в три‑четыре недели это означает потерю первого касания ровно на том сегменте, который приносит деньги. Кука, выставленная сервером со своего домена заголовком Set‑Cookie, живёт по другим правилам. Если виджет подключается со стороннего домена, спасает CNAME на собственный поддомен.

Контекст должен приезжать оператору до первого сообщения. Страница входа, текущая страница, источник, номер визита, устройство, время до первого сообщения — всё это прикрепляется к диалогу автоматически. Оператор, который видит, что человек третий раз за неделю читает страницу с ценами, ведёт разговор иначе, чем оператор, который видит только «Здравствуйте».

Куда девать лид: про интеграции и их отсутствие

Требование «работает без интеграции» звучит как маркетинговый лозунг, но за ним стоит трезвая экономика. Связка «чат → CRM» — это не галочка в настройках. Это вебхук, маппинг полей, дедупликация, ретраи, идемпотентность и обработка случая, когда CRM отвечает пятисоткой. Для команды из трёх человек это неделя работы, которой нет, и стоит она дороже самого инструмента.

Поэтому разумных сценариев два. Либо лид никуда не уезжает и живёт в примитивной, но работающей воронке внутри самого инструмента. Либо наружу уходит плоское событие с гарантией доставки:

{
  "event": "lead.created",
  "id": "ld_01J8XM4T2K",
  "source": { "medium": "organic", "landing": "/catalog/example/" },
  "contact": { "phone": "+375...", "name": "..." },
  "touch_type": "chat",
  "created_at": "2026-07-28T11:42:03Z"
}

Заголовок Idempotency‑Key, равный id, снимает проблему дублей при ретраях, экспоненциальная пауза — проблему лежащего приёмника. Это тридцать строк на стороне отправителя и ноль настроек на стороне клиента.

Чего этот метод не доказывает

Раздел, без которого статья была бы нечестной.

Двойной прогон измеряет лабораторную дельту, а не полевую. Троттлинг Lighthouse — симуляция медленной сети и слабого процессора, и абсолютные значения не равны тому, что видят живые пользователи. Для сравнения «с виджетом и без» на одной машине метод корректен, для утверждений вида «у вас будет вот такой INP» — нет. Полевые данные берутся отдельно, из CrUX.

Одна страница не равна сайту. Цена виджета на главной и на карточке товара разная, потому что разный объём собственного JavaScript, за который виджет конкурирует.

Два наблюдения не равны исследованию. Всё, что написано выше про порядок величины, справедливо для двух конкретных сайтов в конкретный день. Чтобы говорить о вендорах, нужна выборка в сотню‑другую URL, три прогона на конфигурацию и публикация сырых данных. Этим я и занят; когда датасет будет собран, он будет выложен целиком, вместе с методикой и списком сайтов, чтобы любой мог перепроверить.

Чек‑лист

Что стоит сделать до того, как ставить на сайт очередной сторонний виджет:

1. Зафиксировать бюджет на сторонний JavaScript в килобайтах и миллисекундах TBT.

2. Замерить страницу до установки — три прогона, медиана.

3. Установить виджет и замерить снова. Не «посмотреть в PageSpeed», а сравнить медианы.

4. Если дельта TBT больше сотни миллисекунд — заворачивать в facade.

5. Зарезервировать место под кнопку и под подменяемый телефон.

6. Вынести всё, что можно, на собственный домен.

7. Проверить, что идентификатор посетителя ставится сервером, а не из JavaScript.

8. Поставить проверку бюджета в CI, иначе через три релиза всё вернётся.

Скрипт для двойного прогона я выложил в открытый доступ — он небольшой, зависит только от Lighthouse и headless‑браузера, и запускается по списку URL из текстового файла. Если у вас есть свой список сайтов, интересно будет сравнить результаты.


Часть описанного собрана с источника seochat.by — на нём я это и обкатывал.