Контекст
В Daribar есть внутренний дашборд для операторов - React-приложение без Next.js, обычный CRA. Там отображаются заказы в реальном времени: у каждого заказа есть таймер, статус, куча данных. Заказов много - в пиках сотни на экране одновременно.
Сервер работал с высокой нагрузкой, приложение немного тормозило - но терпимо. Никто особо не копал: операторы привыкли, бизнес мирился. Периодически добавляли виртуализацию, оптимизировали рендеры - становилось чуть лучше, и на этом успокаивались.
Пока не начало расти количество пользователей. Тогда "терпимо" превратилось в "невозможно работать" - и пришлось разбираться всерьёз. Оказалось, главная проблема всё это время была не в коде.
Проблема 1: приложение 3 года крутилось не в том режиме
React-приложение деплоилось командой yarn start. Это dev-сервер. Он создан для разработки - медленный, без оптимизаций, с hot reload и кучей лишней работы под капотом.
В продакшне нужно было собирать билд и отдавать статику через serve или nginx:
# Было (неправильно)
yarn start
# Стало (правильно)
yarn build
serve -s build
# или через nginxРазница огромная. Dev-сервер при каждом запросе делает работу которую production билд делает один раз при сборке. Три года приложение работало в режиме разработки на продакшне - и никто не замечал, потому что искали проблему в коде.
После переключения на правильный деплой цифры говорят сами за себя:
- CPU: с 60-80% в пик до 2-4%
- Memory: стабилизировалась, перестала расти
- Рестарты сервера: исчезли полностью

На графиках видно чёткий момент переключения - примерно в 12:00. До этого CPU скачет между 47-58%, память нестабильна, сервер периодически рестартует. После - всё ровно и тихо.
Бизнес сэкономил на инфраструктуре. Операторы перестали жаловаться на тормоза. И всё это - одной командой в деплое.
Мораль: прежде чем оптимизировать код - убедитесь что приложение вообще запущено правильно.
Проблема 2: сотни таймеров которые пересоздавались каждую секунду
У каждого заказа в дашборде есть таймер - показывает сколько времени прошло. Выглядело примерно так:
function OrderRow({ order }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - order.createdAt);
}, 1000);
return () => clearInterval(interval);
}, [order.createdAt]); // пересоздаётся при каждом изменении заказа
return <div>{formatTime(elapsed)}</div>;
}На первый взгляд - нормально. Но когда заказов 200+ и каждый имеет свой setInterval, который к тому же пересоздаётся при любом обновлении данных заказа - браузер начинает тратить огромное количество ресурсов просто на управление таймерами.
setInterval в useEffect с зависимостями - это ловушка. Каждый раз когда зависимость меняется, старый интервал очищается и создаётся новый. При частых обновлениях данных таймер фактически перезапускается постоянно.
Решение - useRef:
function OrderRow({ order }) {
const [elapsed, setElapsed] = useState(0);
const intervalRef = useRef(null);
const startTimeRef = useRef(order.createdAt);
useEffect(() => {
// Запускаем интервал один раз
intervalRef.current = setInterval(() => {
setElapsed(Date.now() - startTimeRef.current);
}, 1000);
return () => clearInterval(intervalRef.current);
}, []); // пустой массив - интервал создаётся один раз
return <div>{formatTime(elapsed)}</div>;
}useRef хранит значение между рендерами без перезапуска эффекта. Интервал создаётся один раз при монтировании и живёт до размонтирования. Браузер больше не тратит ресурсы на постоянное пересоздание сотен таймеров.
Разница была заметна сразу - CPU в браузере перестал скакать, интерфейс стал отзывчивее.
Проблема 3: рендер сотен строк без виртуализации
Это как раз та проблема которую искали с самого начала - и она тоже была реальной, просто не единственной.
Когда в таблице 300+ заказов и каждая строка - это компонент с таймером, данными, кнопками - браузер рендерит всё это одновременно. Даже если пользователь видит только 20 строк на экране.
Виртуализация решает это: рендерятся только видимые строки плюс небольшой буфер. Остальные существуют только как пустое пространство в DOM.
npm install react-windowimport { FixedSizeList } from 'react-window';
function OrderList({ orders }) {
return (
<FixedSizeList
height={600} // высота контейнера
itemCount={orders.length}
itemSize={60} // высота одной строки
width="100%"
>
{({ index, style }) => (
<div style={style}>
<OrderRow order={orders[index]} />
</div>
)}
</FixedSizeList>
);
}Вместо 300 компонентов в DOM - только 15-20 видимых. Разница в потреблении памяти и скорости рендера существенная.
Что в итоге
Три проблемы, три решения - и каждое из них давало реальный эффект:
Правильный деплой - самый большой выигрыш. CPU упал с 60-80% до 2-4%, рестарты исчезли, бизнес сэкономил на инфраструктуре. Dev-сервер в продакшне - это как ехать на машине с ручником.
useRef для таймеров - убрал постоянное пересоздание сотен интервалов. Браузер перестал тратить CPU на управление таймерами, интерфейс стал плавнее.
Виртуализация - сократила количество DOM-элементов с сотен до десятков. Особенно заметно на слабых устройствах.
Самый важный вывод: три года с проблемой просто мирились - пока рост нагрузки не заставил разобраться. И оказалось что главная причина была не в коде, а в способе запуска приложения. Это не упрёк - такое случается, особенно когда проект растёт постепенно и "так исторически сложилось". Но теперь первое что я проверяю при проблемах с производительностью - базовые вещи: как запущено, в каком режиме, какие ресурсы реально потребляет.
Иногда самые простые вещи дают самый большой эффект.