Ведите разговор в фиксированном порядке: уточните требования, нарисуйте архитектуру клиента, опишите модель данных, договоритесь об API и оставшееся время потратьте на оптимизации. Этот порядок часто называют RADIO (Requirements, Architecture, Data model, Interface, Optimizations).
Требования (около 5 минут): что делает пользователь, нефункциональные цели (устройства, сеть, масштаб, доступность, языки) и что не входит в задачу. Запишите их: умение спросить тоже оценивают.
Архитектура: схема клиента из блоков: вью, слой стора или кэша, сетевой слой, сервер или BFF. Назовите, кто владеет каким состоянием.
Модель данных: какие сущности хранит клиент, серверный кэш против UI-состояния и где живёт каждое (память, URL, IndexedDB).
Интерфейс: контракт API (эндпоинты, пагинация, формат ошибок, канал реального времени) и пропсы ключевых компонентов.
Оптимизации: производительность, сеть, доступность, офлайн, безопасность, мониторинг. Углубитесь в две-три темы, важные для этого продукта, вместо перечисления всех.
Проговаривайте компромиссы вслух: «поллинг проще, но при такой частоте обновлений выигрывает WebSocket» ценится выше, чем правильный выбор без объяснений.
ReactSenior
Frontend system design
Проектирование ленты, автокомплита или чата, стратегия рендеринга, API и состояние, кэш, офлайн, обновления в реальном времени, микрофронтенды, сборка
Закрыто вопросов
0 / 21
- 0Повторить
- 10Часто спрашивают
Вопросы
Offset-пагинация запрашивает элементы по позиции («пропусти 40, дай 20»), курсорная просит «следующие 20 после этого элемента» по непрозрачному токену из предыдущей страницы. Ленте, которая меняется во время скролла, нужны курсоры: при вставке или удалении постов offset повторяет или пропускает элементы.
Offset (?offset=40&limit=20 или ?page=3): просто, можно прыгнуть на 17-ю страницу и показать общее количество. Подходит для админских таблиц и поиска с номерами страниц.
Почему offset ломает ленту: если пока пользователь читает первую страницу, сверху появились 5 постов, запрос с offset 20 вернёт 5 уже увиденных постов. Удаление так же приводит к пропуску элементов.
Курсор (?after=eyJpZCI6...&limit=20): сервер кодирует ключ сортировки последнего элемента (например, createdAt плюс id для разрешения равенства) и продолжает с него, поэтому вставки выше не сдвигают окно. Для базы это ещё и дешевле большого OFFSET.
Контракт: ответ содержит items и nextCursor (null в конце); клиент считает курсор непрозрачным и никогда не собирает его сам.
Новые посты сверху приходят отдельным запросом «since» или событием реального времени и ждут за плашкой «5 новых постов».
Решайте для каждого маршрута по двум вопросам: насколько свежим должен быть контент и нужно ли индексировать его и быстро показывать при холодном заходе. Редко меняющиеся страницы делают SSG или ISR, персональные и важные для SEO делают SSR со стримингом, а экраны приложения за логином могут остаться CSR.
SSG: HTML собирается при деплое и отдаётся с CDN. Самый быстрый TTFB и самый дешёвый хостинг. Документация, маркетинговые страницы, блог. Устаревает до следующей сборки.
ISR (ревалидация): SSG плюс фоновая перегенерация по таймеру или по вызову revalidatePath / revalidateTag в Next.js. Каталог товаров: достаточно свежо и всё ещё из кэша.
SSR: HTML рендерится на каждый запрос, поэтому может использовать куки и личные данные и при этом индексируется. Стоит CPU сервера и TTFB; стриминг через Suspense отправляет оболочку первой. В App Router серверные компоненты не пускают неинтерактивные части в JS-бандл.
CSR: пустая оболочка плюс бандл, который сам грузит данные. Просто хостить и нормально для дашбордов за авторизацией, но LCP ждёт JS и данные, а краулеры видят мало.
Смешивайте: один продукт обычно использует все четыре. Partial Prerendering (статическая оболочка с динамическими «дырами») ещё сильнее размывает границу.
Гидратация остаётся скрытой ценой SSR и SSG: страница выглядит готовой раньше, чем начинает реагировать на ввод, поэтому клиентские компоненты стоит держать маленькими.
Performance budget задаёт жёсткие лимиты, которые изменение не может превысить; о них договариваются заранее: пользовательские метрики вроде LCP и INP плюс величины, которые на них влияют, например размер JavaScript и число запросов. Работает он, только если CI валит сборку при превышении.
Пользовательские метрики (Core Web Vitals по 75-му перцентилю): LCP ≤ 2.5 с, INP ≤ 200 мс, CLS ≤ 0.1. INP заменил FID в 2024 году.
Бюджеты ресурсов: входной чанк (например, ≤ 100 KB gzip), весь JS до первой отрисовки, вес картинок на лендинге, число сторонних скриптов и веб-шрифтов.
Проверка в лаборатории: size-limit или проверка размера бандлером на каждом PR и Lighthouse CI на замедленном мобильном профиле с проверками LCP и TBT.
Полевой мониторинг: данные реальных пользователей через библиотеку web-vitals или CrUX с разбивкой по классу устройства и стране. Цифры с быстрого ноутбука прячут медианный Android-телефон.
Ответственность: превышение блокирует мерж, а поднять лимит можно только осознанным решением с причиной на ревью. Дашборд, который ничего не блокирует, расползается за несколько месяцев.
Бюджет задают на маршрут, а не на всё приложение: тяжёлый редактор может позволить себе больше, чем лендинг.
Если идти от пользователя: кэш запросов в памяти, постоянное хранилище (IndexedDB), кэш service worker, HTTP-кэш браузера, CDN и кэши самого сервера. Для каждого слоя нужен свой ответ на вопрос «когда это устарело», иначе пользователь видит старые данные и не может их обновить.
Клиент
Кэш запросов (TanStack Query, RTK Query, Apollo): убирает дублирующиеся запросы и показывает кэш, пока данные перепроверяются в фоне. staleTime настраивается на каждый запрос, инвалидация происходит после мутаций.
IndexedDB: данные, которые должны пережить перезагрузку или работать офлайн (черновики, последняя страница ленты, outbox). localStorage только для маленьких синхронных настроек.
Service worker: Cache Storage со своей стратегией на маршрут: cache-first для ассетов с хэшем, network-first для HTML, stale-while-revalidate для API-данных, которым можно слегка устареть.
HTTP и CDN
Статика с хэшем (app.3f9c1.js): Cache-Control: public, max-age=31536000, immutable. Новая сборка меняет имя файла, поэтому инвалидация вообще не нужна.
HTML и ответы API: no-cache с ETag или короткий max-age плюс stale-while-revalidate. HTML нельзя кэшировать надолго: он ссылается на ассеты конкретной сборки.
CDN: кэширует публичные ответы рядом с пользователем; персональные ответы помечены Cache-Control: private или несут правильный Vary, чтобы страница одного пользователя не досталась другому.
Инвалидация
Версионированные URL лучше, чем чистка кэша. Данные инвалидируйте по событию (мутация, сообщение из сокета), а не только по таймеру, и дайте пользователю pull-to-refresh как запасной выход.
Поставьте после последнего элемента сторожевой элемент (sentinel) и следите за ним через IntersectionObserver; когда он приближается к вьюпорту на заданный отступ, загружайте следующую страницу по курсору. Основная работа вокруг этого: виртуализация, восстановление скролла, ошибки и доступность.
Триггер: IntersectionObserver с rootMargin в один-два экрана, а не слушатель scroll. Его не нужно троттлить, и он не выполняет код на каждое событие скролла.
Без дублей запросов: игнорируйте триггер, пока страница грузится (isFetchingNextPage), и останавливайтесь, когда nextCursor равен null.
Виртуализация: после нескольких сотен строк узким местом становится сам DOM; рендерите только видимое окно (TanStack Virtual, react-window) и измеряйте строки разной высоты.
Восстановление скролла: после перехода в пост и возврата пользователь должен оказаться на том же элементе. Храните страницы в кэше, который переживает смену маршрута, и восстанавливайте позицию по id элемента, а не по пикселям.
Сдвиг макета: резервируйте место под картинки через width/height или aspect-ratio, иначе строки прыгают при загрузке.
Ошибки и конец списка: строка «Повторить» вместо молчаливой остановки, явное состояние конца и кнопка «Загрузить ещё» там, где пользователю нужен футер.
Доступность: role="feed" с aria-busy во время загрузки и article на каждый элемент.
Стройте её на нативном горизонтальном скролле с CSS scroll snap, грузите только текущий слайд и соседние, а требования доступности и автопрокрутки считайте обязательными, а не дополнительными. API принимает слайды как children и отдаёт активный индекс как необязательное контролируемое состояние.
Механика: overflow-x: auto со scroll-snap-type: x mandatory бесплатно даёт тач, трекпад и инерцию; IntersectionObserver на слайдах сообщает активный индекс. Двигать слайды JS-трансформациями стоит только ради эффектов, которых скролл не умеет.
Изображения: srcset и sizes для нужного разрешения, фиксированный aspect-ratio против сдвига макета, loading="lazy" для дальних слайдов и fetchpriority="high" на первом, если он и есть LCP-элемент.
API: index с onIndexChange (контролируемый режим) или defaultIndex, плюс loop и autoplay; слайды передаются как children, чтобы внутрь можно было положить что угодно.
Доступность: подписанный регион с aria-roledescription="carousel", подписи слайдов вида «3 из 8», настоящие button для «назад» и «вперёд», стрелки на клавиатуре и inert на скрытых слайдах.
Автопрокрутка: по умолчанию выключена; если включена, то останавливается при наведении и фокусе, имеет видимую кнопку паузы (WCAG 2.2.2) и учитывает prefers-reduced-motion.
Бесконечная прокрутка: клонированные крайние слайды дают больше всего багов, поэтому зацикливание стоит предлагать, только если продукту оно действительно нужно.
Делайте debounce нажатий, отменяйте устаревшие запросы, чтобы старый ответ никогда не перезаписал новый, кэшируйте результаты по запросу и рендерьте список как доступный combobox. Сам эндпоинт подсказок должен отвечать за десятки миллисекунд, иначе никакие трюки на клиенте задержку не спрячут.
Запросы: debounce примерно 200–300 мс и минимальная длина; предыдущий запрос отменяется через AbortController. Без этого ответы, пришедшие не по порядку, показывают результаты для «rea», когда в поле уже «react».
Кэш: небольшой LRU по нормализованному запросу, тогда возврат к прежнему запросу через backspace мгновенный. Популярные префиксы можно префетчить или фильтровать локально более широкий закэшированный результат.
Доступность: паттерн WAI-ARIA combobox: role="combobox" с aria-expanded, aria-controls и aria-activedescendant на поле, role="listbox" и role="option" на списке; стрелки двигают выделение, Enter выбирает, Escape закрывает.
UX: подсветка совпавшей части, недавние запросы при фокусе и раздельные состояния загрузки, пустого результата и ошибки.
Рендер: если фильтрация большого локального списка дорогая, useDeferredValue сохраняет отзывчивость ввода.
Мобильные: inputmode="search" и enterkeyhint="search", а список не должна перекрывать экранная клавиатура.
Long polling держит обычный запрос открытым, пока у сервера не появятся новости; SSE представляет собой один длинный HTTP-ответ со стримом событий от сервера к клиенту; WebSocket превращает соединение в двусторонний канал сообщений. SSE подходит для потоков к клиенту, WebSocket для частого обмена в обе стороны.
Long polling: работает через любой прокси и с любым сервером, но каждая пачка событий стоит полного запроса с заголовками, а курсор нужен, чтобы не потерять события между двумя запросами.
SSE (EventSource): один ответ text/event-stream, только текст, только одно направление. Браузер сам переподключается и отправляет Last-Event-ID, поэтому сервер может продолжить с места остановки. Ограничения: нельзя задать свои заголовки (авторизация через cookie), а по HTTP/1.1 доступно всего шесть соединений на домен на все вкладки.
WebSocket: начинается как HTTP, переключается ответом 101 Switching Protocols и дальше передаёт текстовые или бинарные сообщения в обе стороны. Переподключение, heartbeat, догрузку после разрыва и авторизацию вы строите сами.
Инфраструктура: SSE работает как обычный HTTP, если ни один прокси не буферизует ответ; WebSocket требует прокси с поддержкой upgrade и длинных таймаутов простоя. Обоим нужен слой pub/sub (Redis, NATS), чтобы достучаться до пользователей, подключённых к другому инстансу сервера.
Выбор: уведомления, ленты, прогресс задач, стриминг ответа LLM: SSE. Чат, мультиплеер, совместное редактирование: WebSocket. Редкие обновления или строгая корпоративная сеть: поллинг или long polling.
Первую страницу рендерьте на сервере ради быстрого LCP, дальше подгружайте страницы по курсору в нормализованный клиентский кэш, виртуализируйте список и сообщайте о новых постах плашкой, а не сдвигом контента. Лайки и комментарии обновляются оптимистично.
Требования и API
Скроллить персональную ленту из постов разных типов (текст, картинки, видео), ставить лайки, комментировать, видеть новые посты. Нефункциональные: быстрая первая отрисовка на мобильных, плавный скролл, устойчивость к плохой сети.
GET /feed?after=<cursor>&limit=20 возвращает { items, nextCursor }, где авторы и медиа вложены или лежат в отдельной карте, чтобы клиент не делал запрос на каждый пост.
Данные на клиенте
Нормализованный кэш: посты и пользователи по id, поэтому лайк обновляет пост везде, где он показан; сама лента хранится как список id по страницам.
Оптимистичные мутации: лайк сразу меняет счётчик и откатывается при ошибке (useOptimistic или onMutate в библиотеке запросов).
Новый контент: поллинг эндпоинта «since» или подписка на сокет, плашка «12 новых постов» и вставка только по нажатию, чтобы позиция чтения никогда не прыгала.
Рендер и производительность
Первая страница стримится через SSR, гидратируется, а следующие страницы грузятся на клиенте. После нескольких сотен элементов включается виртуализация, позиция восстанавливается при возврате из поста.
Типы постов собраны в реестр (type в компонент), редкие типы вроде опросов или live-видео грузятся лениво.
Медиа: srcset, зарезервированное соотношение сторон, ленивая загрузка ниже первого экрана и видео, которое играет без звука только пока видно.
Качество
role="feed" с article на каждый пост и действия, доступные с клавиатуры. Показы отслеживаются через IntersectionObserver и отправляются пачками через navigator.sendBeacon.
Сделайте URL единственным источником правды для фильтров, сортировки и страницы, рендерите по нему первую страницу на сервере и кешируйте HTML по нормализованному URL, а дальнейшие изменения грузите на клиенте, не убирая старые результаты. Какие комбинации фильтров индексировать, решает SEO.
URL как состояние
/catalog/phones?brand=apple,samsung&price=100-500&sort=price_asc&page=2 можно переслать, он переживает кнопку «Назад» и его может отрендерить сервер. Нормализуйте его (отсортированные ключи и значения, без значений по умолчанию), чтобы у одного набора фильтров был один URL, одна запись в кеше и один canonical.
Рендер и данные
SSR или ISR для первой страницы: краулерам и LCP-картинке нужен настоящий HTML. Персональные части (счётчик корзины, избранное, персональные цены) грузятся на клиенте, поэтому HTML остаётся кешируемым для всех анонимных посетителей.
Один запрос возвращает товары, общее число и счётчики фасетов («Samsung (124)») из поискового движка вроде Elasticsearch или OpenSearch.
Пока грузятся новые результаты, оставляйте старую сетку приглушённой (placeholderData: keepPreviousData или transition), а не показывайте скелетон.
Пагинация и SEO
Краулеры не скроллят: у нумерованных страниц должны быть настоящие ссылки <a href="?page=2"> и canonical на саму себя (Google больше не использует rel="next"). Кнопка «Показать ещё» может дописывать результаты для людей и при этом обновлять ?page=.
Индексируйте категорию и отобранные посадочные комбинации (категория плюс бренд) со своим title и заголовком; произвольным комбинациям фильтров и URL с сортировкой ставьте noindex или canonical на родителя, иначе краулеры потратят бюджет на почти одинаковые страницы.
Скорость и UX
Картинки с srcset, фиксированным соотношением сторон, fetchpriority="high" на одной-двух картинках в первом экране и ленивой загрузкой для остальных.
На телефоне фильтры открываются в шторке с кнопкой применения («Показать 1 234 товара»), а не отправляют запрос на каждый тап.
Один WebSocket на клиент для событий в обе стороны, оптимистичная отправка с id, который генерирует клиент и возвращает сервер, порядок по серверному sequence-номеру и досинхронизация с последнего увиденного номера после каждого переподключения. История показывается виртуализированным списком, прижатым к низу.
Транспорт
WebSocket для двустороннего обмена с низкой задержкой; SSE плюс обычные POST-запросы проще, если сообщения отправляют редко. Страницы истории и вложения всё равно идут по HTTP.
Переподключение с экспоненциальной задержкой и jitter; heartbeat обнаруживает мёртвое соединение, которое так и не прислало close.
После переподключения запрашивается всё после последнего seq в каждом диалоге, поэтому отправленное за это время не теряется.
Сообщения
Оптимистичная отправка: сообщение сразу появляется в статусе «отправляется» с clientId; подтверждение сервера несёт настоящий id и seq. clientId служит и ключом идемпотентности, поэтому повторная отправка не создаёт дубль.
Порядок: сортировка по серверному seq, а не по часам устройства, которые могут врать; неотправленные сообщения остаются внизу.
Ошибки: неудачное сообщение остаётся с действием «Повторить», а неотправленные лежат в outbox в IndexedDB и переживают перезагрузку.
UI
Старая история грузится вверх по курсору; список прижат к низу и сохраняет позицию, когда сообщения добавляются в начало.
Счётчики непрочитанных и отметки о прочтении идут отдельными лёгкими событиями; индикатор «печатает» троттлится до одного события раз в несколько секунд.
Несколько вкладок: одно соединение на всех через SharedWorker или лидера в BroadcastChannel, либо осознанно по сокету на вкладку.
Стройте API вокруг того, что нужно экранам, а не вокруг таблиц базы. По умолчанию подходит обычный REST; GraphQL окупается, когда многим клиентам нужны разные срезы богатого графа данных; Backend for Frontend (BFF) собирает несколько сервисов в один ответ под экран, и им владеет фронтенд-команда.
Варианты
REST: просто, кэшируется по URL, легко мониторить. Риск в лишних данных и водопадах запросов (/user, потом /user/posts, потом /posts/:id/comments).
GraphQL: клиент за один запрос получает ровно нужные поля, схема типизирована, а нормализованный кэш (Apollo, Relay, urql) идёт в комплекте. Цена: без persisted queries нет кэширования по URL, нужны лимиты на стоимость запроса, а серверный N+1 лечится через DataLoader.
BFF: тонкий сервер на каждый тип клиента (веб, мобильный), который ходит в сервисы и отдаёт JSON под экран. В Next.js BFF часто живёт в том же репозитории в виде route handlers и серверных компонентов.
Детали контракта
Курсорная пагинация, единый формат ошибок (code, message, ошибки полей) и ключи идемпотентности на записи, которые клиент может повторить.
Типы генерируются из схемы (OpenAPI или GraphQL codegen), поэтому ломающее изменение валит сборку фронтенда, а не прод.
API развивается добавлением полей и пометкой старых как deprecated, но не их поломкой: мобильные клиенты месяцами сидят на старых версиях.
Частичный отказ: один медленный сервис ухудшает один виджет, а не всю страницу.
Отвяжите частоту обновлений от частоты рендера: держите входящие данные в сторе вне React, схлопывайте обновления по ключу и рендерьте не чаще раза в кадр (или нескольких раз в секунду), причём только виджеты, чьи данные изменились. Графики с большим числом точек рисуются на canvas, а не в SVG.
Входящие данные
Транспорт: SSE для потока от сервера к клиенту (автоматическое переподключение, обычный HTTP), WebSocket, если клиент тоже часто меняет подписки, и поллинг для данных, которые меняются раз в минуту.
Узкая подписка: клиент сообщает серверу, какие виджеты и временные диапазоны видны, а сервер агрегирует или прореживает данные вместо отправки каждого сырого тика.
Рендер
Сообщения копятся в буфере и сбрасываются раз в requestAnimationFrame; 500 изменений в секунду всё равно никто не прочитает, а хранение только последнего значения по ключу ограничивает память.
Внешний стор читается через useSyncExternalStore с узкими селекторами, поэтому тик по одному тикеру ререндерит одну строку, а не всю таблицу.
Canvas или WebGL для графиков на тысячи точек, виртуализированные таблицы и никаких CSS-переходов на каждое изменение значения.
Устойчивость
Замедляйтесь или вставайте на паузу, пока вкладка скрыта (visibilitychange), после переподключения загружайте свежий снимок данных и показывайте их возраст («обновлено 3 с назад»), чтобы устаревшие цифры было видно.
Отдавайте оболочку приложения из кэша service worker, сделайте IndexedDB хранилищем, из которого читает UI, а записи складывайте в outbox, который синхронизируется при появлении сети. Самое сложное здесь разрешение конфликтов, и решать его нужно для каждой сущности вместе с бэкендом.
Оболочка приложения
Service worker (Workbox) заранее кэширует сборку с хэшами и отдаёт HTML по стратегии network-first с запасным вариантом из кэша.
Продумайте обновления: новый worker ждёт, пока закроются все вкладки, поэтому приложение показывает «Доступна новая версия, перезагрузите» вместо смешивания двух сборок.
Данные
UI читает из локального хранилища (IndexedDB через Dexie или кэш запросов, сохранённый в IndexedDB), а сеть только обновляет его, поэтому экран рендерится одинаково с подключением и без.
Запросите постоянное хранилище через navigator.storage.persist(); Safari может удалить записанные скриптом данные после 7 дней без посещений.
Запись
Outbox: каждая мутация попадает в очередь в IndexedDB с id, сгенерированным на клиенте, применяется оптимистично и отправляется по порядку, когда сеть появилась. Благодаря id повторы идемпотентны на сервере.
Повторяйте отправку по событию online и при старте приложения; Background Sync есть только в Chromium, поэтому единственным триггером он быть не может.
Конфликты
Для настроек хватит «последняя запись побеждает» по каждому полю. Документам нужны версии (If-Match с ETag, ответ 409 показывается пользователю) или CRDT (Yjs, Automerge) для совместного редактирования.
Честно показывайте состояние синхронизации: «Сохранено на устройстве», «Синхронизация», «Конфликт».
Главная задача: сливать одновременные правки так, чтобы копии сошлись, через Operational Transformation на центральном сервере (как в Google Docs) или через CRDT вроде Yjs, который сливает обновления в любом порядке и офлайн. Вокруг: сервер синхронизации на WebSocket, журнал обновлений, presence и личный undo.
Слияние правок
Почему наивная синхронизация ломается: Алиса вставляет текст в позицию 0, а Боб одновременно в позицию 3; если применять правки как есть, правка Боба у Алисы попадает не туда, и копии расходятся. Last write wins для всего документа теряет чужую работу.
OT: правки представлены операциями вроде insert(3, "s"); сервер упорядочивает их и трансформирует каждую относительно уже применённых параллельных, сдвигая позиции. Нужен центральный сервер, а корректные трансформации для rich text писать сложно.
CRDT: у каждого символа есть уникальный id (id клиента плюс счётчик) и позиция относительно соседей, поэтому обновления коммутируют и копии сходятся без координатора, офлайн или peer to peer. Цена: метаданные и tombstones для удалённого текста; Yjs, Automerge и Loro держат их компактными.
Архитектура
Редактор (ProseMirror или Tiptap, Lexical, CodeMirror) привязан к общему документу, сразу применяет локальные правки и отправляет маленькие бинарные обновления по WebSocket.
Сервер синхронизации на документ (y-websocket, Hocuspocus или сервис вроде Liveblocks) пересылает обновления; все клиенты одного документа попадают на один инстанс или делят канал pub/sub. Он проверяет права на каждое обновление, потому что клиент только для чтения всё равно может его отправить.
Хранилище дописывает обновления в журнал и сжимает их в снапшоты; IndexedDB на клиенте даёт офлайн-редактирование и мгновенное повторное открытие.
Помимо текста
Presence (курсоры, выделения, кто сейчас в документе) эфемерно: передаётся отдельно (awareness в Yjs), троттлится и никогда не сохраняется в документе.
Undo откатывает только ваши изменения, а не набранный коллегой текст.
Загружайте файл напрямую в объектное хранилище по presigned URL частями, которые грузятся параллельно и повторяются по отдельности, показывайте прогресс по частям через XMLHttpRequest и запоминайте готовые части, чтобы после обрыва или перезагрузки докачать файл, а не начинать заново.
Поток
POST /uploads начинает multipart-загрузку в S3 (или GCS, R2) и возвращает uploadId; бэкенд подписывает короткоживущий URL для каждой части, поэтому байты файла не проходят через ваши серверы приложения.
Клиент режет File через file.slice(), который ничего не копирует, на части, скажем, по 8 МиБ (S3 требует минимум 5 МиБ на часть, кроме последней, и допускает 10 000 частей) и отправляет по три-четыре одновременно.
Ответ на каждую часть содержит ETag; клиент отправляет их список в POST /uploads/:id/complete, и хранилище собирает файл. CORS-правила бакета должны открывать заголовок ETag, иначе JavaScript не сможет его прочитать.
Прогресс, отмена, повтор, докачка
Прогресс: fetch не сообщает о прогрессе отправки, поэтому части идут через XMLHttpRequest и upload.onprogress; общий прогресс равен сумме по частям.
Повтор упавшей части с экспоненциальной задержкой, но никогда не всего файла; истёкший URL подписывается заново.
Отмена: прервите текущие запросы, попросите бэкенд отменить загрузку и держите lifecycle-правило бакета, которое удаляет незавершённые загрузки через сутки.
Докачка: храните uploadId и отпечаток файла (имя, размер, lastModified) в IndexedDB. После перезагрузки браузер не может сам заново открыть файл, поэтому пользователь выбирает его ещё раз; клиент сверяет отпечаток, спрашивает, какие части уже есть, и досылает остальные. Протокол tus (tus-js-client, Uppy) упаковывает ту же идею.
Вокруг загрузки
Менеджер загрузок вне компонентов роутов (в сторе или воркере) не даёт загрузкам оборваться при навигации, показывает очередь с состоянием каждого файла и предупреждает через beforeunload, пока что-то отправляется.
Начните с дизайн-токенов как единого источника цветов, отступов и типографики, постройте поверх доступные примитивы и поставляйте их версионированным пакетом с документацией, визуальными тестами и понятным процессом контрибьюта. Успех определяет внедрение в командах, а не число компонентов.
Слои
Токены: описаны один раз (JSON в формате W3C design tokens, сборка через Style Dictionary) и компилируются в CSS custom properties, поэтому тема или тёмный режим означают замену переменных, а не пересборку.
Примитивы: headless-поведение с доступностью (фокус, клавиатура, ARIA) из Radix, React Aria или своего кода, стилизованное токенами.
Компоненты и паттерны: Button, Dialog, Combobox, затем паттерны уровня страницы; композиция через children и слоты вместо десятков булевых пропсов.
Поставка
Пакет в монорепозитории с semver и Changesets; ESM с поддержкой tree shaking, объявленный sideEffects в package.json и CSS, который не протекает глобальными селекторами.
Ломающие изменения выходят с кодмодами и периодом deprecation, поэтому продуктовые команды обновляются в своём темпе.
Качество
Storybook как документация и стенд, визуальные регрессионные тесты (Chromatic или скриншоты Playwright) и проверки доступности axe в CI.
Правила линтера запрещают сырые цвета и размеры в продуктовом коде, чтобы использовать систему было проще всего.
Управление
Небольшая core-команда, RFC на каждый новый компонент и метрики внедрения по командам. Компонент, нужный одной команде, живёт у неё, пока он не понадобится второй.
Выносите строки из кода под стабильные ключи в формате ICU MessageFormat, форматируйте числа и даты через Intl, грузите только сообщения активной локали для маршрута и используйте логические CSS-свойства, чтобы письмо справа налево включалось сменой dir. Тексты возит пайплайн переводов, а не разработчики.
Сообщения
ICU-сообщения умеют плюралы, род и select: {count, plural, one {# file} other {# files}}. Русскому нужны one, few, many и other, поэтому склейка строк никогда не работает.
Ключи описывают смысл (cart.checkout.button), а не английский текст, и переводчики получают контекст и скриншот.
react-intl (FormatJS) или i18next с ICU; ключи извлекаются автоматически, и CI падает на отсутствующих или неиспользуемых.
Форматирование и вёрстка
Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat и Intl.PluralRules вместо самописных форматов, с часовым поясом пользователя, а не сервера.
Справа налево: <html dir="rtl">, margin-inline-start вместо margin-left и отзеркаленные иконки направлений. Немецкий текст примерно на 30% длиннее, поэтому кнопок фиксированной ширины быть не должно.
Доставка
Один JSON-чанк на локаль и маршрут, загружаемый через динамический import(); пользователь локали по умолчанию никогда не качает остальные сорок.
Локаль живёт в URL (/de/...) ради SEO и ссылок, которыми можно поделиться, с альтернативами hreflang; сохранённая настройка только выбирает редирект.
Псевдолокализация (строки с диакритикой и удлинением) в разработке ловит захардкоженный текст и обрезку раньше переводчиков.
Вычисляйте флаги до первого рендера, на сервере или на edge, и отдавайте значения вместе с HTML, чтобы клиент гидрировался с тем же вариантом. Распределение детерминированное и постоянное, показ (exposure) логируется, когда вариант увидели, а у каждого флага есть владелец и дата удаления.
Почему наивный вариант мигает
Клиентский SDK, который загружает флаги после старта бандла, сначала рендерит вариант по умолчанию, а потом переключается: пользователь видит оба варианта, вёрстка сдвигается, а данные эксперимента загрязняются. Сниппеты, которые прячут страницу до загрузки флагов, меняют мелькание на худший LCP.
Ранние вычисления
SSR или edge: сервер читает id пользователя или cookie с анонимным id и вычисляет правила по локальной копии SDK, за микросекунды, а не сетевым запросом, затем рендерит нужный вариант и встраивает значения для гидрации.
Статические страницы на CDN: edge middleware назначает группу, ставит cookie и переписывает запрос на заранее собранный вариант (/pricing в /pricing-b). Ключ кеша должен включать вариант, иначе один вариант отдадут всем.
Чистый SPA: стартуйте со значений, закешированных при прошлом визите, обновляйте их в фоне и применяйте изменения при следующей навигации; ждёт только первый визит, и скелетон показывает только экспериментальная часть.
Распределение и измерение
Группу определяет хеш от ключа эксперимента и id пользователя: один и тот же пользователь получает один вариант на любом сервере и устройстве, а эксперименты не зависят друг от друга.
Логируйте показ, когда вариант отрендерился, а не при распределении, иначе пользователи, которые так и не дошли до оформления заказа, размоют результат.
Гигиена
Держите один типизированный реестр флагов с владельцем и датой истечения, и пусть CI сообщает о просроченных: старый флаг становится мёртвой веткой, которую никто не решается удалить.
Отдавайте браузеру вычисленные значения, а не правила таргетинга, в которых могут быть списки пользователей и названия ещё не вышедших фич.
Микрофронтенды окупаются, когда много независимых команд выпускают части одного продукта в своём релизном цикле; для одной команды они добавляют затраты без пользы. Делите по бизнес-доменам или маршрутам, интегрируйте на уровне маршрутов или в рантайме через Module Federation и делайте общим как можно меньше.
Когда
Оправданы: пять и больше команд, независимые деплои как жёсткое требование или легаси-приложение, которое заменяют по частям (паттерн strangler).
Не оправданы: одна команда или проблема, которую уже решает монорепозиторий с контролируемыми границами модулей.
Как делить и интегрировать
По маршрутам (проще всего): каждое приложение владеет своими URL, а тонкая оболочка или edge-прокси маршрутизирует между ними. Переход между приложениями означает полную перезагрузку страницы, если им не управляет оболочка вроде single-spa.
По виджетам в рантайме: Module Federation (webpack 5, Rspack или @module-federation/vite) загружает компонент, который удалённое приложение отдаёт из своего деплоя.
Пакетами при сборке: версионированные npm-пакеты проще, но каждое изменение требует релиза хоста, и деплои перестают быть независимыми.
Цена, которую нужно заложить
Общие зависимости: React должен быть синглтоном (shared: { react: { singleton: true } }), иначе с двумя копиями ломаются хуки; расхождение версий между командами становится постоянным налогом.
Единообразие: одна дизайн-система и один набор токенов, иначе продукт выглядит сшитым из кусков.
Коммуникация: URL, кастомные DOM-события или крошечная шина событий; никакого общего глобального стора.
Производительность и сбои: дублированные библиотеки и водопады загрузки remote entry требуют бюджета на каждое приложение, а error boundary вокруг каждого remote не даёт неудачному деплою другой команды обнулить страницу.