javascript

Ускоряем drop in replace Next.js в 100 раз. Часть 1

  • суббота, 15 августа 2026 г. в 00:00:19
https://habr.com/ru/companies/t2/articles/1066044/

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

Часть 1. Архитектура и бенчмарки

Последние годы, если мы говорим о BFF, SSR, API routes, React Server Components или server-side fetch, мы почти автоматически думаем про JavaScript runtime. Node.js, Deno, Bun, много маркетинга, но парадигма одна: JS/TS исполняется внутри runtime, который живёт, как долгоживущий процесс, управляет памятью через GC. Часто можно слышать баталии: Node.js vs Deno vs Bun. Но почти никто не спрашивает: а обязан ли серверный фронтенд вообще быть JS runtime-приложением?

Node.js: удобный в плане developer experience вариант

Node.js стал default не случайно. Один язык на клиенте и сервере, одна команда, одна экосистема, npm, TypeScript, React SSR, Next.js, огромное количество готовых библиотек. Хороший developer experience. Deno добавил permissions-модель. Bun ускорил cold start. Но оба остаются в той же парадигме: V8 или JavaScriptCore, garbage collector, event loop, долгоживущий процесс, module graph. Но как основа для современного BFF этот слой имеет системные проблемы:

Cold start. Runtime, module graph, framework initialization, JIT warmup, состояние приложения. Для долгоживущих страниц это необходимость, но вопрос стоит ли платить такую цену за короткоживущие серверные actions.

Память. V8 heap, module cache, framework runtime, userland dependencies, долгоживущие объекты. Процесс, который должен быть «тонкой прослойкой», легко съедает сотни мегабайт, а если постараться, счёт пойдёт и на гигабайты.

Безопасность. Весь application code живёт в одном процессе и одном trust boundary. Shared globals, monkey patching, filesystem/network/process environment доступны по умолчанию, если их специально не ограничивать.

Node.js выигрывает по удобству входа. Но BFF на горячем пути запроса оценивается по другим критериям: cold start, memory footprint, p99 latency, throughput. И здесь JS runtime — не всегда лучший выбор.

JavaScript как язык продукта, runtime как отдельный слой

Ключевая идея:

JavaScript и TypeScript могут остаться языками UI, композиции и бизнес-логики. Но HTTP-layer, cache, streaming, observability, server-side fetch, memory model, sandboxing и runtime lifecycle — это отдельный слой.

BFF: от тонкой прослойки к latency-sensitive runtime

От бекендеров мне часто доводилось слышать, что BFF — это просто: принять запрос, сходить в backend-сервисы, собрать данные, привести к модели клиента, отдать JSON, HTML или RSC. В реальности в своей повседневной практике я вижу, как BFF разрастается: auth, cache, retries, timeouts, observability, feature flags, server-side fetch, SSR, streaming, RSC, middleware. Через него проходит критичный пользовательский трафик. Он влияет на latency всего продукта и на нагрузку backend-сервисов. BFF становится критичным к задержкам. И здесь ограничения JS runtime особенно заметны:

●   Cold start долгий — пользователь ждёт.
●   Процесс ест много памяти — инфраструктура дороже.
●   Event loop перегружен — растёт tail latency.
●   Server-side fetch дорогой — каждый backend-запрос дороже.
●   Нет сильной изоляции — shared state и глобальное окружение становятся риском.

Что, если убрать Node.js, но оставить V8?

Первый архитектурный вопрос: что произойдёт, если написать свой runtime layer, но оставить V8, React и экосистему? Кажется, что задача решаемая, так как не придётся переписывать React на Rust и мы не отказываемся от RSC. Именно так работает Rari.

Что такое Rari

Представьте: React Server Components, App Router, streaming SSR, 'use client' — всё как в Next.js. Только под капотом не Node.js, а Rust. HTTP-сервер на axum/tokio, V8 через deno_core, build на Vite. Один бинарник. Один порт, который не требует прокси-сервиса по типу nginx для продакшен-окружения.

Цифры:

  • 67.4x+ throughput против Next.js на static-сценариях (на React Summit в июне было 45x — с тех пор подросло).

  • Cold start в миллисекунды — V8 snapshot создаётся при build, при старте сервер десериализует готовый isolate.

  • GC pauses не блокируют HTTP — V8 работает на отдельном thread, связь идет через message channel.

  • Multi-core из коробки — JS runtime pool с несколькими V8 isolates в одном процессе, без PM2 и cluster mode.

  • Response cache — на cache hit V8 не просыпается, отдаются pre-compressed bytes.

Дальше можно было написать: «Rust быстрее, потому что Rust». Нооо... не всё так просто. На React Summit автор говорил: когда он пофиксил три вещи — app router support, true SSR, correct RSC semantics, — производительность прыгнула с 4x до 45x. То есть, оказывается, просто Rust недостаточно. С тех пор добавился response cache — и цифра перевалила за 60x.

Контекст и зрелость

В июне 2026 года Rari представляли на React Summit в Амстердаме: доклад «Building RSCs Framework on Rust: Architecture Decisions That Delivered 45x Performance» вошел в основную программу. На GitHub (rari-build/rari) — активная разработка: контрибьюторы, issues, PR'ы, регулярные релизы.

HTTP и orchestration — Rust. Axum + Tokio. Мультипоточная обработка запросов, tower-middleware, graceful shutdown.

JavaScript execution — V8 через deno_core. Без npm-резолвинга, без CommonJS/ESM dual mode, без legacy API. Deno_core даёт чистый V8 со snapshot'ами и контролем lifecycle. Конкретно он даёт:

  • JS ↔ Rust bridge (ops): типизированные sync- и async-операции. Rust-функция вызывается из JS и наоборот без ручного FFI.

  • V8 snapshots: сериализация готового isolate при build, десериализация при старте за миллисекунды.

  • Контроль lifecycle: создание, управление и уничтожение isolates из Rust-кода.

  • Event loop integration: нативная связка с tokio. Async ops в JS прозрачно мапятся на tokio tasks.

  • Module loading: кастомный загрузчик модулей. Не npm, не node_modules, не CommonJS/ESM dual mode.

  • Web APIs из коробки: fetch, timers, console, streams — без легаси Node.js.

  • Extensions: механизм для добавления собственных ops и API в isolate.

Build layer — Vite. Трансформации, RSC-aware code splitting, client/server boundary analysis.

Что есть сегодня

В большинстве кейсов Rari работает как drop-in replacement для Next.js. Если приложение использует стандартный App Router без exotic-фич, миграция сводится к замене runtime, а не к переписыванию кода.

Поддерживаемые фичи:

Поддерживаемые фичи:

  • App Router: layouts, loading/error states, стандартная маршрутизация.

  • Server Components / Client Components: корректная 'use client' boundary.

  • 'use cache': серверное кэширование через directive, pluggable backends (redb и другие).

  • Streaming SSR: через Suspense, progressive delivery.

  • API routes и server-side fetch.

  • Response cache: pre-compressed bytes (zstd/brotli/gzip), на cache hit V8 не просыпается.

  • JS runtime pool: несколько V8 isolates в одном процессе, multi-core без PM2.

  • HMR для разработки.

  • Vite как build layer.

Three-layer architecture

Rari — не просто «Rust-сервер, который запускает JS». Это связка трёх слоёв:

Первый слой — Rust runtime: HTTP server, routing, runtime orchestration, cache, streaming, observability.

Второй слой — build layer: Vite, RSC-aware transforms, client/server boundary analysis.

Третий слой — React Server Components: server components, client components, streaming SSR, RSC payload.

Справедливо. Вместо двадцатого повторения «Rust под JS» — конкретные точки контакта между слоями.

Как слои связаны друг с другом

Build layer → Rust runtime

Vite на этапе сборки делает RSC-aware transforms и client/server boundary analysis. На выходе не просто js бандл, а структурированный результат:

  • Client bundle: JS для браузера, только 'use client' компоненты.

  • Server bundle: серверные компоненты, исполняются в V8.

  • RSC payload: сериализованное дерево компонентов.

  • Route manifest: какие модули к какому маршруту относятся.

Rust runtime потребляет этот manifest. Он знает, какие маршруты существуют, какие модули нужны для каждого, где проходит client/server boundary. Route dispatch происходит на стороне Rust, до того как V8 вообще проснётся.

Rust runtime ↔ V8 (deno_core)

Связь через ops — типизированные sync- и async-операции между Rust и JS:

  • Rust → JS: вызов рендера компонента, передача props, запуск server action

  • JS → Rust: fetch(), обращение к response cache, чтение файлов, логирование

  • Async ops: JS вызывает await fetch(...), deno_core мапит это на tokio task, результат возвращается в JS через event loop

V8 → Rust runtime (streaming)

При streaming SSR через Suspense:

  1. V8 начинает рендер, встречает <Suspense> boundary.

  2. Отдаёт shell (первый chunk) через op обратно в Rust.

  3. Rust оборачивает chunk в HTTP response (chunked transfer encoding) и отправляет клиенту.

  4. V8 продолжает рендер, резолвит Suspense, отдаёт следующий chunk.

  5. Rust стримит его в тот же HTTP response.

Клиент получает progressive delivery. Rust стримит по мере готовности.

Response cache (Rust-слой, до V8)

Request → Rust route dispatch
  → hash(request) → cache lookup
      hit  → pre-compressed bytes (zstd/brotli/gzip) → HTTP response
      miss → V8 render → compress → cache write → HTTP response

Cache lookup и отдача происходят целиком на Rust-стороне.

Итого: кто что делает

Слой

Отвечает за

Не делает

Vite (build)

Трансформы, boundary analysis, client/server split, manifest

Runtime, HTTP, кэш

Rust runtime

HTTP, routing, cache, streaming transport, observability, lifecycle

Рендер компонентов, продуктовую логику

V8 (deno_core)

Исполнение React, RSC render, server actions

HTTP, кэш, маршрутизацию

Взаимодействие через: ops, message channel, manifest, chunks.

V8 без Node.js: как устроен runtime

HTTP request
    → Rust HTTP server (axum + tokio, multi-threaded)
        → route dispatch
            → message channel
                → JS thread (deno_core + V8, single-threaded)
                    → React RSC render
                → response
    → HTTP response

В Node.js всё живёт в одном процессе и на одном event loop. HTTP-сервер, обработка запросов, таймеры, стриминг, GC — всё конкурирует за один thread. Когда V8 запускает garbage collection, event loop останавливается. Все pending-запросы ждут. Все новые соединения ждут. Те, кто смотрел Kibana на проде, могут по p99 latency увидеть периодический stop the world, и вы не можете ничего с этим сделать, потому что HTTP-слой и GC живут в одном адресном пространстве. В Rari это разделено физически.

Как устроено

Tokio runtime (Rust): multi-threaded, work-stealing scheduler. HTTP-запросы обрабатываются на пуле OS threads. Axum принимает соединения, роутит, отдаёт статику, работает с response cache — всё это на Rust-потоках, независимо от V8.

V8 isolate (deno_core): single-threaded. Живёт на выделенном thread. Исполняет React Server Components, генерирует RSC payload, выполняет server actions.

Связь: message channel (mpsc). Rust отправляет задачу в V8: «отрендери компонент X с props Y». V8 отправляет результат обратно. Данные сериализуются при передаче. Shared memory нет — это исключает data races и делает GC pause изолированным событием.

Что происходит при GC pause

GC pause в V8
  → V8 thread останавливается
  → tokio threads ПРОДОЛЖАЮТ работать:
      - принимают новые соединения
      - отдают cache hit (V8 не нужен)
      - стримят данные, которые уже в буфере
      - обрабатывают запросы к другим isolates (если JS runtime pool)
  → задержку получают ТОЛЬКО запросы, которые ждали V8

GC pause перестаёт быть глобальным событием. Он влияет только на те запросы, которые в данный момент исполняются в V8. На cache hit, статику, TLS handshake, новые соединения пауза не влияет.

Blast radius

Разделение на процессы/threads меняет не только latency, но и последствия сбоев:

В Node.js: утечка памяти в одном модуле, бесконечный цикл в middleware, необработанное исключение — весь процесс падает. Все запросы теряются. Нужен restart, а значит cold start.

В Rari: проблема в V8 (OOM, crash, зависший рендер) не роняет HTTP-сервер. Rust-процесс продолжает принимать запросы. V8 isolate можно перезапустить. Недавно в Rari произошло долгожданное событие: был влит JS runtime pool, обсуждение тут: (issue #223). Если один isolate долго обрабатывает запрос, роутер его обходит, остальные обслуживают трафик.

Почему это важно именно для BFF

BFF обрабатывает много конкурентных запросов одновременно. Часть из них — cache hit, и V8 не нужен. Часть — dynamic render, для которого V8 нужен. Часть — server-side fetch к backend-сервисам, где V8 нужен только для сборки ответа. В Node.js GC pause от одного тяжёлого рендера бьёт по всем остальным запросам. В Rari тяжёлый рендер изолирован: он замедляет только себя. Остальной трафик идёт через Rust-слой, который не знает про GC.

V8 snapshot: почему скорость cold start-а важна

Архитектура «Rust + embedded V8» бессмысленна, если при каждом старте процесса V8 заново парсит и компилирует React, RSC runtime, framework code и весь application JavaScript. Получаем тот же cold start, что и в Node.js. V8 snapshot – механизм, унаследованный от Deno (Rari использует deno_core).

Build time:
  V8 парсит и компилирует весь JS → сериализует heap → snapshot.bin

Runtime (старт сервера):
  V8 десериализует snapshot.bin → isolate готов к работе

Весь module graph — React, RSC runtime, framework, зависимости — парсится и компилируется один раз при build. Результат: бинарный snapshot. При старте сервер просто десериализует его в память. Первое, что замечаешь после перехода на Rari — время старта. После него Next.js вызывает отторжение: медленный старт, долгий build. В Node.js каждый запуск — это resolve → load → parse → compile → execute для всего module graph заново. В Rari этот цикл происходит один раз при build.

Node.js / Next.js

Rari

Старт процесса

Resolve + parse + compile всего module graph

Десериализация snapshot

Cold start

Секунды (зависит от размера приложения)

Миллисекунды

Зависимость от размера приложения

Чем больше зависимостей — тем дольше старт

Размер почти не влияет

Preview / serverless

Каждый инстанс платит полную цену

Каждый инстанс стартует быстро

В общем, это просто другая модель инициализации. Она возможна потому, что V8 здесь — embedded-компонент с контролируемым lifecycle, а не «весь runtime», который сам управляет своим запуском.

Rust is blazing — недостаточное объяснение

Легко сказать: Rari быстрее, потому что Rust быстрее Node.js. Но Rust сам по себе не спасает от неправильной архитектуры. Можно легко навайбкодить решение и упереться в потолок.

Architecture > language.

Три архитектурные ошибки, которые убивают производительность даже на быстром runtime:

Ошибка #1: Неправильные defaults для RSC

В React Server Components server component — это default. Client component должен явно opt-in через 'use client'. Если сделать наоборот, каждый компонент попадает в client bundle, даже без интерактивности. Bundle растёт с размером приложения, а не с количеством интерактивности. 'use client' и server-by-default — это не только DX. Неинтерактивный код остаётся на сервере, а клиент получает меньше JavaScript.

Ошибка #2: Нет App Router — framework слепой к graph

Rari использует App Router как декларативное описание графа маршрутов: layout → page → loading → error → not-found. Каждый узел — конкретный файл. Framework видит дерево и знает, что для /dashboard/settings нужны компоненты из app/dashboard/settings/ и родительские layouts, а всё остальное — нет. Какие преимущества дает такое решение:

Code splitting. Если framework знает граф, он делит клиентский JS по маршрутам. Пользователь на /home получает только то, что нужно для /home. Без графа мы бы получили всё в один bundle. 50 маршрутов и весь JS для всех 50 страниц едет на клиент.

Client/server boundary. Зная граф, framework определяет, какие 'use client' компоненты реально нужны для конкретного маршрута. Компонент, который импортируется только в /admin, не попадёт в bundle для /home. Без графа — невозможно понять, что относится к текущему маршруту, а что нет. Всё тащится везде.

Prefetching. Граф позволяет предзагружать данные и компоненты для вероятных следующих маршрутов. Пользователь на /dashboard — предзагрузи /dashboard/settings. Без графа prefetch слепой: нечего предзагружать, потому что неизвестно, что нужно.

Итого: runtime может быть быстрым — Rust, V8, snapshot. Но если build-слой не понимает граф, он не может правильно разделить код, отсечь лишнее и предзагрузить нужное. Build/runtime boundary остаётся медлиным. Быстрый runtime поверх неоптимизированного build'а — это быстрый сервер, который отдаёт клиенту слишком много JavaScript. Исправить это оптимизацией bundler'а нельзя. Нужно менять архитектуру: добавлять App Router, чтобы framework видел граф. Именно поэтому в Rari поддержка App Router стала вторым этапом, который поднял производительность с 4x до 10x.

Ошибка #3: Incomplete SSR

Компоненты выполняются на сервере. HTML генерируется. Но если hydration неполная — client-side hydration overhead, JavaScript parsing delay, cascading waterfall requests. Server execution ещё не означает server-first архитектуру. Исправление — не оптимизация bundler'а, а соответствие замыслу React: серверные компоненты по умолчанию, клиентские — только по явному запросу, анализ графа зависимостей на уровне маршрутов, полноценный SSR, гидратация — только интерактивных частей.

Бенчмарки: как я сравнивал Rari и Next.js

Стенд в Docker Compose, нагрузка через wrk, warmup, несколько прогонов, итог — медиана. Инструментация: в Next.js — встроенный telemetry через env переменную, в Rari — ручное подключение к tracing crate через tracing-opentelemetry bridge. Весь стенд, конфиги и скрипты: github.com/jarick/rari-vs-nextjs

Три сценария: static/prerenderfetchstreaming через Suspense.

Почему было добавлено несколько тестов. Static — это не React render. Fetch — это overhead server-side fetch. Streaming — это runtime pipeline. Нельзя смешивать в одну фразу «Rari быстрее Next.js». Правильный вопрос: что на hot path в конкретном сценарии?

Static: скорость серверного слоя

Оба фреймворка отдают заранее подготовленный результат. У Rari — prerendered RSC buffer из cache. У Next.js — static/prerender результат. React-рендер один и тот же, в данном случае мы сравниваем Rust/hyper против Node.js + Next.js server overhead.

Rari ~132k req/s, Next.js ~2.2k req/s, разница ~59x.

Вывод: когда результат подготовлен, Rust server hot path радикально быстрее Node.js/Next.js hot path.

Fetch (разница в 550 раз!): что реально происходит под капотом

Когда серверный компонент в Next.js вызывает fetch(), это не один вызов. Это цепочка слоёв, каждый из которых добавляет overhead:

1. Next.js patchFetch. Next.js патчит глобальный fetch. Перед тем, как запрос вообще уйдёт в сеть, проходит обёртка: проверка revalidate, cache, tags, дедупликация одинаковых запросов в рамках одного рендера, запись в cache layer. Это JavaScript-код, который исполняется в V8 до того, как начался сетевой обмен.

2. undici. Глобальный fetch() в Node.js реализован через undici — HTTP-клиент на JavaScript. Создание запроса, сериализация заголовков, выбор dispatcher (Pool, Client, Agent), решение: переиспользовать connection или создавать новый. Всё это — JS-объекты, аллокации, GC pressure.

3. Event loop. Запрос уходит в event loop. Node.js должен переключить фазу: timers → pending callbacks → poll → check. I/O операция регистрируется в libuv, поток ждёт на epoll/kqueue, ответ возвращается в poll-фазу. Каждый await — это переключение контекста внутри event loop, и оно не бесплатное.

4. Promises и microtasks. fetch() возвращает Promise. Каждый await response.json() — ещё одна микрозадача. Тело ответа читается через ReadableStream — каждый chunk порождает отдельную микрозадачу. Для ответа из 10 чанков — 10+ переключений в microtask queue.

5. V8 overhead. Всё вышеперечисленное исполняется в V8: создание объектов для запроса/ответа, временные строки для заголовков, буферы для тела. Всё это в итоге попадает в сборщик мусора. Если код не прогрет JIT'ом — интерпретация вместо компилированного кода.

Итого на один fetch в Next.js

fetch() вызов
  → Next.js patchFetch (cache check, dedup, revalidate)
  → undici (создание запроса, dispatcher, pool)
  → libuv / event loop (I/O registration, poll)
  → TCP (даже loopback — это syscall, context switch)
  → ответ: event loop → ReadableStream → chunks → microtasks
  → response.json() → Promise → parse → JS-объект

Каждый слой — это аллокации, переключения, обёртки. Даже для одного запроса overhead незаметен. Для BFF, который делает 5–10 fetch'ей на запрос к разным backend-сервисам, это всё, как правило, ещё и складывается, так как запросы часто идут последовательно.

Что происходит в Rari

fetch() вызов в V8
  → op: Rust получает задачу
  → Rust HTTP client (hyper) поверх tokio async I/O
  → TCP
  → ответ читается в Rust buffer
  → результат передаётся в V8 через op (одна сериализация)

Нет JavaScript-обёрток на сетевом уровне. Нет undici, dispatcher, event loop переключений между фазами, ReadableStream для тела. Данные читаются напрямую в Rust-буфер и передаются в V8 один раз.

Почему self-fetch это показывает особенно clearly

Self-fetch — запрос к самому себе, к собственному data.json. Данные уже в памяти сервера. Сетевая задержка минимальная (loopback). Казалось бы, overhead должен быть нулевым. Но в Next.js даже self-fetch проходит весь стек: patchFetch → undici → dispatcher → event loop → TCP loopback → ответ → ReadableStream → microtasks → parse. Все слои абстракции работают, даже когда данные уже рядом. В Rari self-fetch — это Rust HTTP client, который делает loopback-запрос через tokio. Сетевой overhead минимален, а JavaScript-обвязки на сетевом уровне нет вообще. V8 получает результат через один op.

Streaming: самый честный dynamic-сценарий

Здесь оба фреймворка реально выполняют dynamic RSC/Suspense pipeline. Кеш отключён для страницы, получается, нельзя просто отдать подготовленный буфер. Оба runtime каждый раз генерируют страницу. Тестовая страница: 10 <Suspense> boundaries с задержками 100ms×5, 500ms×3, 1000ms×2. Минимальное время рендера — 1 секунда (самая длинная задержка). Нагрузка: 50 конкурентных соединений, 15 секунд.

Результаты:

Метрика

Rari

Next.js

Разница

req/s

74

22

3.4x

TTFB

7ms

5ms

Next.js быстрее

Chunks

13

14

разное количество

Inter-chunk gap p95

500ms

500ms

идентично

И вот тут начинается интересное. TTFB: Next.js оказывается быстрее. Next.js отправляет первый байт за 5ms, а Rari — за 7ms. Казалось бы, Next.js выигрывает на старте. Но дальше начинается streaming, и картина меняется.

Chunks: разное количество при одинаковых Suspense boundaries

Оба фреймворка рендерят одни и те же 10 Suspense boundaries. Но Rari отправляет 13 чанков, а Next.js за 14. При абсолютно одинаковой вёрстке. Почему? Next.js стримит больше транспортных данных: RSC payload metadata, script tags для hydration, дополнительные обёртки. Каждый чанк — это отдельный проход через Node.js HTTP stack: serialize → chunked transfer encoding → event loop → TCP write. Rari группирует данные плотнее. Меньше чанков, соответственно, меньше проходов через транспортный стек. Каждый чанк в Rari — это V8 serialize → Rust HTTP write. Без event loop, без промежуточных обёрток.

Inter-chunk gap: идентично

Причём можно заметить, что p95 inter-chunk gap — 500ms у обоих. Это значит, что задержки Suspense boundaries (100ms, 500ms, 1000ms) доминируют. Оба фреймворка ждут одинаково. Progressive delivery работает у обоих: shell уходит сразу, Suspense-границы резолвятся по мере готовности, клиент получает данные инкрементально. Разница не в том, когда приходят данные. Разница в том, сколько overhead приходится на каждый чанк.

Почему 3.4x при одинаковом inter-chunk gap

Каждый чанк в Next.js проходит через:

React render → serialize → Node.js HTTP response
  → chunked transfer encoding → event loop → TCP write

Каждый чанк в Rari:

V8 render → serialize → Rust HTTP write

При 13–14 чанках на запрос overhead на каждый чанк умножается. Next.js делает больше чанков, и каждый чанк дороже. Rari делает меньше чанков, и каждый чанк дешевле. 3.4x — это не «каждый запрос в 3.4 раза быстрее». При 50 конкурентных соединениях и 1-секундном рендере это throughput: Rari обслуживает в 3.4 раза больше конкурентных запросов в секунду. Latency на запрос примерно одинаковая (Suspense-задержки доминируют), но способность сервера обрабатывать параллельные запросы разная. Тут стоит подчеркнуть, что разница в TCP chunks — транспортная, а не RSC-level. Rari быстрее в этом сценарии не за счёт отключения progressive delivery, а за счёт более быстрого транспортного стека на каждый чанк.

Итог первой части

Rari позиционируется как drop-in replacement для Next.js. И на уровне API это во многом так: App Router, Server Components, 'use client', streaming — всё на месте.

Но, как обычно бывает с drop-in, есть свои моменты. От Next.js в коде Rari фактически ничего не осталось — это другой runtime, HTTP-слой и другая модель инициализации. Совместимость на уровне интерфейса не означает идентичность внутри.

Что именно поменялось под капотом, где это даёт выигрыш, а где создаёт ограничения,  во второй статье.

Бенчмарки: github.com/jarick/rari-vs-nextjs. Rari: github.com/rari-build/rari.