Оптимизация next.js monorepo приложения
- суббота, 4 июля 2026 г. в 00:00:05
Дисклеймер: Данный кейс основан на архитектуре нашего проекта (~2600 файлов). В проектах другого масштаба или с другой структурой зависимости результаты могут отличаться. Это не «серебряная пуля», а мой личный опыт оптимизации конкретной инфраструктуры.
Буду краток, на проекте, где я сейчас работаю, мы с командой заметили огромную проблему со скоростью сборки и весом проекта после билда. Стек у нас React, Next.js, FSD Monorepo.
Изначальный набор инструментов был такой:
Билд | Turbopack |
Линтинг | Eslint |
Проверка типов | TypeScript Compiler |
Форматирование | Prettier |
Мертвый код | Knip |
Как видите, вполне стандартный набор инструментов для проекта на React+Next.js, к которым все привыкли и многих они устраивают. Но в какой‑то момент я устал ждать всё то время, пока все проверки пре‑пуш‑хука пройдут. Тем более, что из‑за кривой настройки проверка проходила не на изменённые файлы, а на весь проект: несколько минут ждать только чтобы узнать, что ты не можешь сделать пуш из‑за другого человека, и так по кругу. Так что я сделал полноценную миграцию на нативные инструменты и настроил их.
Начал я с линтера: стандартный Eslint, как оказалось, даже не был способен дойти до конца проекта на базовых настройках. Он просто падал в OOM(Out‑of‑Memory), но даже в таких условиях линтинг этой малой части занимал 35.2с на не полную проверку проекта. После увеличения лимитов оперативной памяти до 8 гигабайт, проверка всего проекта наконец‑то смогла пройти, но заняла 146.3с, то есть, на каждый пуш приходилось ждать 2.5 минуты просто на один этап из двух.
Заменой ему стала связка из oxlint и ast‑grep, поскольку один лишь oxlint не поддерживал все правила линтинга, что нам были необходимы. Такая, казалось бы, минорная и простая замена на два нативных инструмента позволила срезать время линтинга с 146.3с до жалких 14.5с. Таким образом, каждый пуш стал на 2 минуты короче для каждого члена команды, что даже при 5 пушах от разработчика в день сэкономило 20 минут времени каждому и часы, если считать общее время всех разработчиков. Дополнительно срезалось потребление оперативной памяти с 6.91GB на Eslint до 2.79GB на oxlint+ast‑grep.
Eslint | oxlint+ast‑grep | Разница | |
Время | 146.3с | 14.5с | 10.08х |
Потребление RAM | 6.91GB | 2.79GB | 2.47х |
Следующим на разделочный стол попал tsc. Он был просто медленным, проходил весь проект за 101.9 с, что тоже достаточно долго, но хотя бы без OOM. Его я заменил на tsgo, по памяти он остался почти таким же, из‑за специфики проверки типов в коде, но вот время упало с 101.9с на tsc до 9с на tsgo, при той же стабильности.
tsc | tsgo | Разница | |
Время | 101.9с | 9с | 11.3х |
Потребление RAM | 1.55GB | 1.54GB | 1х |
А вот дальше пошел в ход самый неоднозначный результат в этом замере: билд. В базе, как я писал ранее, у нас был Turbo с его 8.9MB после всех процедур(JS+css) и скоростью билда в 18.6с. Это очень быстро, этим результатом он даже обогнал базовый Webpack+Terser в 3 прохода с его 9.2MB и билдом в 147.5с, что в 8 раз дольше чем Turbo так еще и проигрывает по весу. Но у Webpack есть огромное преимущество — контроль над ним. Мы можем сами контролировать, как он будет резать и собирать чанки. После пары часов настроек я смог добиться общего веса проекта в 5.8MB, что уже почти пик, но ещё не полный.
Дальше в ход пошли уже относительно нестандартные техники вроде:
стабы next,
пре билд и пост билд скриптов,
обрезки ядра React,
сжатие css после билда.
Дали они очень интересный результат: общий вес они порезали относительно мало (660кб), если сравнить с корректной настройкой самого билдера, но вот максимальный вес чанка они смогли очень сильно срезать, более чем в 2 раза.
Важно: эти манипуляции требуют глубокого понимания графа зависимостей проекта и могут привести к регрессиям, если не покрыты тестами. Также отмечу, что я использовал стабы не для уменьшения общего веса проекта, а для уменьшения веса edger чанков, что в разы критичнее, чем общий вес, так что даже срез 50кб raw веса с главной стоит в разы больше в моем случае, чем 200кб суммарного веса проекта.
Пределом же оказался Rspack, который позволил достичь времени билда в 61.2с и при этом веса в 5.78MB. Я назвал этот инструмент самым неоднозначным по одной простой причине: в базе, без настроек он проигрывает в весе всем и очень сильно, как и видно из бенчмарка.
Тип сборки | Время сборки | потребление RAM | Суммарный вес |
rspack база | 69.0с | 1.53 GB | 13 881 KB |
webpack база | 147.5с | 2.30 GB | 9 379 KB |
turbopack | 18.6с | 2.56 GB | 9 125 KB |
rspack настройка | 61.2с | 3.46 GB | 5 780 KB |
webpack настройка | 136.6с | 3.83 GB | 5 798 KB |
rspack трюки | 104.0с | 1.66 GB | 5 088 KB |
webpack трюки | 106.9с | 2.28 GB | 5 145 KB |
Но его пик оказался на нашем проекте лучшим, за счет гибкой настройки самого Rspack удалось достичь аномальных результатов в весе огромного проекта. Еще хочу сказать кое‑что интересное про сам turbo: у него есть огромная проблема, а именно то, как он режет чанки. Если сейчас зайти на наш проект или же на ремангу, после чего открыть вкладку «сеть», можно увидеть крайне неприятную картинку: количество запросов js чанков+css+html стремится к сотне, а каждый запрос — это дополнительный оверхэд на установку, tcp‑соединение и так далее.
Вопрос сетей и сетевых задержек от такого количества чанков — это отдельная огромная тема, которую стоит рассматривать в отдельной статье. Пока что просто зафиксируем, что избыточное количество запросов приводит к плохому результату и долгой загрузке , статья и так получилась длиннее, чем я ожидал.
Теперь про Prettier. Сам по себе инструмент не так плох, со своей работой он справляется, но у меня есть личная неприязнь к инструментам на Js, так что я решил заменить его тоже. Замена нашлась достаточно быстро — Biome, тут расписывать в принципе нечего, просто форматер кода, в моем случае. Хотя в себе он имеет также: линтер, сортировку импортов, поиск по коду, авто миграцию конфига, lsp‑proxy и hot daemon. Использовать это всё не стал, так‑как настроил на тот момент уже oxlint. Вот сама таблица времени прогонов на всем репозитории.
Холодный прогон | Горячий прогон | RAM | |
Biome | 3.7с | 1.5с | 162MB |
Prettier | 41.1с | 34.7с | 399MB |
Как видно из прогона, выигрыш огромен, особенно в горячем прогоне, там он в 24 раза.
Также расскажу про полную замену всеми любимого Knip. Он получился тоже достаточно интересным, тестировал я его и его замену Fallow на дефолтных конфигах и на реальных продакшен конфигах. Получилась интересная ситуация. Fallow быстрее, что логично, но разница уже небольшая — всего 2.7 раза на холодном прогоне, 4.7с у fallow против 12.7c у Knip, и 6 раз на горячем прогоне — 1.7с у Fallow против 10.2c у Knip. Насчет работы обоих инструментов сложно что‑либо сказать, поскольку оба очень сильно зависят от настройки, но на базовых настройках Fallow нашел на 19% больше файлов, но это абсолютно не показатель.
Холодный прогон | Горячий прогон | RAM | |
Fallow | 4.7с | 1.7с | 211MB |
Knip | 12.5с | 10.2с | 531MB |
Тут, как видно, выигрыш в скорости скорее минорен, особенно учитывая, что инструмент используется относительно редко, но всё же выигрыш есть.
Также, думаю, стоит затронуть достаточно базовую, в моём понимании, тему: различие между npm и pnpm, и в чем pnpm превосходит уже ставший стандартом npm.
Для начала стоит разобраться, как вообще работают обе команды.
Начнем с npm, он ведет себя максимально просто: открывает ваш package.json и качает все зависимости и иногда зависимости зависимостей прямо в ваш проект в папку node_modules.
И естестественно, у такого подхода есть проблемы, начнем с самых очевидных:
Скачивание, при каждом npm install вы копируете его из кэша, что достаточно долгая и тяжелая операция.
Из этого вытекает вторая очевидная проблема, это вес: если работать с одним проектом, то проблем почти нет, но как только вы начинаете работать с двумя или более проектами, вес становится заметным, особенно, если стек похож, поскольку npm заботливо скачивает вам для каждого проекта свои зависимости.
Далее идет не очень очевидная проблема: в случае конфликта версий, npm прямо в папке node_modules создает еще одну папку node_modules и скидывает уже в нее файл с конфликтом версий. И тут происходит страшное: если у этой зависимости есть свои транзитивные зависимости, то их тоже качает и начинает раздувать вес node_modules еще больше.
Ну а теперь самое страшное: это фантомные зависимости. Вы скачали, например, библиотеку, которой нужен lodash, и она появилась в вашем node_modules, после чего вы или другой разработчик в любом файле пишет import lodash from 'lodash'; а за счет того, что npm имеет плоскую структуру и каждая зависимость пытается встать в корневой node_modules, у него всё запускается и работает, при том, что в package.json lodash не указан. Казалось бы, не указан и не указан, что такого, но тут та библиотека, которую вы поставили, становится не нужна или обновляется, и ей становится не нужен lodash → следовательно, этот самый lodash удаляется из node_modules, и билд начинает падать из‑за ошибки импорта.
Это были все основные проблемы npm, теперь — как эти проблемы решает pnpm.
Проблема 1 и 2 решается одновременно: при скачивании библиотеки через pnpm install, она ставится не в node_modules, а в специальную папку (в базе ~/.local/share/pnpm/*), и прокидывает в вашу папку node_modules хард линк. Когда вы пишете pnpm install повторно, сначала идет проверка, есть ли у вас уже эта зависимость в базовой папке. Если есть, то pnpm вместо долгого и тяжёлого копирования просто прокидывает дешевый хард линк и в другую папку node_modules в другом проекте. Таким образом, скачав один раз зависимость, вы получаете ее бесплатное использование во всех проектах.
Проблема 3 и 4 решается за счет симлинка: в pnpm зависимость не пытается вылезти в корневой node_modules, и ситуация, когда две зависимости конфликтуют по версиям, почти невозможна. А фантомные зависимости в принципе становятся невозможны.
Какой итог я для себя вывел: индустрия нативных инструментов для проектов уже достаточно зрелая, чтобы использовать ее в реальном продакшене и получать с этого реальный выигрыш в скорости. Ваше мнение, естественно, может отличаться в зависимости от вашего опыта, я всегда готов к обсуждение в комментариях.
Вот сам бенчмарк, кому интересно можете использовать и протестировать на своем next.js monorepo https://github.com/BezSaharaD/Benchmark
Все замеры были произведены на Ryzen 5 3600 и 16GB оперативной памяти 3733MHz с таймингами 16–18-18-36-86

