
Про HTTP/3 написано примерно одно и то же: QUIC решает проблему head-of-line blocking, соединение переживает смену сети, рукопожатие укладывается в один раунд. Всё это правда. Непонятно другое — сколько из этого достаётся конкретной странице, и в каких случаях не достаётся ничего.
Внятного ответа я не нашёл. Измерения в интернете противоречат друг другу: одни показывают выигрыш HTTP/3 везде и всегда, другие — что разницы нет, а на потерях выгода не подтверждается вовсе. Так что я решил померить сам.
Собрал стенд: сервер с HTTP/1.1, HTTP/2 и HTTP/3 одновременно, эмулятор канала на уровне IP-пакетов и настоящий Chromium в роли клиента. Прогнал шесть профилей сети — от датацентра до края соты с пятью процентами потерь — на трёх типах нагрузки. Получилось 54 серии замеров.
Короткий ответ: переход с HTTP/1.1 на HTTP/2 дал двукратный выигрыш на странице с полусотней файлов и ровно ноль на скачивании одного большого. HTTP/3 на этом стенде выиграл 12% на десяти файлах при большом RTT — и проиграл HTTP/2 втрое на пятидесяти. А на трёх мегабайтах с однопроцентными потерями отстал в девять раз.
Дальше — как я это мерил, почему получилось именно так, и в каком месте я сам чуть не опубликовал ерунду.
❯ Почему нельзя просто взять и померить
Первый подход был лобовым: curl --http1.1 / --http2 в цикле, сравнить time_total, взять среднее. Такой замер собирается за пять минут, и на нём построена половина сравнений в интернете. Он меряет установку соединения плюс передачу одного объекта по петлевому интерфейсу — то есть величину, которая с временем загрузки страницы связана слабо. Отсюда и расхождение выводов: при RTT около нуля разница между протоколами лежит внутри погрешности, и знак результата определяется тем, какой прогон попал в выборку.
Проблем три.
Клиент должен быть браузером. Различие не в удобстве, а в модели загрузки.
На HTTP/1.1 браузер держит до шести TCP-соединений на источник и раскладывает по ним очередь запросов; curl по умолчанию работает в одно соединение и последовательно. Уже здесь замеры расходятся в разы, причём в пользу curl — он не показывает того параллелизма, ради которого HTTP/1.1 вообще живёт.
Дальше — приоритизация. Браузер строит дерево зависимостей: блокирующий рендер CSS идёт раньше картинок ниже сгиба, preload scanner вытаскивает ссылки из HTML до того, как парсер до них дошёл. В HTTP/2 это выражается в дереве приоритетов, в HTTP/3 — в схеме Extensible Priorities. curl не приоритизирует ничего, потому что ему нечего приоритизировать: у него нет DOM, нет критического пути рендеринга и нет понятия «страница отрисовалась».
И техническое ограничение: HTTP/3 в стандартной сборке curl недоступен — нужна сборка с ngtcp2, quiche или msh3. На большинстве машин curl --http3 просто вернёт ошибку.
Отсюда выбор клиента: Chromium под управлением Playwright, а метрика — loadEventEnd из Navigation Timing, то есть момент, когда страница действительно загрузилась.
Сеть должна быть плохой. Вся разница между протоколами измеряется в сэкономленных раундтрипах, а на петлевом интерфейсе раундтрип почти ничего не стоит.
Считаем, что вообще экономится. Установка соединения по TCP с TLS 1.3 — это один раунд на SYN/SYN-ACK плюс один на ClientHello/ServerHello, итого два до первого байта запроса. QUIC совмещает транспортное и криптографическое рукопожатие и укладывается в один; при возобновлении сессии с 0-RTT данные уходят вместе с первым пакетом. Экономия — от одного до двух RTT на соединение.
Теперь подставим числа. На lo время оборота порядка 0,05 мс, значит вся экономия составляет около 0,1 мс — она тонет в разбросе между прогонами. При RTT 150 мс та же экономия превращается в 150–300 мс, то есть в заметную долю загрузки. Плюс на петлевом интерфейсе MTU равен 65536 байт против типичных 1500: сегментация, а вместе с ней и вся механика окна перегрузки ведут себя не так, как в реальной сети.
Поэтому «у меня на ноутбуке HTTP/3 быстрее» — не результат. Это замер величины, которая на локальной машине физически не может проявиться.
Потери должны быть настоящими. Здесь и оказалась главная сложность.
Прокси на сокетах не годится принципиально. Он терминирует соединение: со стороны клиента одно TCP-соединение, со стороны сервера другое, между ними — копирование байтов в пространстве пользователя. Отбросить в такой схеме «пакет» нельзя — можно только не переслать кусок потока, а это для TCP не потеря, а разрыв.
Важнее другое: блокировка головы очереди в HTTP/2 происходит не в HTTP/2. Она происходит в ядре, на уровне TCP. Потерянный сегмент останавливает выдачу в сокет всех последующих сегментов, которые уже пришли и лежат в буфере приёма, — потому что TCP обязан отдать приложению непрерывный поток. Мультиплексированные поверх него стримы встают все разом, хотя данные девяти из десяти уже физически на машине. Прокси, работающий с сокетом, этого не воспроизведёт: он видит поток уже после того, как ядро его собрало.
QUIC устроен иначе: у каждого потока своя нумерация, и потерянный пакет блокирует только те потоки, чьи данные в нём ехали. Остальные продолжают доставляться.
Ровно эту разницу и надо было измерить — а значит, работать пришлось на уровне IP-пакетов, до того как ядро соберёт из них поток.
Так что стенд получился злее, чем я рассчитывал.
❯ Стенд
Сервер — Caddy 2.8.4, он отдаёт все три протокола на одном порту сразу. Клиент — Chromium 141 под управлением Playwright, протокол форсируется флагами запуска.
const EXPECT = { h1: ['http/1.1'], h2: ['h2'], h3: ['h3', 'h3-29'] }; // ... const seen = [...new Set([d.navProto, ...Object.keys(d.protos)])].filter(Boolean); const ok = seen.length > 0 && seen.every((p) => EXPECT[proto].includes(p));
Каждый ресурс отдаёт свой nextHopProtocol через Resource Timing API. Если браузер молча съехал на другой протокол — а он это делает охотно, — прогон помечается негодным и не попадает в статистику. Без этой проверки очень легко получить сравнение HTTP/2 с HTTP/2 под видом сравнения с HTTP/3.
Эмулятор канала пришлось писать самому. В контейнере не оказалось tc, а sch_netem не вкомпилирован в ядро — модулей там нет вообще. Зато нашёлся /dev/net/tun, и это открывало дорогу к настоящим IP-пакетам.
Схема простая. Поднимаем TUN-интерфейс с адресом 10.9.0.1, на нём же слушает сервер. Клиент стучится на 10.9.0.2 — адрес, который никому не назначен, поэтому пакет уходит в TUN. Мой процесс его читает, задерживает или выбрасывает, переписывает адреса и отдаёт обратно в ядро:
if dst == b_vsrv: # клиент → сервер: прячем клиента за VIRT_CLI, целимся в реальный адрес to_srv.push(rewrite(pkt, VIRT_CLI, REAL)) elif dst == b_vcli: # сервер → клиент: возвращаем адреса на место to_cli.push(rewrite(pkt, VIRT_SRV, REAL))
Задержка и потери применяются к каждому пакету в обе стороны одинаково — TCP и QUIC оказываются в равных условиях. Именно этого не даёт прокси на сокетах.

Шесть профилей сети, от честного датацентра до сотового края:
Профиль | RTT | Джиттер | Потери |
Датацентр | 0 мс | 0 | 0% |
Проводной Интернет | 40 мс | 5 мс | 0% |
Другой континент | 150 мс | 10 мс | 0% |
Мобильный | 60 мс | 15 мс | 1% |
Плохой мобильный | 60 мс | 20 мс | 3% |
Край соты | 100 мс | 25 мс | 5% |
Три сценария нагрузки: страница с 10 файлами (80 КБ), страница с 50 файлами (421 КБ) и скачивание одного файла на 3 МБ.
❯ Как стенд едва не наврал
Первая версия эмулятора спала по паре миллисекунд и выпускала накопившиеся пакеты пачками. Логично и просто. И — неверно.
Я это заметил не сразу, а когда полез считать байты. На одной и той же странице HTTP/3 прогонял через провод вдвое больше данных, чем HTTP/2: 908 КБ против 448 КБ при полезной нагрузке в 421 КБ. Столько ретрансмитов на канале без потерь взяться было неоткуда.
Причина оказалась в самом стенде. TCP работает в ядре, и всплеск пакетов ему сглаживает сетевой стек. QUIC живёт в userspace со своими таймерами: для него пачка из двадцати пакетов, пришедшая разом, выглядит как перегрузка канала — и он начинает ретранслировать. Мой эмулятор душил ровно тот протокол, который я собирался хвалить.
Переписал выдачу: теперь каждый пакет уходит в свой момент, грубое ожидание одним вызовом плюс короткое докручивание активным циклом.
gap = due - time.perf_counter() if gap > 0.0004: time.sleep(gap - 0.0003) while time.perf_counter() < due: pass
Стало точнее. А разрыв между протоколами почти не изменился — и вот это уже было интересно, потому что означало: дело не в стенде.
Мораль, которую я вынес: если вы меряете сеть, сначала померьте свой измеритель. Я поймал это только потому, что считал байты, а не только секунды.
❯ HTTP/1.1 → HTTP/2: выигрыш есть, и он большой

Начнём с того, что работает. Пятьдесят мелких файлов — типичный результат сборки фронтенда, где каждый чанк лежит отдельно:
Профиль | HTTP/1.1 | HTTP/2 | Выигрыш |
Датацентр | 199 мс | 161 мс | 19% |
Проводной | 589 мс | 321 мс | 45% |
Другой континент | 2018 мс | 1034 мс | 49% |
Мобильный | 1027 мс | 541 мс | 47% |
Плохой мобильный | 1349 мс | 679 мс | 50% |
Край соты | 2289 мс | 1209 мс | 47% |
Двукратная разница на всех профилях, кроме локального. Причина известна: HTTP/1.1 открывает шесть соединений на домен и гоняет по ним запросы по очереди. Пятьдесят файлов в шесть очередей — это девять последовательных раундтрипов только на ожидание. При RTT 150 мс это полторы секунды чистого простоя.
HTTP/2 мультиплексирует всё в одно соединение: запросы уходят разом, ответы возвращаются вперемешку. Пятьдесят файлов стоят почти столько же, сколько один.
Вот здесь стоит остановиться, по данным Cloudflare, HTTP/2 держит около 64% всех запросов потому что это самый практичный вывод статьи. Основной выигрыш от современных протоколов вы получаете при переходе на HTTP/2. Он у вас, скорее всего, уже включён — по данным Cloudflare, HTTP/2 держит около 64% всех запросов, HTTP/1.1 сжался до 9%. Всё, что дальше, — это борьба за проценты поверх уже полученных пятидесяти.
❯ Когда HTTP/2 не даёт ничего

Теперь тот же тест, но вместо пятидесяти файлов — один на 3 МБ:
Профиль | HTTP/1.1 | HTTP/2 | Разница |
Проводной | 378 мс | 378 мс | 0% |
Другой континент | 1296 мс | 1272 мс | 2% |
Мобильный | 841 мс | 820 мс | 2% |
Плохой мобильный | 1059 мс | 1161 мс | -10% |
Ноль. На плохом мобильном HTTP/2 даже чуть медленнее — в пределах разброса, но выигрыша нет точно.
Это не дефект, это арифметика. Мультиплексирование ускоряет параллельные запросы. Когда запрос один, мультиплексировать нечего: и там и там — одно TCP-соединение, один TLS, один поток байтов. HTTP/2 добавляет к нему обрамление кадрами и накладные расходы, и ровно ничего не ускоряет.
Отсюда практическое следствие, которое противоречит привычному «оптимизируем всё»:
API, отдающий один JSON на запрос, от HTTP/2 не выиграет ничего. Выиграет от переиспользования соединений, но это и в HTTP/1.1 работает через keep-alive.
Раздача видео, архивов, крупных бандлов — тот же ноль.
Страница с десятками мелких ресурсов — вот тут выигрыш вдвое.
И зеркальное следствие: шардинг по доменам, который в эпоху HTTP/1.1 был обязательной оптимизацией, при HTTP/2 работает против вас. Каждый лишний домен — это отдельное TCP-соединение, отдельное TLS-рукопожатие и потерянное мультиплексирование. Если у вас в проекте до сих пор живёт static1.example.com и static2.example.com — их пора схлопнуть.
❯ HTTP/3: где обещанное сбывается

Теория говорит: QUIC даёт рукопожатие за один раунд вместо трёх, а потеря пакета в одном потоке не тормозит остальные. Значит, выигрыш должен расти с задержкой и с потерями.
На странице из десяти файлов первая половина обещания сбывается ровно:
Профиль | HTTP/1.1 | HTTP/2 | Разница |
Датацентр | 54 мс | 70 мс | +30 (хуже) |
Проводной, RTT 40 | 192 мс | 168 мс | -12% |
Другой континент RTT 150 | 643 мс | 562 мс | -13% |
Мобильный, потери 1% | 294 мс | 296 мс | +1% |
Плохой мобильный, 3% | 330 мс | 339 мс | +2% |
Чем дальше сервер, тем лучше HTTP/3: минус 12–13% на дистанции. Это честный выигрыш, и он ровно там, где обещан.
А вот в датацентре HTTP/3 проигрывает на треть. Это тоже логично: при нулевом RTT экономить нечего, а QUIC живёт в пространстве пользователя и платит за это процессором. TCP обрабатывается ядром с аппаратными разгрузками, которых у UDP нет. Исследование на десятигигабитных каналах даёт ту же картину в цифрах: TCP с TLS выдаёт 8010 Мбит/с, лучшие реализации QUIC — около 3000 Мбит/с.
Если ваши клиенты — это соседние сервисы внутри одного датацентра, HTTP/3 вам не нужен. Он будет медленнее.
❯ Где обещанное не сбылось

Теперь неприятная часть. Та же самая разница протоколов, но на странице с пятьюдесятью файлами:
Профиль | HTTP/2 | HTTP/3 | Разница |
Проводной | 321 мс | 500 мс | +56% |
Другой континент | 1034 мс | 1633 мс | +58% |
Мобильный, потери 1% | 541 мс | 1145 мс | +112% |
Плохой мобильный, 3% | 679 мс | 1869 мс | +175% |
Край соты, потери 5% | 1209 мс | 3914 мс | +224% |
Те же протоколы, тот же стенд, тот же объём данных. Разница только в числе файлов — и HTTP/3 из победителя превратился в аутсайдера, отстающего втрое.
Решил, что стенд врёт. Полез разбираться и вытащил из Resource Timing разбивку по фазам. Вот она, и она всё объясняет:
Метрика Resource Timing | HTTP/2 | HTTP/3 |
| 162 мс | 41 мс |
| 17 мс | 181 мс |
| 20 мс | 364 мс |
Активных запросов в середине окна загрузки | 44 | 29 |
Последняя строка — конкурентность: сколько ресурсов имели незакрытый интервал [startTime, responseEnd] в момент, делящий окно загрузки пополам. Для HTTP/2 это 44 запроса из 50, то есть почти вся страница качается одновременно. Для HTTP/3 — 29, и остальные ждут своей очереди.
Вторая строка в терминах Chrome DevTools называется Stalled или Queueing: интервал между тем, как загрузчик поставил запрос в очередь, и тем, как первый байт запроса ушёл в сокет. Сетевого времени там нет вообще — это чистое ожидание внутри клиента.
Складываем картину. QUIC отвечает вчетверо быстрее: 41 мс против 162, то есть один раунд вместо четырёх — транспорт отрабатывает ровно так, как обещано в спецификации.
Но до транспорта запросы не доходят. Они простаивают в очереди по 181 мс, в десять раз дольше, чем на HTTP/2, а в хвосте распределения — 364 мс. Выигрыш в один раунд оплачен пятью раундами ожидания перед отправкой. Похоже на исчерпание лимита одновременных потоков: браузер упирается в разрешённое сервером число и ждёт кадра MAX_STREAMS, чтобы открыть следующий.
Проверил на страницах разного размера, чтобы поймать порог:
Файлов на странице | HTTP/2, ожидание отправки | HTTP/3, ожидание отправки |
10 | 8 мс | 9 мс |
20 | 13 мс | 71 мс |
30 | 16 мс | 115 мс |
50 | 16 мс | 186 мс |
У HTTP/2 очередь не растёт вообще. У HTTP/3 растёт линейно с числом файлов.
Дальше я проверил, дело в объёме данных или в числе потоков: сделал страницу из тех же пятидесяти файлов, но по 900 байт каждый — 45 КБ вместо 421. Задержка осталась на месте, 191 мс. Значит, упирается не в байты, а в количество одновременных потоков.
И главное: этот эффект воспроизвёлся при замере в обход эмулятора, напрямую через ядро. Стенд его усиливает пропорционально RTT — что физически правильно, — но не создаёт.
Итого: на странице с большим числом мелких ресурсов HTTP/3 в связке Chromium с Caddy упирается в лимит одновременных потоков там, где HTTP/2 не упирается. Выигрыш от быстрого транспорта съедается очередью на входе.
❯ Три мегабайта: полный провал
Самое резкое расхождение — на большом файле в плохой сети:
Профиль | HTTP/2 | HTTP/3 |
Мобильный, потери 1% | 820 мс | 7517 мс |
Плохой мобильный, потери 3% | 1161 мс | 14923 мс |
Край соты, потери 5% | 1840 мс | не дошёл за 2 минуты |
Разница почти в девять раз, а на пяти процентах потерь HTTP/3 не доехал вовсе.
Я был уверен, что это мой стенд захлебнулся на UDP. Посчитал пакеты — и получил обратное:
h2: 4514 пакетов, 6.4 МБ передано, 1068 мс h3: 2417 пакетов, 3.3 МБ передано, 7561 мс
HTTP/3 передал ровно объём файла, почти без ретрансмитов. HTTP/2 прогнал вдвое больше байтов — то есть переотправлял потерянное куда активнее — и всё равно закончил в семь раз быстрее.
Значит, дело не в потерях как таковых. 3.3 МБ за 7.5 секунды при RTT 60 мс — это окно перегрузки размером примерно в 26 КБ, которое так и не выросло. Ядерный TCP на тех же потерях разгоняется и держит скорость, а реализация QUIC зажимает окно и не восстанавливается.
Здесь важна оговорка, и я на ней настаиваю: это поведение конкретной библиотеки — quic-go, на которой работает Caddy. У Cloudflare своя реализация, у Google своя, у nginx третья. Мой результат говорит «QUIC-стек в этом сервере плохо разгоняется на потерях», а не «QUIC плохой».
❯ Почему в интернете такой разброс данных
Пока собирал материал, наткнулся на прямо противоречащие друг другу измерения — и теперь понимаю, почему.
Академическая работа, где прогнали тысячи сайтов, даёт осторожную картину: в обычных условиях разницы нет, примерно половина сайтов быстрее на HTTP/3, половина на HTTP/2. При добавленной задержке в 200 мс быстрее на HTTP/3 уже 81% сайтов. А про потери там прямая формулировка: выигрыш HTTP/3 в сценариях с высокими потерями не подтверждается.
Замеры Request Metrics дают противоположное: HTTP/3 быстрее во всех точках, и чем дальше сервер, тем сильнее — до 1200 мс выигрыша при загрузке из Лондона.
Оба измерения, скорее всего, корректны. Просто они меряли разные вещи: разный сервер, разные реализации QUIC, разное число ресурсов на странице. Мои данные говорят, что число ресурсов и тип нагрузки меняют знак результата — не величину, а именно знак.
Вывод, которым я закончу техническую часть, такой: вопрос «что быстрее, HTTP/2 или HTTP/3» не имеет ответа в отрыве от вашей нагрузки и вашего сервера. Любая статья, которая отвечает на него одним числом, включая эту, описывает свой стенд, а не вашу систему.
❯ Что с этим делать на практике
Включите HTTP/2, если ещё нет. Это главный выигрыш: до двух раз на странице с множеством ресурсов. Всё остальное в статье — борьба за проценты поверх него.
Уберите шардинг по доменам. При HTTP/2 он вредит: лишние соединения и рукопожатия вместо мультиплексирования.
Не ждите чуда от HTTP/2 на одиночных больших ответах. API с одним JSON, отдача видео, скачивание архивов — там выигрыша нет и не будет.
HTTP/3 включайте, если ваши пользователи далеко. Минус 12–13% на дистанции — это реальные цифры, и они стоят одной строчки в конфиге. Если вся аудитория в соседнем датацентре, не включайте: будет медленнее.
Померьте сами, если у вас много мелких ресурсов. Именно тут мой стенд показал, что HTTP/3 может проиграть втрое. Ваша связка сервера и клиента может вести себя иначе — но это надо проверить, а не предположить.
Не оставляйте HTTP/3 без присмотра на раздаче крупных файлов. Проверьте на плохой сети, прежде чем катить.
Сначала посмотрите, где вы реально теряете время. Если у вас 3 МБ несжатого JavaScript и картинки без ленивой загрузки, переход на HTTP/3 не спасёт: он экономит десятки миллисекунд там, где вы теряете тысячи.
❯ Выводы
Начинал я с простого вопроса: сколько даёт HTTP/3 на обычной странице. Закончил пониманием, почему на многих страницах он не даёт ничего — и почему это нормально.
Основную выгоду забирает HTTP/2, и забирает он её этажом ниже. Мультиплексирование сводит девять последовательных раундтрипов к одному — вот они, те самые 50%. Всё, что QUIC может добавить сверху, — это сэкономленное рукопожатие: один раунд там, где их было три. При RTT в 20–30 миллисекунд такая экономия тонет в шуме измерений. Она становится заметной там, где раунд стоит дорого: 150 миллисекунд до другого континента превращают её в 13% времени загрузки.
Что удивило по-настоящему — это не отсутствие выигрыша, а его знак. Я ждал, что HTTP/3 будет либо лучше, либо примерно так же. А он на пятидесяти мелких файлах оказался втрое хуже, и не из-за транспорта: транспорт как раз отвечал вчетверо быстрее. Его задушила очередь на открытие потоков — то есть настройка, а не протокол.
И отдельно про самопроверку. Я дважды получал красивый результат, который оказывался артефактом: сначала два экземпляра сервера дрались за порт и отдавали пустые ответы, потом мой собственный эмулятор гнал пакеты пачками и наказывал за это QUIC. Оба раза спасло то, что я не поверил цифрам и полез считать байты и пакеты. Если бы поверил — статья была бы уверенной, красивой и неправильной.
И отдельно про самопроверку. Я дважды получал красивый результат, который оказывался артефактом: сначала два экземпляра сервера дрались за порт и отдавали пустые ответы, потом мой собственный эмулятор гнал пакеты пачками и наказывал за это QUIC. Оба раза спасло то, что я не поверил цифрам и полез считать байты и пакеты. Если бы поверил — статья была бы уверенной, красивой и неправильной.
Так что если у вас после включения HTTP/3 ничего не изменилось — возможно, у вас всё в порядке. Вы просто уже собрали основной выигрыш этажом ниже.
Может быть интересно:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram‑канале ↩

