# Performance Optimization

> Оптимизирует производительность приложения на фронтенде, бэкенде, в запросах и базах данных. Используй, когда есть требования к производительности, когда подозреваешь регрессию, когда надо улучшить Core Web Vitals или время загрузки, когда надо устранить паттерны запросов N+1 или когда профилирование выявило узкие места.

- Skill: `aleksandr-litvinenko/performance-optimization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add aleksandr-litvinenko/performance-optimization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aleksandr-litvinenko/performance-optimization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Aleksandr-Litvinenko (https://skillmd.com/u/aleksandr-litvinenko)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aleksandr-litvinenko/performance-optimization

---


# Оптимизация производительности

## Обзор

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

## Когда применять

- В спеке есть требования к производительности (бюджеты времени загрузки, SLA по времени ответа)
- Пользователи или мониторинг сообщают о медленной работе
- Показатели Core Web Vitals ниже порогов
- Подозреваешь, что изменение внесло регрессию
- Строишь функциональность, работающую с большими объёмами данных или высоким трафиком

**Когда НЕ применять:** не оптимизируй, пока нет доказательств проблемы. Преждевременная оптимизация добавляет сложность, которая стоит дороже, чем даёт производительности.

## Целевые значения Core Web Vitals

| Метрика | Хорошо | Нужно улучшить | Плохо |
|--------|------|-------------------|------|
| **LCP** (отрисовка крупнейшего элемента) | ≤ 2,5 с | ≤ 4,0 с | > 4,0 с |
| **INP** (отклик на взаимодействие) | ≤ 200 мс | ≤ 500 мс | > 500 мс |
| **CLS** (накопленный сдвиг вёрстки) | ≤ 0,1 | ≤ 0,25 | > 0,25 |

## Рабочий процесс оптимизации

```
1. ИЗМЕРИТЬ  → Установить базовую линию на реальных данных
2. НАЙТИ     → Найти настоящее узкое место (а не предполагаемое)
3. ПОЧИНИТЬ  → Устранить конкретное узкое место
4. ПРОВЕРИТЬ → Измерить снова; оставить или откатить
5. ЗАСТРАХОВАТЬ → Добавить мониторинг или тесты, чтобы не было регрессии
```

### Шаг 1: измерить

Два взаимодополняющих подхода — используй оба:

- **Синтетика (Lighthouse, вкладка Performance в DevTools):** контролируемые условия, воспроизводимость. Лучше всего для отлова регрессий в CI и для изоляции конкретных проблем.
- **RUM (библиотека web-vitals, CrUX):** данные реальных пользователей в реальных условиях. Необходимы, чтобы подтвердить, что исправление действительно улучшило пользовательский опыт.

**Фронтенд:**
```bash
# Синтетика: Lighthouse в Chrome DevTools (или в CI)
# Chrome DevTools → вкладка Performance → Record
# Chrome DevTools MCP → трассировка производительности

# RUM: библиотека Web Vitals в коде
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(console.log);
onINP(console.log);
onCLS(console.log);
```

**Бэкенд:**
```bash
# Логирование времени ответа
# Мониторинг производительности приложения (APM)
# Логирование запросов к БД с таймингами

# Простое измерение времени
console.time('db-query');
const result = await db.query(...);
console.timeEnd('db-query');
```

### С чего начинать измерения

Пусть симптом подскажет, что мерить первым:

```
Что тормозит?
├── Первая загрузка страницы
│   ├── Большой бандл? → Измерь размер бандла, проверь разбиение кода
│   ├── Медленный ответ сервера? → Измерь TTFB в водопаде сети DevTools
│   │   ├── Долгий DNS? → Добавь dns-prefetch / preconnect для известных источников
│   │   ├── Долгий TCP/TLS? → Включи HTTP/2, проверь выкатку на край сети, keep-alive
│   │   └── Долгое ожидание (сервер)? → Профилируй бэкенд, проверь запросы и кеширование
│   └── Ресурсы, блокирующие отрисовку? → Проверь водопад сети на блокирующие CSS/JS
├── Взаимодействие ощущается вязким
│   ├── Интерфейс подвисает при клике? → Профилируй основной поток, ищи длинные задачи (>50 мс)
│   ├── Задержка ввода в форме? → Проверь перерисовки, накладные расходы управляемых компонентов
│   └── Рывки анимации? → Проверь избыточные перерасчёты вёрстки, принудительные reflow
├── Страница после перехода
│   ├── Загрузка данных? → Измерь время ответа API, проверь на водопады запросов
│   └── Отрисовка на клиенте? → Профилируй время отрисовки компонентов, проверь на N+1 запросы
└── Бэкенд / API
    ├── Тормозит один эндпоинт? → Профилируй запросы к БД, проверь индексы
    ├── Тормозят все эндпоинты? → Проверь пул соединений, память, CPU
    └── Периодические подтормаживания? → Проверь конкуренцию за блокировки, паузы сборщика мусора, внешние зависимости
```

### Шаг 2: найти узкое место

Типовые узкие места по категориям:

**Фронтенд:**

| Симптом | Вероятная причина | Что смотреть |
|---------|-------------|---------------|
| Медленный LCP | Крупные изображения, ресурсы, блокирующие отрисовку, медленный сервер | Водопад сети, размеры изображений |
| Высокий CLS | Изображения без размеров, поздно подгружаемый контент, сдвиги шрифтов | Атрибуцию сдвигов вёрстки |
| Плохой INP | Тяжёлый JavaScript в основном потоке, большие обновления DOM | Длинные задачи в трассировке производительности |
| Медленная первая загрузка | Большой бандл, много сетевых запросов | Размер бандла, разбиение кода |

**Бэкенд:**

| Симптом | Вероятная причина | Что смотреть |
|---------|-------------|---------------|
| Медленные ответы API | Запросы N+1, отсутствующие индексы, неоптимизированные запросы | Лог запросов к БД |
| Рост потребления памяти | Утёкшие ссылки, неограниченные кеши, крупные тела запросов | Анализ снимков кучи |
| Всплески CPU | Синхронные тяжёлые вычисления, откаты в регулярных выражениях | Профилирование CPU |
| Высокие задержки | Отсутствие кеширования, избыточные вычисления, сетевые переходы | Трассировку запросов через весь стек |

### Шаг 3: устранить типовые антипаттерны

#### Запросы N+1 (бэкенд)

```typescript
// ПЛОХО: N+1 — по одному запросу на владельца каждой задачи
const tasks = await db.tasks.findMany();
for (const task of tasks) {
  task.owner = await db.users.findUnique({ where: { id: task.ownerId } });
}

// ХОРОШО: один запрос с соединением/включением
const tasks = await db.tasks.findMany({
  include: { owner: true },
});
```

#### Неограниченная выборка данных

```typescript
// ПЛОХО: выбираем все записи
const allTasks = await db.tasks.findMany();

// ХОРОШО: постранично с ограничениями
const tasks = await db.tasks.findMany({
  take: 20,
  skip: (page - 1) * 20,
  orderBy: { createdAt: 'desc' },
});
```

#### Неоптимизированные изображения (фронтенд)

```html
<!-- ПЛОХО: нет размеров, нет оптимизации формата -->
<img src="/hero.jpg" />

<!-- ХОРОШО: главное изображение (LCP) — арт-дирекция + переключение разрешений, высокий приоритет -->
<!--
  Совмещены две техники:
  - Арт-дирекция (media): разная обрезка/композиция на разных контрольных точках
  - Переключение разрешений (srcset + sizes): нужный размер файла под плотность экрана
-->
<picture>
  <!-- Мобильные: портретная обрезка (8:10) -->
  <source
    media="(max-width: 767px)"
    srcset="/hero-mobile-400.avif 400w, /hero-mobile-800.avif 800w"
    sizes="100vw"
    width="800"
    height="1000"
    type="image/avif"
  />
  <source
    media="(max-width: 767px)"
    srcset="/hero-mobile-400.webp 400w, /hero-mobile-800.webp 800w"
    sizes="100vw"
    width="800"
    height="1000"
    type="image/webp"
  />
  <!-- Десктоп: ландшафтная обрезка (2:1) -->
  <source
    srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
    sizes="(max-width: 1200px) 100vw, 1200px"
    width="1200"
    height="600"
    type="image/avif"
  />
  <source
    srcset="/hero-800.webp 800w, /hero-1200.webp 1200w, /hero-1600.webp 1600w"
    sizes="(max-width: 1200px) 100vw, 1200px"
    width="1200"
    height="600"
    type="image/webp"
  />
  <img
    src="/hero-desktop.jpg"
    width="1200"
    height="600"
    fetchpriority="high"
    alt="Hero image description"
  />
</picture>

<!-- ХОРОШО: изображение ниже первого экрана — отложенная загрузка + асинхронное декодирование -->
<img
  src="/content.webp"
  width="800"
  height="400"
  loading="lazy"
  decoding="async"
  alt="Content image description"
/>
```

#### Лишние перерисовки (React)

```tsx
// ПЛОХО: создаёт новый объект на каждой отрисовке, заставляя детей перерисовываться
function TaskList() {
  return <TaskFilters options={{ sortBy: 'date', order: 'desc' }} />;
}

// ХОРОШО: стабильная ссылка
const DEFAULT_OPTIONS = { sortBy: 'date', order: 'desc' } as const;
function TaskList() {
  return <TaskFilters options={DEFAULT_OPTIONS} />;
}

// Используй React.memo для дорогих компонентов
const TaskItem = React.memo(function TaskItem({ task }: Props) {
  return <div>{/* дорогая отрисовка */}</div>;
});

// Используй useMemo для дорогих вычислений
function TaskStats({ tasks }: Props) {
  const stats = useMemo(() => calculateStats(tasks), [tasks]);
  return <div>{stats.completed} / {stats.total}</div>;
}
```

#### Большой размер бандла

```typescript
// Современные сборщики (Vite, webpack 5+) сами обрабатывают именованные импорты с отсечением
// неиспользуемого кода, при условии что зависимость поставляется в ESM и помечена
// `sideEffects: false` в package.json.
// Профилируй, прежде чем менять стиль импортов, — настоящий выигрыш даёт разбиение и отложенная загрузка.

// ХОРОШО: динамический импорт для тяжёлой, редко используемой функциональности
const ChartLibrary = lazy(() => import('./ChartLibrary'));

// ХОРОШО: разбиение кода по маршрутам, обёрнутое в Suspense
const SettingsPage = lazy(() => import('./pages/Settings'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <SettingsPage />
    </Suspense>
  );
}
```

#### Отсутствие кеширования (бэкенд)

```typescript
// Кешируй часто читаемые, редко меняющиеся данные
const CACHE_TTL = 5 * 60 * 1000; // 5 минут
let cachedConfig: AppConfig | null = null;
let cacheExpiry = 0;

async function getAppConfig(): Promise<AppConfig> {
  if (cachedConfig && Date.now() < cacheExpiry) {
    return cachedConfig;
  }
  cachedConfig = await db.config.findFirst();
  cacheExpiry = Date.now() + CACHE_TTL;
  return cachedConfig;
}

// Заголовки HTTP-кеширования для статики
app.use('/static', express.static('public', {
  maxAge: '1y',           // Кешировать на год
  immutable: true,        // Никогда не перепроверять (используй хеш содержимого в именах файлов)
}));

// Cache-Control для ответов API
res.set('Cache-Control', 'public, max-age=300'); // 5 минут
```

### Шаг 4: проверить (оставить или откатить)

Исправление остаётся гипотезой, пока ты не измерил заново. Этот шаг решает, выживет ли оно.

**Измеряй повторно так же, как измерял базовую линию:** та же команда, те же условия, тот же фиксированный бюджет (время по часам, число выборок или число запросов). Базовая линия на холодном кеше против результата на прогретом измеряет кеш, а не твоё изменение.

**Меняй по одной вещи за раз.** Три оптимизации, приехавшие вместе, дают одно число, и приписать его чему-то конкретному нельзя. Если они обязаны ехать вместе, сначала измерь каждую в изоляции.

**Побеждай шум, а не только среднее.** Повтори измерение и сравни разницу с разбросом между прогонами. Выигрыш 3% при разбросе ±5% — это не выигрыш, это другая выборка.

Затем решай строго:

| Результат против базовой линии | Действие |
|---|---|
| Порог пройден, тесты зелёные | **Оставить.** Закоммитить, указав в сообщении цифры «до/после». |
| В пределах шума (измеримых изменений нет) | **Откатить.** |
| Хуже | **Откатить.** |
| Лучше, но покраснел тест | **Откатить.** Это регрессия в одежде победы. |

**«Нейтрально» — это откат, а не сохранение.** Именно этот шаг команды пропускают: изменение уже написано, выбрасывать жалко, поэтому оно приезжает неизмеренным, и кодовая база обрастает сложностью, которая ничего не купила. Код, который ты оставляешь, ты сопровождаешь вечно. Пусть он себя окупает.

**Корректность стоит на воротах перед метрикой.** Набор тестов остаётся зелёным, *и* число сдвигается. «Оптимизация», выигравшая за счёт выброшенной работы, которая продукту была нужна (пропущенная валидация, кеширование того, что обязано быть свежим, удалённый `await`, который был несущим), — это регрессия, а не победа.

#### Записывай каждую попытку, включая откаченные

Откаченная работа не оставляет следа в истории git — именно поэтому та же мёртвая идея всплывает снова в следующем квартале. Веди короткий журнал, чтобы отброшенная идея оставалась отброшенной:

| Идея | База → Результат | Вердикт | Почему |
|---|---|---|---|
| Мемоизировать компонент строки | INP 240 мс → 235 мс | откачено | В пределах шума (±15 мс). Строки не были узким местом. |
| Виртуализировать список | INP 240 мс → 90 мс | оставлено | Длинные задачи исчезли из трассировки. |
| Preconnect к источнику API | LCP 2,8 с → 2,8 с | откачено | Он и так на том же источнике. |

Подойдёт и раздел в описании пулл-реквеста, и файл `PERF.md` в репозитории. Важно, чтобы следующий человек (или следующий агент) прочитал это до того, как предложит эксперимент, и не запустил заново тот, что уже провалился.

## Бюджет производительности

Задай бюджеты и следи за их соблюдением:

```
Бандл JavaScript: < 200 КБ в gzip (первая загрузка)
CSS: < 50 КБ в gzip
Изображения: < 200 КБ на изображение (выше первого сгиба)
Шрифты: < 100 КБ суммарно
Время ответа API: < 200 мс (p95)
Время до интерактивности: < 3,5 с на 4G
Оценка Lighthouse Performance: ≥ 90
```

**Следи за соблюдением в CI:**
```bash
# Проверка размера бандла
npx bundlesize --config bundlesize.config.json

# Lighthouse CI
npx lhci autorun
```

## См. также

Подробные чеклисты производительности, команды оптимизации и справочник антипаттернов — см. `../../references/performance-checklist.md`.


## Типовые самооправдания

| Самооправдание | Как на самом деле |
|---|---|
| «Оптимизируем потом» | Долг по производительности нарастает. Очевидные антипаттерны чини сейчас, микрооптимизации откладывай. |
| «На моей машине быстро» | Твоя машина — не машина пользователя. Профилируй на представительном железе и сетях. |
| «Эта оптимизация очевидна» | Если ты не измерял, ты не знаешь. Сначала профилируй. |
| «Пользователи не заметят 100 мс» | Исследования показывают, что задержки в 100 мс влияют на конверсию. Пользователи замечают больше, чем кажется. |
| «Фреймворк сам разберётся с производительностью» | Фреймворки предотвращают часть проблем, но не чинят запросы N+1 и раздутые бандлы. |
| «Помогло немного, но хуже же не стало» | Нейтральные изменения — это откат. Ты платишь за их сопровождение вечно и не получил ничего взамен. |
| «Мы это уже написали, чего теперь выбрасывать» | Невозвратные издержки. Измерению всё равно, сколько времени заняло написание. |
| «Улучшение очевидно, перемерять незачем» | Тогда перемерять дёшево, и это докажет. Неизмеренные победы — это то, как приезжает нейтральная сложность. |

## Тревожные признаки

- Оптимизация без данных профилирования, которые её обосновывают
- Паттерны запросов N+1 при выборке данных
- Эндпоинты со списками без пагинации
- Изображения без размеров, без отложенной загрузки и без адаптивных размеров
- Размер бандла растёт без всякого контроля
- В продакшне нет мониторинга производительности
- `React.memo` и `useMemo` повсюду (злоупотребление так же плохо, как недоиспользование)
- Оптимизации оставлены без повторного измерения, которое их оправдывает
- Несколько оптимизаций объединены в одно измерение, и ни одно изменение нельзя приписать результату
- «Победа», которая потребовала изменить, пропустить или удалить тест
- Одна и та же провалившаяся оптимизация пробуется повторно, потому что никто не записал первую попытку

## Проверка

После любого изменения, связанного с производительностью:

- [ ] Есть измерения «до» и «после» (конкретные числа)
- [ ] Результат измерен тем же способом, что и базовая линия (та же команда, те же условия)
- [ ] Улучшение превышает разброс между прогонами, а не только среднее
- [ ] Изменения, не побившие базовую линию, откачены, а не оставлены как нейтральные
- [ ] Попытки записаны — и оставленные, и откаченные, — чтобы мёртвая идея не запускалась заново
- [ ] Конкретное узкое место найдено и устранено
- [ ] Core Web Vitals в пределах порогов «Хорошо»
- [ ] Размер бандла существенно не вырос
- [ ] В новом коде выборки данных нет запросов N+1
- [ ] Бюджет производительности проходит в CI (если настроен)
- [ ] Существующие тесты по-прежнему проходят (оптимизация не сломала поведение)

