На официальном сайте Deno (раздел Built for the real world) красуется любопытная табличка. Согласно их замерам, Deno уходит в отрыв от Node.js по HTTP аж в 1.65 раза:
Метрика |
Deno 2.9 |
Bun 1.4 |
Node 26 |
|---|---|---|---|
Realworld HTTP (req/sec) |
72 400 |
68 200 |
44 000 |
Hello-world HTTP (req/sec) |
85 600 |
81 900 |
56 300 |
p99 latency (realworld) |
1.87 ms |
2.80 ms |
3.76 ms |
Независимые тесты (denosaurs/bench, pkgpulse 2026, jsmanifest 2026) показывают похожую картину: Deno стабильно быстрее на HTTP-нагрузке. Я решил копнуть глубже и понять, где Deno получает преимущество по сравнению с Node.js.
Часть 1. Проверяем гипотезу «tokio быстрее libuv»
Самая очевидная мысль: Deno написан на Rust и tokio, Node.js на C++ и libuv. Если один рантайм быстрее другого, значит, и весь сервер быстрее. Давайте проверим это, но с оговоркой: микробенчмарк сравнивает не полностью эквивалентные механизмы (uv_timer_t в libuv против tokio-тасков с sleep(0), yield_now() и вообще без await). Это не «event loop dispatch» в строгом смысле, а измерение скорости пробуждения одной задачи в каждом рантайме.
Как я тестировал
Я написал два автономных бинарника на Rust с одинаковой обвязкой: через FFI вызывает libuv 1.52.1, второй работает на tokio 1.53.
libuv_raw создаёт N таймеров (
uv_timer_t) с задержкой0и запускает их черезuv_run(UV_RUN_DEFAULT). В колбэке, через атомарный инкремент счётчика.tokio_raw спавнит N задач в
current_threadрантайме, собирает их черезJoinSetи ждёт завершения. Каждая задача тоже делает атомарный инкремент.
Идея в том, чтобы измерить tokio и libuv, без JS, HTTP и V8. Сборка: cargo build --release (lto=fat, opt-level=3). Запускал в Docker на Debian 12. Чтобы исключить влияние park/unpark, я прогнал три варианта tokio-нагрузки:
Режим tokio |
Что делает задача |
park? |
|---|---|---|
|
|
да (timer wheel) |
|
|
да (scheduler queue) |
|
|
нет |
Результаты
Прогнал оба бенчмарка с N = 10 000, 50 000 и 100 000 «задач». Вот что получилось (меньше — лучше, наносекунды на операцию):
N |
libuv (ns/op) |
tokio sleep(0) |
tokio yield_now |
tokio noop |
|---|---|---|---|---|
10 000 |
220 |
550 |
371 |
330 |
50 000 |
313 |
506 |
424 |
321 |
100 000 |
410 |
528 |
446 |
333 |
Разница между libuv и tokio зависит от количества задач и она нестабильна: на N=10K libuv быстрее, на N=50K они практически равны, а на N=100K tokio чуть быстрее. Знак разницы меняется в зависимости от N, что указывает на погрешность/особенности нагрузки, а не на устойчивое преимущество одного event loop над другим. В нашем минимальном тесте мы не обнаружили разницы, которую можно было бы считать достаточной, чтобы объяснить 1,65-кратный разрыв в HTTP throughput. Это не означает, что event loop'ы одинаковы: в других сценариях результаты вполне могут отличаться. Но для нашего вопроса(может ли event loop сам по себе объяснить наблюдаемую разницу в HTTP-производительности) ответ, судя по этому тесту, скорее отрицательный.
Часть 2. Проверяем гипотезу «JS-таймеры»
Вторая гипотеза: разработчики Deno взяли и переписали стандартную библиотеку, включая таймеры, в «более асинхронном» стиле, который лучше ложится на Rust-рантайм.
Что я нашёл
Открываю libs/core/02_timers.js в репозитории Deno и lib/internal/timers.js в Node.js рядом. Видно следующее:
Похожие структуры данных: object-map
timerListMap(по длительности задержки) +PriorityQueueповерх + linked list buckets.processTimers:nextExpiry = Infinity; while (list = timerListQueue.peek()) { if (list.expiry > now) return; listOnTimeout(list, now) }— алгоритмически практически один-в-один.listOnTimeout— обход списка с проверкойdiff < msecs, повторная вставка для_repeatтаймеров, логика схожая там и там.
В Node.js linked list helpers живут в отдельном модуле internal/linkedlist.js и называются init/peek/remove/append/isEmpty (используются как L.append(), L.peek() и т.д.), а в Deno они определены прямо в 02_timers.js под именами L_init/L_peek/L_remove/L_append/L_isEmpty. Но сам алгоритм linked list схожий — те же idleNext/idlePrev, та же логика «next = older, prev = newer».
Вывод
Похожие алгоритмы могут давать совершенно разные результаты на практике: V8, GC, представление объектов и границы runtime способны сделать две похожие JS-реализации существенно разными по фактической стоимости. Однако анализ исходного кода Deno (02_timers.js) и Node.js (internal/timers.js + internal/linkedlist.js) показывает, что реализации алгоритмически почти идентичны — структуры данных, логика в processTimers/listOnTimeout и linked list. Сходство делает гипотезу «Deno переписал таймеры под Rust-рантайм и сделал их быстрее» для меня маловероятной.
Часть 3. Главный фактор: архитектура HTTP-стека Deno
Если не event loop и не таймеры, то что остаётся? Смотрим на HTTP-стек целиком: Deno использует собственный Rust-стек HTTP/1.1 и другой путь интеграции с V8, тогда как Node.js использует связку node:http + llhttp + C++ binding. Это, судя по исходному коду и нашим экспериментам, один из наиболее вероятных факторов наблюдаемой разницы, а #[op2(fast)] и V8 Fast API — один из механизмов, который позволяет ему работать быстро.
Как устроен Deno
В Deno нативные операции (ops) объявляются с помощью #[op2], у них есть два режима:
Обычный режим: JS-вызов проходит через полный V8 dispatch — упаковка аргументов, проверка типов, вызов C++-функции, распаковка результата. На практике это слишком долго.
Быстрый режим (
#[op2(fast)]): компилируется в V8 Fast API Call. V8 заранее знает сигнатуру функции и типы аргументов, поэтому вызов происходит напрямую — без упаковки и диспетчеризации. Это позволяет задействовать V8 Fast API.
Ключевой момент: Deno HTTP-стек активно использует макрос #[op2(fast)] для критических по производительности операций.
Как устроен Node.js
В Node.js ситуация сложнее. Там есть несколько механизмов для нативных вызовов:
V8 Fast API Call — есть, но используется не везде.
N-API / node-api — стабильный, но медленный слой с упаковкой аргументов и проверкой типов.
Internal bindings — то, что используется внутри ядра, но тоже с оверхедом.
Возьмём node_http_parser.cc — модуль, который отвечает за парсинг HTTP в Node.js (поверх библиотеки llhttp). Часть методов этого binding'а пока использует обычный V8 calling convention через FunctionCallbackInfo<Value>, тогда как для них в исходнике прямо оставлен TODO(@anonrig): Add V8 Fast API. Это делает скорость перехода JS/native одним из правдоподобных источников overhead в HTTP-цикле Node.js.
Что такое V8 Fast API Call
V8 Fast API, позволяет нативным функциям регистрироваться с фиксированной сигнатурой типов. Когда V8 встречает JS-вызов, который соответствует зарегистрированной Fast API сигнатуре, он может сгенерировать inline-инструкции прямо в JIT-скомпилированном коде, минуя общий dispatch. Несовместимые JS-вызовы уходят на slow path. Подробнее о том, как Node.js разработчики должны добавлять V8 Fast API в документации Node. Обычный путь (через FunctionCallbackInfo<Value>) выглядит так, что каждый вызов проходит через V8-диспетчер:
void HttpParse(const v8::FunctionCallbackInfo<v8::Value>& args) { v8::Local<v8::Value> buf = args[0]; if (!buf->IsArrayBuffer()) { /* throw */ } auto* ab = buf.As<v8::ArrayBuffer>(); // ... распаковка, работа, упаковка результата обратно в v8::Value }
Fast API путь выглядит иначе, так как V8 знает сигнатуру, и для совместимого JS-вызова передаёт нативные типы напрямую:
void HttpParse(uint8_t* data, size_t length, FastApiCallbackOptions& opts) { // Никаких v8::Value unwrap/cast, V8 передаёт сырой указатель + длину // V8 может встроить этот вызов прямо в JIT-код JS-вызова }
Почему это важно именно для HTTP
HTTP-запрос — это целая цепочка вызовов: прочитать сокет, распарсить заголовки, прочитать тело, сформировать ответ, записать в сокет. Если на каждом этапе мы идём через Fast API в одном runtime и через обычный V8 binding в другом, разница на один вызов всего доли микросекунды. Но когда таких вызовов десятки на каждый запрос, а самих запросов десятки тысяч в секунду, эти доли могут складываться в наблюдаемую разницу.
Собственный HTTP-стек на Rust
Это, по совокупности архитектурных признаков и моим экспериментам, один из наиболее вероятных факторов наблюдаемой разницы. В ext/http/http_next.rs видно, что HTTP/1.1 в Deno обслуживается через собственный Rust-стек. serve_http11_raw обрабатывает запросы через crate deno_http_h1 и собственные структуры на чистом Rust, без napi-style binding'ов, без node:http совместимости. Node.js идёт по другому пути: node:http → llhttp (C) → node_http_parser.cc (C++) → V8. Этот binding использует обычный V8 calling convention через v8::FunctionCallbackInfo<Value>. Сам Deno в release notes к 1.35 прямо заявляет двухкратное преимущество своего HTTP-стека. Это согласуется с тем, что собственный Rust-стек и V8 Fast API являются одними из основных факторов наблюдаемой разницы.
Вывод
Для прагматичного выбора стека: если у вас высоконагруженный прокси или простой API-шлюз, Deno (или Bun) могут дать заметный прирост производительности. Если же у вас сложная бизнес-логика с тяжёлым I/O, разница, скорее всего, нивелируется, поэтому выбор лучше делать на основе собственных бенчмарков. И ещё одна настоящая киллер-фича Deno — op-биндинги к Rust. Если вам нужно выжать из V8 максимум и перенести часть логики на системный уровень, Deno даёт удобный механизм для такого сценария.