Почему вайбкод-проект тормозит при росте и как найти узкие места

27 сентября 2026 · 15 мин чтения · Архитектура, пункты 09, 22

Коротко: проект из Lovable, Bolt, Replit или Cursor обычно замедляется при росте по нескольким повторяющимся причинам. Это отдельный запрос к базе на каждый элемент списка (N+1), отсутствие индексов под фильтры и сортировку, загрузка всей таблицы в браузер, политики RLS с вызовом функции на каждую строку и лишние Realtime-подписки. Отдельно стоит проверить фронтенд, холодные старты серверных функций и лимиты тарифа. Начинайте с замеров: вкладка Network в браузере, отчёт Query Performance в Supabase и pg_stat_statements показывают, какой запрос забирает время.

Почему на 50 пользователях всё работало, а сейчас тормозит

Пока в таблице сотни строк, Postgres читает её целиком за миллисекунды, и сотня мелких запросов со страницы незаметна. С ростом данных и аудитории обе вещи дорожают.

Ресурсов у небольшого проекта мало. Бесплатный тариф Supabase работает на размере Nano: разделяемый CPU, память до 0,5 ГБ, 60 прямых подключений к базе (Compute and Disk).

Как измерить, что именно тормозит в проекте

Без замеров легко ускорить место, которое ни на что не влияло. Снимите цифры, исправьте одно узкое место, снимите снова.

Что фиксировать:

  • p95 времени ответа ключевых экранов и API: время, в которое укладываются 95% запросов. Среднее прячет медленный хвост.
  • Число запросов к /rest/v1/ при открытии экрана (вкладка Network).
  • Запросы с наибольшим суммарным временем: Advisors → Query Performance в дашборде Supabase или pg_stat_statements.
  • CPU, память, Disk IO и подключения: отчёт Database в разделе Reports (Reports).
  • Egress и сообщения Realtime: страница Usage организации.
  • Core Web Vitals фронтенда: LCP до 2,5 с, INP до 200 мс, CLS до 0,1 на 75-м перцентиле (web.dev).

Топ запросов по суммарному времени (если расширение выключено, включите его в Database → Extensions, см. документацию):

select calls,
       round(total_exec_time::numeric) as total_ms,
       round(mean_exec_time::numeric, 2) as mean_ms,
       rows,
       query
from pg_stat_statements
order by total_exec_time desc
limit 20;

Счётчики накапливаются с последнего сброса (Inspect the database), поэтому сохраните результат до изменений и сравнивайте с ним.

Как воспроизвести нагрузку в k6

Нагружайте только собственный staging-проект. Тест против продакшена может его положить, против чужого сервиса недопустим. Staging в Supabase — отдельный проект с той же схемой и синтетическими данными в объёме, до которого вы планируете вырасти. Чек-лист Supabase перед запуском тоже советует тестировать нагрузку на staging и называет k6 (Production checklist).

Модуль k6/http отправляет HTTP-запросы и не выполняет JavaScript страницы. Для одностраничного приложения нагружайте запросы, которые делает страница: найдите во вкладке Network тяжёлый запрос к /rest/v1/, скопируйте его через Copy as cURL и перенесите адрес и заголовки в скрипт.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '1m', target: 20 },
    { duration: '3m', target: 20 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],
    http_req_duration: ['p(95)<800'],
  },
};

export default function () {
  const res = http.get(
    `${__ENV.SUPABASE_URL}/rest/v1/orders?select=id,total,created_at&order=created_at.desc&limit=20`,
    {
      headers: {
        apikey: __ENV.SUPABASE_KEY,
        Authorization: `Bearer ${__ENV.USER_JWT}`,
      },
    }
  );
  check(res, { 'status 200': (r) => r.status === 200 });
  sleep(1);
}
k6 run -e SUPABASE_URL="$STAGING_URL" -e SUPABASE_KEY="$STAGING_PUBLISHABLE_KEY" -e USER_JWT="$TEST_USER_JWT" load.js

Скрипт за минуту поднимает 20 виртуальных пользователей, держит их 3 минуты и за минуту снижает до нуля (Running k6). Если ошибок больше 1% или p95 выше 800 мс, k6 завершится с ненулевым кодом (Thresholds). Числа 20 и 800 мс условные. Publishable-ключ передаётся в заголовке apikey, токен пользователя — в Authorization (API keys). Вход в аккаунт в цикл не ставьте, у Auth есть лимиты частоты запросов: получите токен тестового пользователя заранее. Во время теста следите за отчётом Database.

Как найти N+1 запросы в Supabase

N+1 — это когда код делает один запрос за списком и ещё по запросу на каждый элемент. Для 10 заказов это 11 запросов, для 500 — 501. Так выглядит типичный вариант:

const { data: orders } = await supabase.from('orders').select('id, total, customer_id');

const rows = await Promise.all(
  orders.map(async (order) => {
    const { data: customer } = await supabase
      .from('customers')
      .select('name')
      .eq('id', order.customer_id)
      .single();
    return { ...order, customer };
  })
);

Тот же эффект даёт карточка в списке, которая сама загружает свои данные в useEffect.

Как найти:

  1. Откройте DevTools → Network (Chrome DevTools), введите в фильтр rest/v1 и обновите страницу. Десятки одинаковых запросов, которые отличаются только id=eq.…, и есть N+1.
  2. Отсортируйте pg_stat_statements по calls: простой запрос по id с огромным числом вызовов и маленьким средним временем указывает на N+1.
  3. В разделе Logs выберите источник API Gateway (Logs) и найдите серии запросов к одному пути в пределах секунды.

Исправляется это одним запросом с вложенной выборкой (Joins and nesting):

const { data: orders, error } = await supabase
  .from('orders')
  .select('id, total, created_at, customer:customers(name)')
  .order('created_at', { ascending: false })
  .range(0, 19);

Data API находит связь по внешнему ключу, здесь orders.customer_id → customers.id. Если ключа нет, соберите идентификаторы и сделайте один запрос через .in():

const ids = [...new Set(orders.map((o) => o.customer_id))];

const { data: customers, error } = await supabase
  .from('customers')
  .select('id, name')
  .in('id', ids);

Каких индексов не хватает и как проверить запрос через EXPLAIN

Индекс — структура, по которой Postgres находит нужные строки без чтения всей таблицы. Без индекса по колонке из where, order by или условия соединения база делает Seq Scan, то есть читает таблицу целиком.

Отдельно проверьте внешние ключи: Postgres не создаёт индекс на ссылающихся колонках автоматически (PostgreSQL: Constraints). Колонка orders.user_id без индекса замедляет и выборку заказов пользователя, и политику RLS, которая по ней фильтрует.

Где искать в Supabase:

  • Advisors → Performance Advisor. Проверка unindexed_foreign_keys показывает внешние ключи без индекса (Database Advisors).
  • Advisors → Query Performance. Медленные запросы и вкладка с подсказками индексов от расширения index_advisor. Оно предлагает только одноколоночные B-tree индексы (index_advisor), составные придётся проектировать самим.
  • CLI: supabase inspect db seq-scans и supabase inspect db outliers (Inspect the database).

Проверка конкретного запроса:

explain (analyze, buffers)
select id, total, created_at
from public.orders
where user_id = '5950b438-b07c-4012-8190-6ce79e4bd8e5'
order by created_at desc
limit 20;

Признаки проблемы: Seq Scan по большой таблице, большое Rows Removed by Filter, узел Sort над чтением таблицы. actual time указан в миллисекундах; для узлов с несколькими проходами это среднее, умножайте его на loops (Using EXPLAIN). EXPLAIN ANALYZE действительно выполняет запрос. Проверку update или delete оборачивайте в begin; … rollback;.

Индекс под фильтр и сортировку сразу:

create index concurrently if not exists orders_user_id_created_at_idx
  on public.orders (user_id, created_at desc);

С concurrently индекс строится без блокировки записи в таблицу, но такую команду нельзя выполнять внутри транзакции (CREATE INDEX). Запускайте её отдельно. Каждый индекс замедляет вставку и обновление, поэтому неиспользуемые индексы удаляйте: Performance Advisor отмечает их проверкой unused_index.

Почему нельзя загружать всю таблицу и фильтровать в браузере

Типичный шаблон: select('*') без условий, потом .filter() и .sort() в React. На сотне строк это работает. На десятках тысяч браузер скачивает всё, вы платите за egress, а страница подвисает на разборе JSON.

Есть и скрытая ошибка. По умолчанию Supabase отдаёт не больше 1000 строк на запрос (supabase-js: select). Список на клиенте молча обрезается, и поиск с сортировкой работают только по первой тысяче.

Фильтры, сортировку и постраничную загрузку переносите на сервер:

const PAGE_SIZE = 20;

const { data, error } = await supabase
  .from('orders')
  .select('id, total, status, created_at')
  .eq('status', 'paid')
  .order('created_at', { ascending: false })
  .range(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE - 1);

В range(from, to) счёт идёт с нуля, обе границы включаются. Загрузка по смещению замедляется на дальних страницах: строки, пропущенные через OFFSET, сервер всё равно вычисляет (LIMIT и OFFSET). Для бесконечных лент подходит keyset-пагинация: следующая страница запрашивается от последнего показанного значения.

let query = supabase
  .from('orders')
  .select('id, total, created_at')
  .eq('user_id', userId);

if (cursor) query = query.lt('created_at', cursor);

const { data, error } = await query
  .order('created_at', { ascending: false })
  .limit(PAGE_SIZE);

Здесь cursor — created_at последней строки предыдущей страницы. Если у нескольких строк может совпасть created_at, добавьте второй ключ id. В SQL-функции или серверном коде это выглядит так:

select id, total, created_at
from public.orders
where user_id = $1
  and (created_at, id) < ($2, $3)
order by created_at desc, id desc
limit 20;

Под такой запрос нужен индекс (user_id, created_at desc, id desc).

Подсчёт count: 'exact' выполняет COUNT(*) и медленный на больших таблицах. planned берёт быструю оценку из статистики Postgres, estimated считает точно на малых объёмах и приблизительно на больших. Выбирайте только нужные колонки: каждая лишняя увеличивает egress.

Почему RLS замедляет запросы в Supabase

RLS (Row Level Security) — политики Postgres, по которым база отбирает строки, доступные пользователю. Политика проверяется для каждой строки-кандидата. Если в ней прямой вызов auth.uid(), функция может выполняться на каждую строку. Supabase рекомендует оборачивать такой вызов в select: тогда Postgres вычисляет значение один раз на запрос (Row Level Security). Такие политики находит проверка auth_rls_initplan в Performance Advisor.

Было:

create policy "Users read own orders"
on public.orders
for select
to authenticated
using ( auth.uid() = user_id );

Стало:

alter policy "Users read own orders"
on public.orders
using ( (select auth.uid()) = user_id );

Остальные рекомендации из руководства по производительности RLS:

  • индекс на каждой колонке, по которой фильтруют политики (user_id, team_id);
  • роль в политике через to authenticated, чтобы политика не вычислялась для других ролей;
  • явный фильтр в запросе клиента, например .eq('user_id', userId), даже если политика ограничивает то же самое: это помогает планировщику;
  • политики с соединением таблиц переписывать так, чтобы сначала получить набор допустимых значений.
create policy "Team members read projects"
on public.projects
for select
to authenticated
using (
  team_id in (
    select team_id from public.team_user
    where user_id = (select auth.uid())
  )
);

Если на одной таблице много разрешающих политик для одного действия, Postgres выполняет их все. Это отмечает проверка multiple_permissive_policies.

Чтобы увидеть стоимость политик, выполните запрос от имени роли authenticated, как в документации Supabase:

set session role authenticated;
set request.jwt.claims to '{"role":"authenticated", "sub":"5950b438-b07c-4012-8190-6ce79e4bd8e5"}';
explain analyze select count(*) from public.orders;
set session role postgres;

Как проверить, что политики вообще закрывают данные, разобрано в статье RLS в Supabase.

Почему Realtime-подписки нагружают базу

Подписка Postgres Changes проверяет доступ к каждому событию для каждого подписчика. Одно изменение в таблице со 100 подписчиками означает 100 проверок авторизации. События обрабатываются в одном потоке, чтобы сохранить порядок, поэтому переход на более мощный сервер почти не увеличивает пропускную способность (Postgres Changes). Добавьте к этому лимиты тарифа: на Free 200 одновременных подключений и 100 сообщений в секунду, на Pro с включённым spend cap — 500 и 500 (Realtime limits).

Типичные ошибки:

  • подписка на всю таблицу без фильтра, из-за которой каждое изменение проверяется для всех подписчиков;
  • отдельный канал на каждый элемент списка;
  • подписка в useEffect без отписки: каждый переход между экранами добавляет канал;
  • перезагрузка всего списка на каждое событие.

Найти их помогают фильтр WS во вкладке Network, отчёт Realtime в Reports и строки Realtime на странице Usage.

Подписка с фильтром и отпиской:

useEffect(() => {
  const channel = supabase
    .channel(`orders:${userId}`)
    .on(
      'postgres_changes',
      { event: 'INSERT', schema: 'public', table: 'orders', filter: `user_id=eq.${userId}` },
      (payload) => setOrders((prev) => [payload.new, ...prev])
    )
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
}, [userId]);

Когда на одни и те же изменения подписано больше примерно 3000 пользователей одновременно, документация советует Broadcast: изменение отправляется один раз и раздаётся всем. Для данных, которые меняются редко, часто достаточно перезапрашивать их по таймеру или при возврате на вкладку.

Почему тормозит фронтенд: тяжёлый бандл и картинки

Бывает, что база отвечает быстро, а страница на телефоне открывается долго. Запустите Lighthouse в Chrome DevTools или PageSpeed Insights и сравните LCP, INP и CLS с порогами выше.

Посмотреть, из чего состоит бандл:

  • Проект на Vite (в корне есть vite.config.ts): установите rollup-plugin-visualizer, добавьте visualizer() последним элементом массива plugins, и после npm run build в корне появится stats.html с картой модулей (README плагина). Плагину нужен Node.js 22 или новее.
  • Next.js 16.1 и новее: npx next experimental-analyze. Для сборки на webpack есть @next/bundle-analyzer (Next.js: Package Bundling).

Что искать в отчёте: целиком подключённые библиотеки иконок или графиков, редактор или карту на первом экране, все маршруты в одном файле. Маршруты разделяйте через React.lazy и Suspense (React: lazy). В Next.js тяжёлый рендеринг без интерактива переносите в серверные компоненты.

Картинки уменьшайте до размера показа, отдавайте в WebP или AVIF, указывайте width и height, чтобы вёрстка не прыгала, а картинкам ниже первого экрана ставьте loading="lazy". В Next.js это делает компонент next/image. Supabase Storage меняет размер на лету и отдаёт WebP на тарифах Pro и выше: 100 исходных изображений входят в тариф, дальше $5 за 1000 (Image Transformations).

const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-42.jpg', { transform: { width: 200, height: 200 } });

Почему серверные функции на Vercel и Supabase отвечают медленно

Поведение зависит от платформы и тарифа. Цифры ниже — на сентябрь 2026 года, сверяйте их с документацией своей платформы.

Холодный старт — задержка, когда платформа поднимает новый экземпляр функции. Признак: первый запрос после простоя или при всплеске трафика заметно медленнее следующих.

Vercel. С 23 апреля 2025 года для новых проектов по умолчанию включён Fluid compute: кэширование байткода и прогрев функций в продакшен-деплоях снижают влияние холодных стартов (Fluid compute). Длительность функции ограничена: на Hobby 300 с, на Pro 300 с по умолчанию и до 800 с по настройке. При превышении возвращается ошибка 504 FUNCTION_INVOCATION_TIMEOUT (Functions Limits).

Отдельно проверьте регион. По умолчанию функции Vercel выполняются в iad1 (Вашингтон). Если проект Supabase во Франкфурте, каждый запрос функции к базе идёт через океан, и задержка умножается на число запросов. Регион функций задаётся в настройках проекта или в vercel.json (Configuring regions):

{
  "regions": ["fra1"]
}

Supabase Edge Functions: до 2 с процессорного времени на запрос (ожидание сети не считается), до 150 с общего времени на Free и до 400 с на платных тарифах, 256 МБ памяти. Если функция не ответила за 150 с, клиент получит 504 (Edge Functions Limits).

Долгие задачи (генерацию документов, вызовы ИИ-моделей, рассылки) выносите из HTTP-запроса в фоновую очередь. Не импортируйте в функцию библиотеки, которые она не использует. К базе подключайтесь через пулер.

Какие лимиты Supabase мешают расти: подключения, пулер и egress

У каждого размера сервера свой лимит подключений к базе и клиентов пулера (Compute and Disk, на сентябрь 2026 года):

Размер Память Подключения к базе Клиенты пулера
Nano (Free) до 0,5 ГБ 60 200
Micro 1 ГБ 60 200
Small 2 ГБ 90 400
Medium 4 ГБ 120 600

Серверные функции открывают много коротких подключений и без пулера быстро упираются в лимит. Пулер держит небольшой набор подключений к базе и раздаёт их клиентам по очереди. В Supabase общий пулер называется Supavisor, выделенный работает на PgBouncer. Для serverless и edge-функций документация рекомендует transaction mode на порту 6543. В этом режиме не работают prepared statements, их нужно отключить в библиотеке подключения (Connecting to Postgres). Если приложение активно использует Data API, не поднимайте размер пула выше 40% от максимума подключений (Connection management).

Кто держит прямые подключения:

select usename, application_name, state, count(*)
from pg_stat_activity
group by usename, application_name, state
order by count(*) desc;

Egress — все данные, которые Supabase отправляет клиентам: результаты запросов, файлы из Storage, ответы функций, события Realtime. На Free входит 5 ГБ обычного и 5 ГБ кэшированного egress в месяц, на Pro и Team — по 250 ГБ. Сверх квоты $0,09 за ГБ обычного и $0,03 за ГБ кэшированного (Egress). Снижают egress выбор только нужных колонок, пагинация и уменьшенные картинки.

Ещё один лимит — дисковый ввод-вывод. Небольшие серверы могут кратко работать быстрее базовой скорости диска за счёт бюджета, а когда он исчерпан, скорость возвращается к базовой: у Nano это 5 МБ/с и 250 IOPS. Метрика Disk IO % consumed выше 1% значит, что нагрузка за день превышала базовую (Compute and Disk). Снаружи это похоже на внезапное замедление базы без роста трафика. Помогают индексы или сервер большего размера.

Проверьте spend cap. На Pro с включённым лимитом расходов после исчерпания квоты по позиции (egress, сообщения Realtime, трансформации изображений) дальнейшее использование этой позиции блокируется до следующего расчётного периода. На compute лимит не распространяется (Cost control).

Чек-лист: готов ли проект к росту

  • Число запросов на экране не растёт с длиной списка.
  • Performance Advisor не показывает unindexed_foreign_keys, auth_rls_initplan, multiple_permissive_policies.
  • Топ-20 запросов по total_exec_time просмотрены через EXPLAIN.
  • Списки грузятся страницами, фильтры и сортировка выполняются в базе.
  • В политиках RLS стоит (select auth.uid()), их колонки проиндексированы.
  • Realtime-подписки с фильтром и отпиской.
  • LCP на мобильном в пределах 2,5 с.
  • Функции работают в регионе базы и подключаются через пулер.
  • Подключения, egress и spend cap сверены с ожидаемым ростом.
  • Тест k6 на staging проходит пороги.

Производительность стоит проверять вместе с безопасностью, для этого есть чек-лист по безопасности вайбкод-проекта.

Если хотите, чтобы проект проверил инженер, оставьте заявку на Кодосмотр.