Дисклеймер: самая большая ошибка в этой истории даже не выбор Dart, порядок действий. Вместо того чтобы в первый же день сделать честный raw benchmark на нашем production-like сценарии, я поверил в AOT, в статическую типизацию и в обещания ready for cloud - и сразу начал переносить сервис. Поэтому этот текст не только про Dart, но и про очень дорогой инженерный урок: сначала валидируй runtime-гипотезу, потом строй архитектуру вокруг неё. Чтобы не превращать текст в войну микрооптимизаций — все raw results, k8s manifests, CPU profiles, Dockerfiles и исходники рантаймов вынесены в репозиторий. Здесь цифры округлены. Я не гуру в Go, Dart и .NET — я обычный JS-разработчик. Поэтому вполне мог где-то напортачить. Увидели ошибку или способ улучшить код в рамках правил — милости прошу в PR.
Акт I. Надежда
Я работаю над SaaS-сервисом. Стек — Node.js. В целом, он устраивает всем: производительность на уровне, скорость разработки хорошая, один язык на всё. Да, некоторые сервисы жрали память как не в себя под нагрузкой. Например, наш OAuth2-сервис в пике потреблял 500 МБ, но это скорее показатель того, что нашим сервисом вообще пользуются :) Но главное токены выдавались, память возвращалась и деградация RPS упиралась в CPU на криптографии.
Но когда у тебя SaaS, сотни однотипных сервисов, и каждый потребляет по 80 МБ просто на старте — это уже другие цифры и цены на инфраструктуру.
Поэтому мы начали смотреть альтернативы. Ну как смотреть, из очевидного это Go, но команда у нас JS до мозга костей, так что Go энтузиазма не вызвал: err != nil на каждой строчке; context.Context первым аргументом в каждую функцию — Go way, explicit is better than implicit, и всё в этом роде. Ребята смотрели на это как на привет из 2009 года, когда Node.js точно также передавал err первым аргументом в каждый callback. Callback hell помнили хорошо. В Go этим до сих пор гордятся — ну и пусть гордятся, решили мы.
Начали искать что-то ещё и наткнулись на Dart. На бумаге: AOT-компиляция, статическая типизация, знакомый синтаксис и подходы. На официальном dart.dev прямым текстом: "Creating scalable, high performance APIs and event-driven apps are good use cases for Cloud Run". Не маркетинговый блог, не сторонняя статья — официальная документация, звучит как серебряная пуля, я и повёлся.
Знаете этот анекдот?
"Такой умный, красивый, успешный — интересно, почему от него бывшая ушла?"
И только через три месяца: "Таааак вооот почему..."
Вот примерно так это в итоге и сработало.
Мы понимали, что не получим Go-уровень. Но 10 МБ после старта вместо 80 - Claude обещал именно столько для наших типичных сервисов, начитавшись тех же маркетинговых блогов, что и я. Справедливости ради — простой HTTP сервер в AOT действительно занимал копейки. Я запустил, убедился, поверил. Статическая типизация, AOT, в 8 раз меньше памяти, избавление от чёрной дыры в виде node_modules - это звучит отлично. Как оказалось, только звучит.
Акт II. Отрицание
Проблема первая: экосистема
Мы решили переписать сервис авторизации на Dart как тестовый полигон, хорошая метрика для принятия решения. Плюс сам сервис - это только NestJS + самописная ORM для Redis + ts-oauth2-server, что при наличии Claude Code не выглядит как rocket science. И план казался действительно разумным: кодовая база на TypeScript с чёткой архитектурой — это именно тот материал, с которым ИИ-агент работает хорошо. Перенести структуру, сохранить логику, сменить рантайм. Легко?
Dart — это Flutter, для бэкенда нет ничего, от слова совсем. Аналога NestJS нет, Redis-клиентов можно пересчитать по пальцам, а что-то уровня ioredis — забудьте. Да, я знаю про Shelf и Serverpod, но Shelf - это Express.js уровня 2015 года, а Serverpod заточен под Flutter-команды, которые хотят full-stack на одном языке. Мы не пишем на Flutter, и Express нам не нужен.
Мне давно не нравилась модульная система NestJS и то, как там устроено дерево зависимостей. Знаю его болячки не понаслышке: за несколько лет я построил вокруг NestJs целый platform layer с JSON:API, JSON-RPC, SDK-клиентами, ACL и ORM-адаптерами (кому интересно, вот тут можно ознакомиться). Guard'ы, которые срабатывают раньше валидации данных, request scope у провайдеров, который ради одного request-scoped dependency пересоздаёт половину дерева, и вся эта магия, которая отлично продаётся на демках, но больно бьёт по большим системам. Поэтому мысль "раз в Dart NestJs нет — соберём свой NestDart, с блекджеком и куртизанками только сразу без этих проблем" в тот момент казалась почти естественной, а я уже мысленно прикидывал, сколько звёзд соберёт nestdart.
Тем более Max Plan у Claude Code и любовь к изобретению велосипедов, что может пойти не так? Ведь «План надёжен как швейцарские часы» (с)
Проблема вторая: сам Dart
Ещё в начале десятых помню хайп вокруг Dart, как Google продвигал его в замену JavaScript. Когда начал писать код, я понял, почему Google не смог.
Хочешь сделать for...in по объекту? Забудь. Хочешь вызвать статический метод у значения типа Type? Забудь. Type - это вообще какой-то огрызок, который я так и не понял. Передать можно, вызвать на нём ничего нельзя, в AOT это by design. Рефлексия тоже отсутствует, а как строить DI-контейнер без рефлексии?
Окей, думаю, может, просто надо принять евангелие от Dart и не пытаться писать TypeScript на Dart? Посмотрел, как вообще пишут. И тут меня накрыл ужас - кодогенерация. Нет, я не против неё, мы сами её используем, но то, как она реализована в Dart, — это тихий кошмар. Везде part 'file.g.dart' в исходниках, твой чистый файл уже содержит ссылку на сгенерированный. И два стула: либо коммитишь простыню в репозиторий, либо гоняешь генератор на каждый пайплайн. Я уже представил этот ад на ревью: автоматические апрувы, чтобы не читать всю простыню.
Ладно, принимаю евангелие от Dart. "AOT и 10 МБ" повторял как мантру.
Чтобы минимально трогать исходники, нашёл экспериментальный флаг --enable-experiment=enhanced-parts, изменялся только один файл, остальное генерировалось в сторонке. Собирать файл построчно и читать AST дерево я точно не хотел, поэтому Cli утилита. Для CLI-утилиты схема такая: запускаем входной файл в JIT-режиме → используем dart:mirrors → получаем типы с аннотациями → преобразуем ClassMirror в дескрипторы → вызываем сами аннотации → CLI генерирует нужные файлы. И это, чтобы сделать то, что в TypeScript/Java/C# делается тремя строками через рефлексию. Язык не даёт тебе инструмент: ты строишь инструмент, чтобы построить инструмент. Это и есть главная проблема: не то, что оно не работает, оно работало, но то, какой ценой, оставляет много открытых вопросов.
И вот парадокс: сам язык приятный. Писать код на Dart действительно приятно. Он логичный и, в целом, понятный. Большинство ошибок ловятся в процессе компиляции. Claude Code за несколько часов портировал ioredis — 4500 строк Dart против 23500 строк оригинального TypeScript. Язык настолько чистый, что даже ИИ пишет на нём лаконично. Но как только выходишь за пределы "просто писать код", начинается боль, а значит инструменты никто не пишет, а значит экосистемы нет. Это и объясняет, почему Dart за пределами Flutter скорее мёртв, чем жив. Доказательство этому появится чуть позже, с цифрами.
За 2 недели: NestJS-подобный фреймворк с древовидным DI, как в Angular, отдельная core-часть от транспорта, request scope через Zone, CLI для кодогенерации, как в лучших домах ЛондОна и Парижа (с). Claude Code портировал ioredis и нашу Redis ORM. В процессе переноса ts-oauth2-server как отдельного пакета для core части меня не покидало чувство, что слишком все "хорошо" выглядит. И вопрос "интересно, почему от него бывшая ушла" не оставлял меня в покое. Я решил проверить гипотезу в чистом виде. Через 2 недели. Молодец, что можно сказать.
Акт III. Гнев
Проблема третья: производительность
Решил провести простой нагрузочный тест. Намеренно простой сценарий: три эндпоинта, Postgres, Redis, одинаковые условия для всех рантаймов. Никакого NestJS, никаких ORM, сырой HTTP-сервер, чтобы сравнивать именно рантаймы, а не фреймворки.
Я ожидал, что Dart будет медленнее Go, но быстрее Node.js. Я даже репозиторий с бенчмарками назвал go_vs_dart, но цифры показали, что название явно нужно было другое.
Dart стартовал мгновенно и потреблял 3 МБ против 18 МБ у Node.js, не в 8 раз разница, но и зависимостей у nodejs почти не было. И сборка образа быстрей, и размер образа 5МБ, а не 50МБ, успокаивал я себя.
Но как только пошла нагрузка, картина сломалась.
По RPS Dart оказался значительно хуже Node.js, разрыв доходил до двукратного. Но RPS - это ещё полбеды: при 100m CPU и 500 VUS латентность p95 у Dart 9.5 секунды, У Node.js — 5 секунд, у Go — 2.9. Это уже не "медленнее", это timeout. В реальном продукте это означает не деградацию, а отказ.
С памятью отдельная история. В пике на полном CPU — одинаково, оба около 39 МБ, но стоит добавить throttling, Dart растёт до 47 МБ, Node.js остаётся на 37 МБ. GC под CPU pressure не успевает, и это все ещё не основная проблема. Dart отдавал обратно в ОС около 5%, дальше нет. Вообще. Ни байта. Нет, это не утечка, память доходила до максимума и больше не росла. Node.js после пиковой нагрузки скидывал обратно 80%.
В какой-то момент это уже перестало быть просто benchmark'ом и превратилось почти в торг с рантаймом. Я перепробовал все GC-флаги: --dontneed_on_sweep, --use_compactor, --force_evacuation, --mark_when_idle, --old-gen-heap-size, даже Dockerfile в какой-то момент стал выглядеть как ритуал надежды, а не конфигурация. Ничего не помогло. Dart VM team подтвердили - by-design. Dart VM спроектирован под Flutter — минимальные паузы GC для 60fps, а не минимальный RSS для Kubernetes. Для мобилки это правильное решение, для сервера же — смерть. Kubernetes не знает, что твой процесс "просто держит память на всякий случай". Он видит: pod занимает 40 МБ, значит ему нужно 40 МБ. Умножай на сотни сервисов, и экономия на старте в 15 МБ превращается в переплату на проде. А теперь вспомним про горизонтальное масштабирование: HPA поднял тебе 10 инстансов под пиком, окей, нагрузка спала, память не вернулась. Kubernetes смотрит на 40 МБ × 10 подов и не может уплотнить ноду. Node.js в той же ситуации сжался до 20 МБ × 10, и два пода уже освободили целую ноду. Dart платит за пик постоянно.
И это называется "ready for cloud". И это — на рантайме, который в официальной документации описан как "scalable, high performance APIs". На Cloud Run, где платишь за memory × time. Dart на Cloud Run буквально стоит дороже, чем Node.js: медленнее обрабатывает и дольше держит память после пика. Впрочем, Google виднее что у них high performance.
Последний штрих — обещанное доказательство с цифрами. Порт ioredis, написанный AI-агентом как механический один-в-один перенос, показал на 5% больше RPS, чем лучший Redis-клиент на pub.dev. Это не комплимент Claude Code, это некролог экосистеме: бэкенд-инфраструктуру на Dart никто серьёзно не писал, и это видно по цифрам.
Ну что, теперь понятно почему от него ушла бывшая...
Акт IV. Принятие
Я потратил 2 недели с надеждой найти альтернативу Node.js. После 2 недель разработки, собственного DI-фреймворка, порта Redis-стека и raw benchmark’ов стало окончательно ясно: для server-side workload’ов Dart сегодня — бумажный тигр. Очень хорош на бумаге, но на деле его ниша — только Flutter. Никаким ready for cloud тут даже не пахнет. Dart team убрала рефлексию ради AOT и маленького бинарника для мобилок. Несколько лет пытались внедрить макросы, но не смогли, взамен дали только сомнительную кодогенерацию. VM только с прицелом на тот же мобильник. И то, что будет делаться что-то в сторону реального ready for cloud, у меня большие сомнения.
Обидно, потому что Dart был в одном решении от того, чтобы стать серьёзным конкурентом, а при дальнейшем развитии и убийцей Node.js для cloud-native. Старт с 3 МБ, пик памяти как у Node.js. Если бы после спада нагрузки память возвращалась за разумное время, уже сейчас получился бы отличный цикл: HPA поднимает второй под за секунду, трафик распределяется, нагрузка падает, память освобождается, Kubernetes уплотняет ноды. Два пода по 20 МБ легко давали бы 2x RPS за те же ресурсы, на которые Node.js тратит один под с 40 МБ. Текущий разрыв по RPS уже не выглядел бы приговором. Но вместо этого под набрал 40 МБ навсегда. Одно архитектурное решение в GC в пользу 60fps, и весь сценарий рассыпался. Если бы память вела себя предсказуемо, появился бы backend use case, появилось бы комьюнити, подтянулось бы I/O ядро. Тогда Dart мог бы стать настоящей заменой Node.js для cloud-native.
И вот ирония - я искал серебряную пулю, которая уже есть. Да, она скучная, многословная, с err != nil на каждой строчке, и этот культ явности, где context.Context первым аргументом в каждую функцию звучит как привет из 2009 года. Но когда дело доходит до реального продакшена - сотни микросервисов, CPU-throttling в Kubernetes, network hop до баз и необходимость возвращать память после пиковой нагрузки — Go просто работает. Он даёт 1.5–3x больше RPS при жёстких лимитах, чем Node.js и Dart, и полностью восстанавливает память. Да, Bun native на полном CPU неожиданно хорош приближается и даже иногда обгоняет Go по RPS, возврат памяти - 90%, но при CPU throttling в 100m - рестарты. В Kubernetes, где throttling это норма, а не исключение, — это не production-ready. Так что на сегодняшний день, GO - это единственная разумная альтернатива, если ты не готов тратить время на тюнинг .NET или мириться с высокой памятью managed-рантаймов или сырым runtime в принципе. Поэтому я открываю https://go.dev/tour и начинаю с нуля. Следующая статья, возможно, будет именно про этот переход, уже без такой драмы.
Настоящий урок
Обычно в конце такого пишут: Dart — плохой, берите Go. Но мой урок другой. Комментаторы напишут: "автор дурак, два часа на k6 в первый день сэкономили бы две недели" и будут правы. Но есть ловушка, которую я осознал только в конце всей драмы, и она совсем не очевидная. Claude Code сделал механический перенос почти бесплатным — и именно в этом, как ни парадоксально, была ловушка, в которую я попал. Когда стоимость переноса падает до нуля, очень легко забыть, что стоимость неверной runtime-гипотезы остаётся прежней. ИИ-агент не спросит "а ты вообще проверил что этот рантайм тебе подходит?" Он просто очень быстро и очень качественно построит то, что ты попросил. Сначала валидируй гипотезу. Потом дай агенту строить.
P.S. Этот пост я написал за вечер, на эмоциях, с цифрами из первых замеров, а потом решил перепроверить, и провалился в бенчмарки ещё на 2 недели. Прогретые поды, throttling профили, нативные клиенты, compat layers, Bun, Deno, .NET.
Сначала валидируй гипотезу. Потом валидируй свои бенчмарки. Потом пиши статью. Месяц вместо двух часов.
