Оптимизация в React почти всегда сводится к двум факторам: объёму работы, которую выполняет JavaScript, и частоте (а также «стоимости») перерисовок компонентов. Сам React работает достаточно быстро, но в крупных интерфейсах даже небольшие архитектурные промахи и на первый взгляд безобидные ререндеры начинают заметно бить по производительности.

В данной статье мы расскажем про ключевые подходы к оптимизации React-приложений: как уменьшить количество лишних ререндеров, сократить объём вычислений при вводе и скролле и снизить нагрузку стартового JavaScript.

Сначала измеряем, потом оптимизируем

Прежде чем что-то «ускорять», важно понять, где именно теряется производительность и что реально является узким местом. 

Что измерять

Для оценки производительности фронтенда обычно используют Core Web Vitals: 

  • LCP (скорость загрузки основного контента),

  • CLS (стабильность верстки),

  • INP (отзывчивость интерфейса на действия пользователя). 

Быстрый способ увидеть метрики на реальном примере - PageSpeed Insights. Важно смотреть не только на общий score, а на конкретные значения метрик (в первую очередь LCP и CLS) и сравнивать их до/после изменений.

PageSpeed Insights: score и ключевые метрики
PageSpeed Insights: score и ключевые метрики

INP измеряет время от взаимодействия (клик, ввод, тап) до момента, когда пользователь видит обновление на экране. В рамках Core Web Vitals INP пришёл на смену FID, поэтому при анализе интерактивности и «лагов» интерфейса ориентироваться лучше именно на INP

Чем искать проблемы в React

Для быстрого первого среза удобно запускать Lighthouse прямо в Chrome DevTools. Итоговый Performance score полезен как индикатор, но дальше всегда нужно открывать детали отчёта и смотреть конкретные причины.

Lighthouse: общий score
Lighthouse: общий score

React Developer Tools + Profiler помогают увидеть коммиты рендера: какие компоненты перерисовывались и сколько времени это заняло. В самом React есть и программный <Profiler>, но на практике чаще достаточно DevTools.

В профайлере есть режим «почему это отрендерилось» (в DevTools он включается в настройках профайлера). Он позволяет быстро понять, какие изменения state или props вызвали ререндер.

Оптимизация без профайлера обычно превращается в бездумное использование useMemo/useCallback везде — и это часто делает только хуже.

Chrome Performance пригодится, если проблема не в React, а в layout/paint или long task.

В Lighthouse полезно смотреть блок Insights/Opportunities - это список конкретных проблем и примерной экономии. Он помогает быстро понять, упираетесь ли вы в сеть, кэш, изображения, лишний JavaScript или проблемы рендеринга (например forced reflow).

Lighthouse Insights: конкретные рекомендации и ожидаемая экономия.
Lighthouse Insights: конкретные рекомендации и ожидаемая экономия.

Почему компоненты вообще перерисовываются

Чтобы оптимизировать рендеринг, сначала важно понимать, откуда берутся ререндеры. Во многих случаях проблема не в «медленном React», а в том, что обновления запускаются слишком часто или затрагивают больше компонентов, чем нужно.

В упрощённом виде React-компонент перерисовывается, когда:

  • обновился state внутри компонента;

  • пришли новые props;

  • перерендерился родитель (и по цепочке — дочерние компоненты тоже);

  • изменилось значение Context у Provider (и обновились подписчики).

Важно помнить: render не всегда означает изменения в DOM. React может быстро сравнить результат и почти ничего не менять в DOM. Однако сам запуск функций компонентов и вычисления внутри них могут быть дорогими.

В dev-режиме с StrictMode многие вещи вызываются «лишний раз» специально, чтобы выявлять побочные эффекты. Это не проблема производительности в продакшене, но при отладке может вводить в заблуждение. Эти дополнительные вызовы относятся именно к dev-режиму — в продакшене их нет. Поэтому выводы о производительности лучше делать на production build или хотя бы сравнивать поведение без StrictMode, иначе легко принять «проверочную» работу React за реальную проблему.

Самые частые причины замедлений интерфейса и как их чинить

На практике проблемы производительности в React чаще всего возникают из-за лишних ререндеров, неудачного размещения состояния и «тяжёлых» операций, которые выполняются слишком часто. Ниже — типовые причины замедлений и подходы, которые помогают их устранить.

Держите state ближе к месту использования (state colocation)

Классическая проблема — один большой state «наверху», из-за которого при любом изменении начинает обновляться почти весь экран.

Плохо: верхний компонент обновляется на каждый ввод и тянет за собой соседние компоненты.

import { useState } from 'react';

export const Page = () => {
    const [search, setSearch] = useState('');


    const onSearch = (event) => {
        setSearch(event.target.value);
    };


    return (
        <div>
            <header>
                <input value={search} onChange={onSearch} />
            </header>
            <Sidebar />
            <ProductsList />
        </div>
    );
};

Что не так: при каждом вводе в input будет перерисовываться компонент Page, а вместе с ним — Sidebar и ProductsList, хотя значение search им вообще не нужно.

Хорошо: state находится рядом с компонентом, который его использует.

import { useState } from 'react';

const SearchInput = () => {
    const [search, setSearch] = useState('');


    const onSearch = (event) => {
        setSearch(event.target.value);
    };


    return <input value={search} onChange={onSearch} />;
};


export const Page = () => {
    return (
        <div>
            <header>
                <SearchInput />
            </header>
            <Sidebar />
            <ProductsList />
        </div>
    );
};

Состояние должно находиться как можно ближе к тому компоненту, которому оно действительно нужно. Поднимать state выше стоит только в случаях, когда он требуется нескольким компонентам или должен влиять на данные, запросы или навигацию.

Не храните derived state в state

Derived state — это данные, которые полностью вычисляются из props или state. Хранить их отдельно почти всегда лишнее: это добавляет синхронизацию, useEffect и дополнительные ререндеры, а также риск рассинхронизации.

Плохо: дублируем производное значение (count) в state и синхронизируем через эффект.

import { useEffect, useState } from 'react';

export const ItemsInfo = ({ items }) => {
    const [count, setCount] = useState(items.length);

    useEffect(() => {
        setCount(items.length);
    }, [items]);

    return (
        <div>
            <div>Total: {count}</div>
            <ul>
                {items.slice(0, 3).map((item) => (
                    <li key={id}>{title}</li>
                ))}
            </ul>
        </div>
    );
};

Хорошо: считаем derived-значение напрямую (или через useMemo, если вычисление реально дорогое).

export const ItemsInfo = ({ items }) => {
    const count = items.length;

    return (
        <div>
            <div>Total: {count}</div>
            <ul>
                {items.slice(0, 3).map((item) => (
                    <li key={id}>{title}</li>
                ))}
            </ul>
        </div>
    );
};

Разбивайте большие компоненты на части

Крупные монолитные компоненты сложно оптимизировать: любое изменение заставляет пересчитывать сразу всё. Декомпозиция упрощает оптимизацию — становится проще локализовать state и точечно применять memo. Само по себе разбиение не гарантирует уменьшение числа ререндеров, но делает это достижимым без «грязного» кода.

Плохо:

export const Dashboard = ({ userName }) => {
    return (
        <div>
            <div>{userName}</div>
            <div>Notifications</div>
            <div>Settings</div>
        </div>
    );
};

Хорошо:

const UserInfo = ({ userName }) => {
    return <div>{userName}</div>;
};


const Notifications = () => {
    return <div>Notifications</div>;
};


const Settings = () => {
    return <div>Settings</div>;
};


export const Dashboard = ({ userName }) => {
    return (
        <div>
            <UserInfo userName={userName} />
            <Notifications />
            <Settings />
        </div>
    );
};

React.memo работает только при стабильных props

React.memo — это HOC, который мемоизирует результат рендера компонента. Он принимает два аргумента: компонент и (опционально) функцию сравнения пропсов.

const Memoized = React.memo(Component, arePropsEqual);

По умолчанию React.memo делает поверхностное сравнение props: если пропсы «те же», ререндер пропускается. Однако для объектов, массивов и функций «то же самое» означает совпадение по ссылке, а не по содержимому. В документации прямо отмечается: если вы передаёте функцию в memo-компонент, сделайте её ссылку стабильной — например, вынесите функцию наружу или используйте useCallback. (https://react.dev/reference/react/memo)

Второй необязательный аргумент arePropsEqual(prevProps, nextProps) позволяет вручную управлять тем, когда пропускать ререндер:

  • возвращает true → props считаются равными → ререндер пропускается

  • возвращает false → ререндер выполняется

При этом важно помнить: arePropsEqual вызывается при проверке на каждое обновление, поэтому дорогие сравнения (особенно «глубокие») могут дать обратный эффект.

Плохо: при каждом ререндере создаётся новая функция, поэтому memo не даёт эффекта.

import { memo, useState } from 'react';

const SaveButton = memo(({ onSave }) => {
    return <button onClick={onSave}>Save</button>;
});

export const Page = () => {
    const [count, setCount] = useState(0);

    const onIncrement = () => {
        setCount((prev) => prev + 1);
    };

    return (
        <div>
            <SaveButton onSave={() => foo()} />
            <button onClick={onIncrement}>{count}</button>
        </div>
    );
};

Хорошо: стабильная ссылка на функцию.

import { memo, useState, useCallback } from 'react';

const SaveButton = memo(({ onSave }) => {
    return <button onClick={onSave}>Save</button>;
});

export const Page = () => {
    const [count, setCount] = useState(0);

    const onSave = useCallback(() => {
        foo();
    }, []);

    const onIncrement = () => {
        setCount((prev) => prev + 1);
    };

    return (
        <div>
            <SaveButton onSave={onSave} />
            <button onClick={onIncrement}>{count}</button>
        </div>
    );
};

Пример: кастомное сравнение props во втором аргументе memo. Здесь ререндер SaveButton будет выполняться только если реально изменились значимые для UI пропсы (в примере — disabled). Изменения ссылки на onSave будут игнорироваться сравнением, поэтому применять такой приём стоит только тогда, когда вы уверены, что это безопасно.

import { memo, useCallback, useState } from 'react';

const SaveButton = memo(
    ({ onSave, disabled }) => {
        return (
            <button onClick={onSave} disabled={disabled}>
                Save
            </button>
        );
    },
    (prevProps, nextProps) => {
        return prevProps.disabled === nextProps.disabled;
    }
);

export const Page = () => {
    const [count, setCount] = useState(0);
    const [disabled, setDisabled] = useState(false);

    const onIncrement = () => {
        setCount((prev) => prev + 1);
    };

    const onToggleDisabled = () => {
        setDisabled((prev) => !prev);
    };

    const onSave = useCallback(() => {
        foo();
    }, []);

    return (
        <div>
            <SaveButton onSave={onSave} disabled={disabled} />
            <button onClick={onIncrement}>{count}</button>
            <button onClick={onToggleDisabled}>
                Toggle disabled: {String(disabled)}
            </button>
        </div>
    );
};

useCallback и useMemo — не «ускоритель по умолчанию». Эти инструменты имеют смысл, когда:

  • вы действительно упираетесь в лишние ререндеры из-за нестабильных ссылок;

  • у вас есть дорогие вычисления, которые не должны повторяться на каждый ререндер.

Почему useMemo/useCallback иногда делают хуже:

  • это тоже работа: React должен хранить кэш, сравнивать зависимости и поддерживать эту логику;

  • код становится сложнее: легко ошибиться в зависимостях и получить баги (устаревшие значения, неожиданные эффекты);

  • мемоизация не лечит «тяжёлый UI»: если проблема в большом списке, дорогих layout/paint или тяжёлых дочерних компонентах, useMemo не спасёт — там нужны виртуализация, декомпозиция и отложенные обновления.

Кроме того, React постепенно движется в сторону автоматической мемоизации через React Compiler, и в документации отмечается, что это снижает потребность в ручном использовании useCallback.

useMemo — для вычислений, а не для «магии»

useMemo кэширует результат вычисления между ререндерами. Он полезен в случаях, когда вычисление действительно тяжёлое — например, при фильтрации и сортировке больших массивов.

Плохо: сортировка выполняется на каждый ввод, даже если сам список не менялся.

import { useState } from 'react';

export const Products = ({ items }) => {
    const [query, setQuery] = useState('');


    const filteredItems = items
        .filter((item) => item.toLowerCase().includes(query.toLowerCase()))
        .sort((firstItem, secondItem) => firstItem.localeCompare(secondItem));


    const handleQueryChange = (event) => {
        setQuery(event.target.value);
    };


    const itemsToDisplay = filteredItems.join(', ');


    return (
        <>
            <input value={query} onChange={handleQueryChange} />
            <div>{itemsToDisplay}</div>
        </>
    );
};

Хорошо: кэшируем тяжёлую часть и пересчитываем её только тогда, когда меняются items или query.

import { useState, useMemo } from 'react';

export const Products = ({ items }) => {
    const [query, setQuery] = useState('');


    const filteredItems = useMemo(() => {
        const normalizedQuery = query.toLowerCase();


        return items
            .filter((item) => item.toLowerCase().includes(normalizedQuery))
            .sort((firstItem, secondItem) => firstItem.localeCompare(secondItem));
    }, [items, query]);


    const handleQueryChange = (event) => {
        setQuery(event.target.value);
    };


    const itemsToDisplay = filteredItems.join(', ');


    return (
        <>
            <input value={query} onChange={handleQueryChange} />
            <div>{itemsToDisplay}</div>
        </>
    );
};

Context — один большой Provider может влиять на половину приложения

Если в одном Context хранится и тема, и авторизация, и другие данные, то любое изменение value приводит к обновлению всех consumers, даже если им нужна только небольшая часть состояния.

Плохо: один контекст используется «на всё».

import { createContext, useContext, useState } from 'react';


const AppContext = createContext(null);


const Header = () => {
    const context = useContext(AppContext);


    if (!context) {
        return null;
    }


    const textToDisplay = context.state.isLoggedIn ? 'Welcome' : 'Please log in';


    return <div>{textToDisplay}</div>;
};


const Content = () => {
    const context = useContext(AppContext);


    if (!context) {
        return null;
    }


    return <div className={context.state.theme}>Main</div>;
};


export const App = () => {
    const [state, setState] = useState({ isLoggedIn: false, theme: 'light' });


    return (
        <AppContext.Provider value={{ state, setState }}>
            <Header />
            <Content />
        </AppContext.Provider>
    );
};

Хорошо: разделить контексты по смыслу

import { createContext, useContext, useState } from 'react';


const AuthContext = createContext(false);
const ThemeContext = createContext('light');


const Header = () => {
    const isLoggedIn = useContext(AuthContext);


    const textToDisplay = isLoggedIn ? 'Welcome' : 'Please log in';


    return <div>{textToDisplay}</div>;


};


const Content = () => {
    const theme = useContext(ThemeContext);


    return <div className={theme}>Main</div>;
};


export const App = () => {
    const [isLoggedIn] = useState(false);
    const [theme] = useState('light');


    return (
        <AuthContext.Provider value={isLoggedIn}>
            <ThemeContext.Provider value={theme}>
                <Header />
                <Content />
            </ThemeContext.Provider>
        </AuthContext.Provider>
    );
};

Если value — это объект, важно следить за его ссылкой. Часто value нужно мемоизировать через useMemo, иначе Provider будет отдавать новый объект на каждый ререндер.

Плохо: на каждый ререндер создаётся новый объект.

<AppContext.Provider value={{ isLoggedIn, theme }}>
    {children}
</AppContext.Provider>

Хорошо: сделать ссылку на объект стабильной с помощью useMemo.

import { useMemo } from 'react';


const contextValue = useMemo(() => {
    return { isLoggedIn, theme };
}, [isLoggedIn, theme]);


return (
    <AppContext.Provider value={contextValue}>
        {children}
    </AppContext.Provider>
)

Списки — key и виртуализация

В списках производительность и корректность часто упираются в две вещи: стабильные key и виртуализацию при больших объёмах данных.

Нестабильные key (например, индекс) ломают повторное использование элементов и могут приводить к лишним обновлениям и визуальным артефактам.

Плохо: используется индекс в качестве key.

export const TodoList = ({ todos }) => {
    return (
        <ul>
            {todos.map((item, index) => (
                <li key={index}>{item.text}</li>
            ))}
        </ul>
    );
};

Хорошо:

export const TodoList = ({ todos }) => {
    return (
        <ul>
            {todos.map(({ text, id }) => (
                <li key={id}>{text}</li>
            ))}
        </ul>
    );
};

Виртуализация

Если у вас список на тысячи строк, ключевая оптимизация — не борьба с ререндерами, а сокращение числа DOM-узлов: держать в разметке одновременно тысячи элементов дорого для layout/paint и часто даёт лаги при скролле и вводе. В таких случаях помогает виртуализация: на экране рендерятся только видимые строки, а остальное подставляется по мере прокрутки. Обычно для этого используют react-window или react-virtualized.

Важно: виртуализация — не «ставим всегда». Она усложняет реализацию (динамические высоты, измерения, скролл-позиции, scrollTo, фокус/клавиатура, доступность) и имеет смысл, когда проблема уже подтверждена: вы видите подтормаживания со списками и по измерениям (Profiler/Performance/INP) упираетесь именно в объём DOM и рендеринг.

Тяжёлые обновления при вводе — useDeferredValue и useTransition

При вводе текста ключевая задача — сохранить отзывчивость интерфейса. Для этого React предоставляет useDeferredValue и useTransition. Они помогают разделить «срочные» обновления (ввод) и «тяжёлые» (например, обновление большого списка).

Пример: фильтрация большого списка. Поле ввода должно реагировать мгновенно, а список может обновляться «чуть позже».

import { useDeferredValue, useMemo, useState } from 'react';
export const Search = ({ items }) => {
    const [searchQuery, setSearchQuery] = useState('');
    const deferredSearchQuery = useDeferredValue(searchQuery);


    const filteredItems = useMemo(() => {
        const normalizedQuery = deferredSearchQuery.toLowerCase();


        return items.filter((item) => item.toLowerCase().includes(normalizedQuery));
    }, [items, deferredSearchQuery]);


    const handleSearchChange = (event) => {
        setSearchQuery(event.target.value);
    };


    const textToDisplay = `Found: ${filteredItems.length}`;


    return (
        <>
            <input value={searchQuery} onChange={handleSearchChange} />
            <div>{textToDisplay}</div>
        </>
    );
};

Если у вас есть «тяжёлое обновление состояния» (например, применение фильтров или переключение вкладок с тяжёлым контентом), его можно обернуть в transition, чтобы сохранить отзывчивость интерфейса:

import { useTransition, useState, useMemo } from 'react';


export const Filters = ({ items }) => {
    const [searchQuery, setSearchQuery] = useState('');
    const [isPending, startTransition] = useTransition();


    const filteredItems = useMemo(() => {
        const normalizedQuery = searchQuery.toLowerCase();


        return items.filter((item) => item.toLowerCase().includes(normalizedQuery));
    }, [items, searchQuery]);


    const handleOnClick = () => {
        startTransition(() => {
            setSearchQuery((previousQuery) => `${previousQuery}a`);
        });
    };


    const textToDisplay = isPending ? 'Updating' : `Items: ${filteredItems.length}`;


    return (
        <>
            <button onClick={handleOnClick}>Add filter</button>
            <div>{textToDisplay}</div>
        </>
    );
};

Code splitting — грузите тяжёлое только когда нужно

Самый простой выигрыш по скорости старта — не включать в initial bundle то, что нужно только на части экранов. Для этого используют React.lazy в связке с <Suspense>, чтобы подгружать тяжёлые компоненты по требованию.

Перед тем как резать код, полезно понять, что именно раздувает бандл. Для этого обычно подключают анализатор бандла — например, webpack-bundle-analyzer (webpack) или rollup-plugin-visualizer (Vite/Rollup). Он показывает, какие зависимости занимают больше всего места, и помогает найти реальные «кандидаты» на вынос в отдельный чанк (редкие страницы, редакторы, графики, карты, WYSIWYG и т.п.).

import { lazy, Suspense, useState } from 'react';
const Settings = lazy(() => import('./Settings'));
export const App = () => {
    const [open, setOpen] = useState(false);
    return (
        <div>
            <button onClick={() => setOpen(true)}>Open settings</button>
            {open ? (
                <Suspense fallback={<div>Loading...</div>}>
                    <Settings />
                </Suspense>
            ) : null}
        </div>
    );
};

Практический ориентир: code splitting лучше всего работает для маршрутов и редких фич, а не для мелких компонентов — иначе вы можете получить много чанков и дополнительные задержки на запросы.

Частая ловушка — эффекты зависят от объектов и функций, которые создаются на каждый ререндер

Распространённая проблема: в зависимости useEffect попадает объект или функция, которые пересоздаются на каждом ререндере. Тогда эффект запускается снова и снова, а в сочетании с setState это легко превращается в цикл запросов.

Плохо: options каждый раз новый, поэтому эффект постоянно срабатывает заново.

import { useState, useEffect } from 'react';


export const Users = ({ orgId }) => {
    const [users, setUsers] = useState([]);


    const options = { headers: { 'x-org': orgId } };


    useEffect(() => {
        const loadUsers = async () => {
            const response = await fetch('/api/users', options);
            const data = await response.json();
            setUsers(data);
        };


        loadUsers();
    }, [options]);


    return <div>{users.length}</div>;
};

Хорошо: сделать зависимость примитивной (например, orgId) или мемоизировать объект, чтобы ссылка оставалась стабильной.

import { useState, useEffect } from 'react';


export const Users = ({ orgId }) => {
    const [users, setUsers] = useState([]);


    useEffect(() => {
        const options = { headers: { 'x-org': orgId } };


        const loadUsers = async () => {
            const response = await fetch('/api/users', options);
            const data = await response.json();
            setUsers(data);
        };


        loadUsers();
    }, [orgId]);


    return <div>{users.length}</div>;
};

Сеть и данные — не стреляйте запросами на каждый ввод

Частая причина проблем — не ререндеры, а сеть и «тяжёлая работа» на каждый символ: запрос на каждый ввод, гонки ответов, лишние данные и отсутствие отмены. Это приводит и к лагам, и к «дёрганому» UI, и к лишней нагрузке на API.

Важно: проблема бывает не только в сети. Даже при локальном поиске (фильтрация/сортировка на клиенте) без ограничений легко получить подвисания: пользователь вводит несколько символов подряд, а обработка предыдущего ввода ещё не закончилась — из-за этого следующий символ может ощущаться «с задержкой» (падает отзывчивость, растёт INP). Поэтому техники вроде debounce полезны не только для API, но и для тяжёлых вычислений по вводу.

Debounce для поиска

Если вы отправляете запросы по вводу, обычно нужен debounce (например, 300 мс), чтобы не дергать API на каждый символ и не перегружать главный поток лишней работой.

import { useState, useEffect } from 'react';

export const SearchUsers = () => {
    const [query, setQuery] = useState('');
    const [debouncedQuery, setDebouncedQuery] = useState('');

    useEffect(() => {
        const timeoutId = setTimeout(() => {
            setDebouncedQuery(query);
        }, 300);

        return () => clearTimeout(timeoutId);
    }, [query]);

    const handleChange = (event) => {
        setQuery(event.target.value);
    };

    return (
        <>
            <input value={query} onChange={handleChange} />
            <div>Debounced: {debouncedQuery}</div>
        </>
    );
};

Отмена fetch через AbortController

Даже при наличии debounce полезно отменять предыдущий запрос, чтобы его ответ не перезаписал более свежий результат и чтобы не держать лишние запросы «в полёте».

import { useState, useEffect } from 'react';

export const Users = ({ query }) => {
    const [users, setUsers] = useState([]);

    useEffect(() => {
        const abortController = new AbortController();

        const loadUsers = async () => {
            try {
                const data = await fetchUsers({
                    query,
                    signal: abortController.signal,
                });

                setUsers(data);
            } catch (error) {
                if (error.name === 'AbortError') {
                    return;
                }
                return;
            }
        };

        loadUsers();

        return () => abortController.abort();
    }, [query]);

    return <div>{users.length}</div>;
};

На практике такие фрагменты кода обычно выносят в отдельные хуки (например, useDebouncedValue, useAbortableRequest), чтобы не размазывать debounce и AbortController по компонентам и не дублировать одну и ту же логику. Это базовая гигиена, которая снижает нагрузку и делает UI стабильнее — особенно на слабых устройствах и при медленной сети.

Итог

Оптимизация React-приложений на практике почти всегда сводится к снижению «лишней работы»: реже запускать тяжёлые обновления, ограничивать область ререндеров и уменьшать стоимость операций на главном потоке. Начинать стоит с измерений (Lighthouse/Profiler/Performance, Core Web Vitals), чтобы понимать, что именно ухудшает LCP/INP.

Дальше работают базовые приёмы: держать state ближе к месту использования, не хранить derived state, аккуратно применять React.memo/useMemo/useCallback (только там, где есть подтверждённая польза), правильно проектировать Context и следить за стабильностью ссылок. Для больших списков ключевое — стабильные key и, при реальных лагах, виртуализация. А для скорости старта — code splitting и загрузка тяжёлых модулей только там, где они действительно нужны.