Узнайте, как создавать быстрые списки в дашборде на 100k строк с помощью пагинации, виртуализации, умной фильтрации и оптимальных запросов, чтобы внутренние инструменты работали шустро.

Экран списка обычно кажется нормальным, пока вдруг не перестаёт быть таким. Пользователи замечают мелкие подвисания: прокрутка дергается, страница на мгновение «застревает» после обновления, фильтры отвечают секунды, и после каждого клика видно спиннер. Иногда вкладка браузера кажется зависшей, потому что поток UI занят.
Порог в 100k строк — распространённая точка, когда нагрузка проявляет себя сразу во всех местах. Набор данных всё ещё нормален для базы, но он достаточно велик, чтобы мелкие неэффективности стали заметны в браузере и по сети. Если пытаться показать всё сразу, простой экран превращается в тяжёлый конвейер.
Цель не в том, чтобы отрисовать все строки. Цель — помочь человеку быстро найти нужное: правильные 50 строк, следующую страницу или узкую выборку по фильтру.
Полезно разбить работу на четыре части:
Если хоть одна часть дорогая, весь экран ощущается медленным. Простой поиск может запустить запрос, который сортирует 100k строк, возвращает тысячи записей и затем заставляет браузер отрисовать их все. Вот почему при наборе текста возникает лаг.
Когда команды быстро создают внутренние инструменты (включая платформы быстрой разработки вроде Koder.ai), экраны списков часто первые, где реальный рост данных показывает разрыв между «работает на демо-данных» и «ощущается мгновенно каждый день».
Прежде чем оптимизировать, решите, что значит «быстро» для этого экрана. Многие команды гонятся за пропускной способностью (загрузить всё), тогда как пользователям важнее низкая задержка (видеть обновления быстро). Список может казаться мгновенным, даже если он никогда не подгружает все 100k строк, при условии, что он быстро реагирует на прокрутку, сортировку и фильтры.
Практичная цель — время до первой строки, а не время до полной загрузки. Пользователи доверяют странице, когда видят первые 20–50 строк быстро и взаимодействие остаётся плавным.
Выберите небольшой набор чисел, которые можно отслеживать при каждом изменении:
COUNT(*) и широкие SELECT)Эти метрики соответствуют распространённым симптомам. Если CPU браузера скачет при прокрутке — фронтенд делает слишком много работы на строку. Если спиннер ждёт, а прокрутка потом в порядке — обычно проблема на бэкенде или в сети. Если запрос быстрый, но страница всё равно зависает — почти наверняка это рендеринг или тяжёлая клиентская обработка.
Сделайте простой эксперимент: оставьте UI без изменений, но временно ограничьте бэкенд так, чтобы он возвращал только 20 строк с теми же фильтрами. Если стало быстро — узкое место в размере загрузки или времени запроса. Если всё ещё медленно — смотрите в сторону рендеринга, форматирования и компонентов на строку.
Пример: экран Заказов внутри компании тормозит при вводе поиска. Если API возвращает 5 000 строк и браузер фильтрует их на каждом нажатии клавиши, набор текста будет лагать. Если API занимает 2 секунды из‑за COUNT на нефильтрованном поле без индекса — вы увидите ожидание до изменения любой строки. Разные причины, одна и та же жалоба пользователя.
Часто узким местом оказывается браузер. Список может ощущаться медленным, даже если API быстрый, просто потому что страница пытается отрисовать слишком много. Первое правило: не рендерьте тысячи строк в DOM одновременно.
Ещё до полной виртуализации держите каждую строку лёгкой. Строка с множественными вложенными контейнерами, иконками, тултипами и сложными условными стилями в каждой ячейке дорого обходится при прокрутке и обновлениях. Предпочитайте простой текст, пару небольших бейджей и только 1–2 интерактивных элемента на строку.
Стабильная высота строки помогает больше, чем кажется. Когда все строки одной высоты, браузер лучше предсказывает раскладку, и прокрутка остаётся плавной. Строки переменной высоты (переносимый текст, разворачиваемые заметки, большие аватары) вызывают дополнительные измерения и перерасчёт. Если нужны дополнительные детали, подумайте о боковой панели или единой разворачиваемой области, а не о многострочной строке.
Форматирование тоже съедает ресурсы. Даты, валюты и тяжёлая работа со строками накапливаются при повторении во множестве ячеек.
Если значение не видно, не вычисляйте его заранее. Кешируйте дорогие результаты форматирования и вычисляйте их по запросу, например когда строка становится видимой или пользователь открывает её.
Быстрый набор правил, который часто даёт заметный выигрыш:
Пример: таблица Счётов внутри компании, которая форматирует 12 колонок с валютой и датами, будет дергаться при прокрутке. Кеширование отформатированных значений по счёту и отложенная обработка для невидимых строк могут сделать её ощущаемо мгновенной ещё до глубокой работы с бэкендом.
Виртуализация означает, что таблица рисует только те строки, которые видны (плюс небольшой буфер сверху и снизу). По мере прокрутки она переиспользует одни и те же DOM-элементы и просто подставляет в них данные. Так вы не заставляете браузер рисовать десятки тысяч компонентов строк одновременно.
Виртуализация хорошо подходит для длинных списков, широких таблиц или «тяжёлых» строк (аватары, статус‑чипы, меню действий, тултипы). Она также полезна, когда пользователи много прокручивают и ожидают плавного непрерывного просмотра, а не постраничной навигации.
Это не магия. Несколько вещей часто создают проблемы:
Самый простой подход — скучный: фиксированная высота строки, предсказуемые колонки и немного интерактивных виджетов в строке.
Можно сочетать оба приёма: запрашивать из сервера порцию данных (пагинация или cursor‑load more), а внутри этой порции виртуализировать рендер. Практичный паттерн — запрашивать обычный размер страницы (часто 100–500 строк), виртуализировать внутри страницы и давать понятные элементы управления для перехода между страницами. Если используете бесконечную прокрутку, добавьте видимый индикатор «Загружено X из Y», чтобы пользователи понимали, что они видят не всё сразу.
Если нужен экран списка, который остаётся удобным по мере роста данных, пагинация обычно самый безопасный выбор. Она предсказуема, хорошо подходит для админских рабочих процессов (просмотр, редактирование, утверждение) и поддерживает распространённые задачи вроде «экспортировать страницу 3 с этими фильтрами» без сюрпризов. Многие команды возвращаются к пагинации после экспериментов с более эффектными методами прокрутки.
Бесконечная прокрутка может быть приятной для случайного просмотра, но у неё есть скрытая цена. Люди теряют ощущение позиции, кнопка «назад» часто не возвращает их на то же место, и длительные сессии накапливают память по мере подгрузки всё новых строк. Компромисс — кнопка «Загрузить ещё», которая всё ещё использует постраничную загрузку и помогает пользователям не потерять контекст.
Offset-пагинация — классический подход page=10&size=50. Она проста, но может замедляться на больших таблицах, потому что базе приходится пропускать много строк, чтобы добраться до поздних страниц. Кроме того, когда приходят новые записи, элементы могут «переместиться» между страницами.
Keyset-пагинация (cursor) просит «следующие 50 строк после последней видимой», обычно по id или created_at. Она остаётся быстрой, потому что не требует больших пропусков и подсчётов.
Практическое правило:
Пользователи любят видеть общее число, но полный «count all matching rows» может быть дорогим при тяжёлых фильтрах. Варианты: кешировать счётчики для популярных фильтров, обновлять количество в фоне после загрузки страницы или показывать приближённый счёт (например, «10,000+»).
Пример: экран Заказов может отображать результаты мгновенно с keyset-пагинацией, а точный общий счёт подгружать только когда пользователь перестаёт менять фильтры на секунду.
Если вы строите это в Koder.ai, продумайте поведение пагинации и подсчёта на этапе спецификации экрана, чтобы сгенерированные бэкенд‑запросы и состояние UI не конфликтовали позже.
Большинство экранов списка кажутся медленными, потому что они стартуют «широко открытыми»: загружают всё, а потом предлагают сузить. Сделайте наоборот. Начинайте с умных дефолтов, которые возвращают небольшую полезную выборку (например: последний 7‑дневный период, «Мои», Статус: Open), а опцию «Всё время» делайте явным выбором.
Текстовый поиск — ещё одна ловушка. Если вы выполняете запрос на каждый ввод, вы создаёте очередь запросов и UI, который мигает. Добавьте дебаунс для поля поиска, отменяйте старые запросы при новом вводе. Простое правило: если пользователь ещё печатает — не стучите на сервер.
Фильтрация кажется быстрой, когда она ещё и понятна. Показывайте активные фильтры в виде чипов над таблицей, чтобы пользователь видел, что включено, и мог убрать фильтр одним кликом. Делайте метки чипов человечными, а не сырыми именами полей (например, Owner: Sam вместо owner_id=42). Когда кто‑то говорит «мои результаты пропали», чаще всего виноват невидимый фильтр.
Паттерны, которые сохраняют отзывчивость больших списков без усложнения UI:
Сохранённые представления — тихие герои. Вместо того чтобы учить пользователей строить идеальную разовую комбинацию фильтров, дайте им несколько пресетов под реальные рабочие потоки. Команда операций может переключаться между «Ошибные платежи сегодня» и «Крупные клиенты» одним кликом. Такие виды проще поддерживать быстрыми на бэкенде.
Если вы строите внутренний инструмент в чат‑ориентированном конструкторе вроде Koder.ai, рассматривайте фильтры как часть продуктового потока, а не как надстройку. Начните с частых вопросов, затем спроектируйте дефолт‑вид и сохранённые представления вокруг них.
Экран списка редко нуждается в тех же данных, что и страница деталей. Если API возвращает всё обо всём, вы платите дважды: база делает лишнюю работу, и браузер получает и рендерит больше, чем нужно. Формирование запроса — привычка запрашивать только то, что нужно списку сейчас.
Начните с возврата только колонок, нужных для строки. Для большинства дашбордов это id, пара меток, статус, владелец и временные метки. Большие тексты, JSON‑поля и вычисляемые поля подождут, пока пользователь не откроет строку.
Избегайте тяжёлых JOINов для первого рендера. JOINы ок, когда они проходят по индексам и возвращают небольшие результаты, но они становятся дорогими, когда вы объединяете множество таблиц и затем сортируете или фильтруете по связанным данным. Простой паттерн: получить список из одной таблицы быстро, а связанные детали загружать по требованию (или батчить для видимых строк).
Ограничьте варианты сортировки и сортируйте по индексированным колонкам. «Сортировать по чему угодно» выглядит полезно, но часто приводит к медленным сортировкам на больших наборах. Предпочитайте несколько предсказуемых опций вроде created_at, updated_at или status, и убедитесь, что эти колонки проиндексированы.
Будьте осторожны с серверной агрегацией. COUNT(*) по огромному фильтрованному набору, DISTINCT по широкой колонке или вычисление общего числа страниц могут доминировать над временем ответа.
Практический подход:
COUNT и DISTINCT опциональными; кешируйте или приближайте, когда возможноЕсли вы строите внутренние инструменты на Koder.ai, заранее определите лёгкий запрос списка отдельно от запроса деталей, чтобы UI оставался шустрым по мере роста данных.
Чтобы экран списка оставался быстрым при 100k строк, база должна делать меньше работы на запрос. Большинство медленных списков — не «слишком данных», а неверный паттерн доступа к данным.
Начните с индексов, которые соответствуют реальному использованию. Если список обычно фильтруют по status и сортируют по created_at, нужен индекс, который это поддерживает в таком порядке. Иначе СУБД может просканировать гораздо больше строк, чем вы ожидаете, а затем сортировать их, что быстро становится дорогим.
Исправления, которые обычно дают наибольший выигрыш:
tenant_id, status, created_at).OFFSET страницам. OFFSET заставляет базу проходить мимо множества строк только чтобы пропустить их.Простой пример: таблица Заказов, которая показывает имя клиента, статус, сумму и дату. Не джойните все связанные таблицы и не тяните полные примечания к заказу для вида списка. Верните только колонки, используемые в таблице, остальное загружайте отдельным запросом при клике на заказ.
Если вы строите с платформой вроде Koder.ai, держите этот подход даже когда UI генерируется из чата: убедитесь, что сгенерированные эндпоинты поддерживают cursor‑пагинацию и выборочные поля, чтобы нагрузка на базу оставалась предсказуемой по мере роста таблицы.
Если страница списка сегодня медленная, не начинайте с переписывания всего. Сначала зафиксируйте, как выглядит нормальное использование, затем оптимизируйте этот путь.
Определите вид по умолчанию. Выберите дефолтные фильтры, порядок сортировки и видимые колонки. Списки тормозят, когда пытаются показывать всё по умолчанию.
Выберите стиль пагинации, соответствующий использованию. Если пользователи обычно просматривают первые несколько страниц, классическая пагинация подходит. Если люди прыгают глубоко (страница 200+) или нужна стабильная производительность независимо от глубины — используйте keyset‑пагинацию (по стабильной сортировке вроде created_at + id).
Добавьте виртуализацию для тела таблицы. Даже если бэкенд быстрый, браузер может захлебнуться при рендере слишком многих строк.
Сделайте поиск и фильтры отзывчивыми. Дебаунсьте ввод, чтобы не отправлять запрос на каждое нажатие. Храните состояние фильтров в URL или в общем сторе, чтобы обновление страницы, кнопка назад и шаринг работали надёжно. Кешируйте последний успешный результат, чтобы таблица не мигала пустым.
Измеряйте, затем точно настраивайте запросы и индексы. Логируйте время сервера, время в базе, размер полезной нагрузки и время рендера. Затем обрежьте запрос: выбирайте только колонки, которые отображаете, применяйте фильтры как можно раньше и добавляйте индексы, которые соответствуют дефолтному фильтру + сортировке.
Пример: внутренний дашборд поддержки с 100k тикетов. По умолчанию — Open, назначенные моей команде, сортировка по новизне, показываем шесть колонок и запрашиваем только id, subject, assignee, status и временные метки. С keyset‑пагинацией и виртуализацией вы держите базу и UI предсказуемыми.
Если вы строите внутренние инструменты в Koder.ai, этот план легко мапится на итеративную работу: настройте вид, протестируйте прокрутку и поиск, затем подгоняйте запрос до тех пор, пока страница не перестанет тормозить.
Самый быстрый способ сломать экран списка — обращаться с 100k строк как с обычной страницей данных. Большинство медленных дашбордов попадают в несколько предсказуемых ловушек.
Одна из главных ошибок — рендерить всё и прятать это через CSS. Даже если визуально видно только 50 строк, браузер всё равно платит за создание 100k DOM‑узлов, их измерение и перерисовку при прокрутке. Если нужны длинные списки — рендерьте только то, что видно (виртуализация) и упрощайте компонент строки.
Поиск может тихо убить производительность, если каждый нажатие запускает полный скан таблицы. Такое происходит, когда фильтры не покрыты индексами, когда ищут сразу по многим колонкам или используют contains‑запросы по большим текстовым полям без плана. Хорошее правило: первый фильтр, к которому тяготеет пользователь, должен быть дешёвым для базы, а не только удобным в UI.
Ещё одна типичная проблема — загрузка полных записей, тогда как списку нужны только сводки. Строке обычно нужно 5–12 полей, а не целый объект, не длинные описания и не связанные данные. Лишние поля увеличивают работу базы, время в сети и парсинг frontend.
Экспорт и подсчёты могут заморозить UI, если вы делаете их на главном потоке или ждёте тяжёлого запроса перед ответом. Держите интерфейс интерактивным: запускайте экспорт в фоне, показывайте прогресс и избегайте пересчёта итогов при каждом фильтре.
И, наконец, слишком много опций сортировки может обернуться против вас. Если пользователи могут сортировать по любой колонке, вы будете сортировать большие наборы в памяти или вынуждать базу в медленные планы. Оставьте сортировки ограниченными набором индексированных колонок и сделайте дефолтную сортировку совместимой с реальным индексом.
Быстрая проверка по ощущениям:
Относитесь к производительности списка как к продуктовой фиче, а не одноразовому фикс‑рейду. Список остаётся быстрым только когда он кажется быстрым реальным людям, которые скроллят, фильтруют и сортируют по реальным данным.
Используйте этот чеклист, чтобы убедиться, что вы исправили нужные вещи:
Простой reality check: откройте список, прокручивайте 10 секунд, затем примените распространённый фильтр (например Status: Open). Если UI зависает — проблема обычно в рендеринге (слишком много DOM‑строк) или в тяжёлой клиентской трансформации (сортировка, группировка, форматирование) при каждом обновлении.
Следующие шаги, в порядке приоритета, чтобы не прыгать между фиксами:
Если вы строите это с Koder.ai (koder.ai), начните в Planning Mode: заранее определите точные колонки списка, поля фильтров и форму ответа API. Затем итеративно тестируйте и откатывайте, если эксперимент ухудшает производительность.
Смените цель с «загрузить всё» на «показать первые полезные строки быстро». Оптимизируйте время до первой строки и плавность взаимодействия при фильтрации, сортировке и прокрутке, даже если полный набор данных никогда не загружается целиком.
Измеряйте время до первой строки после загрузки или смены фильтра, время обновления при фильтре/сортировке, размер ответа, медленные запросы к БД (особенно широкие SELECT и COUNT(*)) и пики на главном потоке браузера. Эти числа напрямую соответствуют тому, что пользователи воспринимают как «лаг».
Ограничьте API временно до 20 строк с теми же фильтрами и сортировкой. Если стало быстро — узкое место в размере нагрузки или в запросе; если всё ещё медленно — ищите проблемы в рендеринге, форматировании или в обработке на стороне клиента.
Не рендерьте тысячи строк в DOM одновременно, упрощайте компонент строки и предпочитайте фиксированную высоту строки. Также избегайте дорогой работы с форматированием для невидимых строк: вычисляйте и кешируйте формат только когда строка становится видимой или открывается.
Виртуализация монтирует только видимые строки (плюс небольшой буфер), переиспользуя DOM-элементы по мере прокрутки. Она оправдана при частой прокрутке и «тяжёлых» строках, но лучше работает при предсказуемой и одинаковой высоте строк и простой разметке таблицы.
Пагинация — самый безопасный вариант для большинства админских и внутренних сценариев: она сохраняет ориентацию пользователя и ограничивает работу сервера. Бесконечная прокрутка удобна для казуального просмотра, но часто портит навигацию и увеличивает потребление памяти, если не вводить явные ограничения.
Offset-пагинация (page=10&size=50) проще, но может замедляться на глубоких страницах, потому что СУБД пропускает много строк. Keyset (cursor) пагинация остаётся быстрой, поскольку продолжает с последней видимой записи, но менее удобна для перехода на конкретную страницу.
Не шлите запрос при каждом нажатии клавиши. Добавьте дебаунс для поиска, отменяйте предыдущие запросы при новом вводе и по умолчанию предоставляйте уже суженные фильтры (например, за последние 7 дней или «мои элементы»), чтобы первый запрос был маленьким и полезным.
API списка должен возвращать только поля, которые отображаются: обычно id, метку, статус, владельца и временные метки. Большие тексты, JSON и связанные данные следует загружать по требованию в деталях записи, чтобы первый рендер оставался лёгким и предсказуемым.
Сделайте фильтр и сортировку такими, как реально используют пользователи, и добавьте индексы, поддерживающие этот шаблон, часто составные индексы, объединяющие поле фильтра и колонку сортировки. Рассматривайте точный COUNT как опциональный: кэшируйте, предварительно вычисляйте или показывайте «10,000+», когда точное число не критично.