Главная / Статьи / Двадцать пять сценариев интервью по JavaScript, основанных на реальных ошибках в продакшене.

Двадцать пять сценариев интервью по JavaScript, основанных на реальных ошибках в продакшене.

Гонки, тарсхинг, идемпотентные платежи, утечки, гидратация, кэши WeakMap, ненадежные тесты и общие слои запросов — с ответами, которые действительно хотят услышать интервьюеры.

4513 слов

От ИИ

Двадцать пять вопросов на знание JavaScript, похожих на задачи из реальных проектов

На собеседованиях по-прежнему задают вопросы о том, что возвращает typeof null, как работает механизм подъема переменных, или как заменить функцию bind. На такие вопросы легко ставить оценки — и их легко сфабриковать после недельного изучения. Затем тот же кандидат предлагает поисковую строку, которая отображает результаты по запросу, который пользователь уже удалил.

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

Проблема проявляется наиболее остро в трех областях: асинхронное поведение в реальных сетях (ответы в неправильном порядке, обещания, которые выполняются, но уже не имеют значения), память (ничего не ломается, если ноутбук оставлен включенным на шесть минут) и сбои (поставщик возвращает код HTTP 200 с телом ошибки в формате HTML, и response.json() выдает ошибку в 2 часа ночи).

Далее приведено двадцать пять сценариев, взятых из реальных багов, попадающих в продакшн: медленные дашборды, повторные запросы, рост объема памяти при смене маршрутов, устаревшие результаты поиска, двойные списания, сбои в работе механизмов аутентификации, таблицы с двадцатью тысячами строк и API, которые работают 96% времени. Каждый пункт включает описание ситуации, ответ, на который ждут интервьюеры, краткий пример кода, распространенный неверный подход, возможные последствия и то, что измеряется.

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

Начальный уровень — основы работы в продакшене

Это разделяет тех, кто уже выпустил продукт, от тех, кто лишь пройдёт учебные материалы. Ничего сложного; всё это может сломаться в реальных приложениях.

Debounce против throttle для поиска и прокрутки

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

Вопрос. Когда подходит debounce вместо throttle, и как безопасно его реализовать в React?

Ответ. Механизм ограничения скорости обеспечивает вызовы с фиксированной частотой — что идеально для прокрутки и изменения размера. Механизм задержки ожидает паузы в вводе, что соответствует намерению пользователя выполнять поиск. Начните с периода от четверти секунды до трехсот миллисекунд и настраивайте его на основе статистики. Сочетайте механизм задержки с минимальной длиной запроса и возможностью отмены запроса; один только механизм задержки не решает проблему некорректного порядка ответов.

function debounce(fn, wait = 250) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const search = debounce((q) => {
  if (q.length < 2) return;
  fetchResults(q);
});

Неправильный подход. Создание нового обертывающего элемента с механизмом задержки при каждой отрисовке. Таймеры сбрасываются с учетом новых контекстов, поэтому механизм задержки на самом деле никогда не активируется. Для стабилизации используйте useMemo/useRef и очищайте данные при демонтировании компонента.

Дополнительный вопрос. Пользователь быстро вводит текст, а затем очищает поле. Вызов с использованием механизма задержки все равно происходит с последним ненулевым значением. Как взаимодействуют функции отмены при демонтировании и при очистке поля?

Измерено. Выбор инструмента определяется целями пользовательского интерфейса, а также удобством использования механизмов закрытия элементов, которые сохраняются при повторных отрисовках.

Двадцать тысяч обработчиков кликов

Сценарий. В таблице данных к каждой операции в строке привязан обработчик onClick. При наличии 20 000 строк странице требуется несколько секунд, чтобы стать интерактивной, а использование памяти растёт при прокрутке.

Вопрос. Как следует реструктурировать обработку событий?

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

grid.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action]');
  if (!button || !grid.contains(button)) return;

  const { action } = button.dataset;
  const rowId = button.closest('tr')?.dataset.rowId;
  handleAction(action, rowId);
});

Неправильный подход. По-прежнему добавляются тысячи обработчиков событий для строк, в надежде на то, что цикл очистки их удаст. Расходы на настройку остаются прежними, а некорректные ссылки на функции молча не позволяют отсоединить обработчики.

Дополнительные вопросы. В React 17+ где библиотека регистрирует свой корневой обработчик, и как должны сосуществовать делегированные нативные обработчики и синтетическое распространение событий?

Оценка кандидата. Рассматривается, рассматривает ли кандидат объем обработчиков как реальный бюджет производительности, а не просто как вопрос стиля.

Обещание, которое уже выполнено, не равно обещанию, которое по-прежнему имеет значение.

Промежуточный уровень — асинхронность, память и производительность

Именно здесь решается судьба большинства собеседований для высоких позиций. Вопросы напоминают сессии отладки, потому что именно это и требуется.

Панель управления, замерзающая на две секунды

Сценарий. Изменение диапазона дат приводит к замораживанию вкладки. Клики не оказывают никакого эффекта, анимации прекращаются, спиннер так и не начинает вращаться. Сам запрос к сети занимает 180 мс.

Вопрос. Почему спиннер не анимируется, и куда девается время?

Ответ. Основной поток одновременно обрабатывает скрипты, макетирование, отрисовку и ввод. Если задержка стека вызовов слишком велика, даже спиннер не может быть отрисован, поскольку кадр, на котором он должен появиться, так и не генерируется. 180 мс — это время на сетевой запрос; замораживание вкладки обусловлено последующей синхронной обработкой — парсингом огромного JSON-данных, а затем применением операций map/filter к десяткам тысяч строк, при этом для каждой строки выделяется новый массив.

Перенесите тяжелые операции преобразования в Web Worker, разделите работу на части и делайте паузы между ними, а при возможности запрашивайте у сервера сводные данные.

// Yield to the event loop between chunks so input and paint can run
async function processInChunks(items, fn, chunkSize = 500) {
  const out = [];
  for (let i = 0; i < items.length; i += chunkSize) {
    for (const item of items.slice(i, i + chunkSize)) out.push(fn(item));
    await new Promise((r) => setTimeout(r, 0));
  }
  return out;
}

Неправильный подход. Использование конструкций async/await внутри цикла с высокой нагрузкой на процессор и обозначение его как неблокирующего. Дополнительных потоков не появляется; синхронная работа по-прежнему замораживает вкладку.

Дополнительно. Объясните разницу между микозадачами и макрозадачами в контексте такого замораживания, а также почему использование await Promise.resolve() всё равно приводит к длительным синхронным периодам.

Измерения. Рассматривайте конкурентность в JS как планирование работы и определяйте моменты, когда требуются дополнительные работники.

От ИИ

Результаты поиска для запроса, который пользователь уже удалил

Сценарий. При вводе «sam», а затем уточнении до «samantha» иногда отображаются результаты для «sam» после того, как уже появились результаты для «samantha». Трудно воспроизвести на быстром соединении.

Вопрос. Что происходит и каково правильное решение?

Ответ. Гонка между запросами, приходящими в нерегулярном порядке. Отправляются два запроса; медленнее из них относится к более старому запросу; побеждает тот, чей ответ поступит последним, и он получает право на обновление состояния. Рекомендуется использовать оба метода смягчения проблемы: отменять предыдущий запрос с помощью AbortController, а также защищать процесс записи в состояние, чтобы обновления происходили только на основе ответа на текущий запрос.

const controllerRef = useRef(null);

async function search(query) {
  controllerRef.current?.abort();
  const controller = new AbortController();
  controllerRef.current = controller;

  try {
    const res = await fetch(`/api/customers?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    });
    setResults(await res.json());
  } catch (err) {
    if (err.name !== 'AbortError') throw err;
  }
}

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

Дополнение. В каких случаях идентификатор запроса, растущий монотонно, окажется эффективнее AbortController для отклонения устаревших данных поиска?

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

Один и тот же запрос, пять раз

Сценарий. При загрузке пять компонентов запрашивают по адресу /api/current-user. Вкладка сети показывает пять одинаковых запросов; иногда после обновления профиля один из ответов становится устаревшим.

Вопрос. Как устранить дублирование запросов без переписывания всего слоя данных?

Ответ. Необходимо кэшировать обещание выполнения запроса, а не его результат. Ключ к хранилищу формируется на основе идентификатора запроса; общее обещание возвращается всем вызывающим компонентам, а ключ удаляется после того, как запрос будет выполнен, чтобы можно было повторно попытаться обработать ошибки и позже получить свежие данные. Библиотеки вроде TanStack Query и SWR добавляют к этой концепции правила аннулирования данных и определения их устаревшести.

const inFlight = new Map();

export function dedupedFetch(key, fetcher) {
  if (inFlight.has(key)) return inFlight.get(key);

  const promise = fetcher().finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

// All five callers receive the same promise
const user = await dedupedFetch('/api/me', () => fetch('/api/me').then((r) => r.json()));

Неправильный подход. Постоянное хранение готового результата запроса в переменной на уровне модуля. Дублирующиеся GET-запросы исчезают, но интерфейс продолжает отображать данные пользователя вчерашнего дня, пока кто-то не выполнит жесткую перезагрузку.

Дополнение. Если два вызова присваивают разные сигналы отмены к общему обрабатываемому обещанию, какая политика отмены гарантирует соблюдение правил обоими?

Решение. Аккуратное совместное использование обрабатываемых обещаний и заранее планирование процедуры аннулирования.

Один ненадежный поставщик блокирует страницу

Сценарий. Одновременно загружаются профиль, информация о оплате и всплывающее окно с рекомендациями от стороннего поставщика. Доступность поставщика составляет около 96%. При сбое возникают ошибки на всей странице, и информация об оплате становится недоступной.

Вопрос. Как следует перестроить запрос, и в чем разница между Promise.all и Promise.allSettled в данном случае?

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

const [profile, billing, recs] = await Promise.allSettled([
  getProfile(),
  getBilling(),
  withTimeout(getRecommendations(), 2000),
]);

if (profile.status === 'rejected' || billing.status === 'rejected') {
  return renderError();
}
render({
  profile: profile.value,
  billing: billing.value,
  recs: recs.status === 'fulfilled' ? recs.value : [],
});

Неправильный подход. Преобразование ошибок в null, чтобы Promise.all продолжал работать корректно. В результате телеметрия теряет информацию о причине ошибки, и все сбои выглядят одинаково.

Дополнительно. Выберите подход, соответствующий использованию Promise.any или Promise.race, и укажите, что происходит с обещаниями, которые не становятся победителями.

Критерии оценки. Уверенное использование комбинаторов обещаний и понимание возможных последствий их ошибочного применения.

Оплата, которая была взимаема дважды

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

Вопрос. Какая стратегия повторных попыток безопасна для конечной точки оплаты?

Ответ. Повторные попытки безопасны только для идемпотентных операций. Запрос POST для создания платежа по умолчанию не является идемпотентным — сделайте его таким с помощью ключа идемпотентности, генерируемого клиентом, на основе которого сервер исключает дубликаты. Повторяйте попытки только при сбоях передачи данных и ответах 5xx/429 — никогда при ответах 4xx. Разделяйте попытки с использованием экспоненциального отката и джиттера, чтобы не перегрузить восстанавливающийся сервер. Соблюдайте заголовки Retry-After и установите жесткый лимит количества попыток.

async function postWithRetry(url, body, key, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': key },
      body: JSON.stringify(body),
    });
    if (res.ok) return res.json();
    if (res.status < 500 && res.status !== 429) throw new HttpError(res);

    const backoff = 2 ** i * 300 + Math.random() * 300; // jitter
    await new Promise((r) => setTimeout(r, backoff));
  }
  throw new Error('Payment could not be confirmed');
}

Неправильный подход. Слепое повторение всех операций три раза с фиксированным интервалом. Ошибки таймаутов могут привести к двойной оплате, циклы с ответами 4xx никогда не приводят к успеху, а сбои усугубляются до перегрузки сервера.

Дальнейшие действия. У браузера истекло время ожидания, но оплата была обработана на стороне сервера — опишите путь восстановления, видимый для пользователя.

Измерения. Идемпотентный дизайн при неопределенности с клиентской стороны.

API, которое «зависает», вместо того чтобы выдать ошибку

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

Вопрос. Как это ограничить, и что входит в рамки действий после истечения времени ожидания?

Ответ. Браузеры не задают значение по умолчанию для таймаута в функции fetch — его необходимо указать явно. Современный подход заключается в использовании AbortSignal.timeout; AbortSignal.any сочетает таймаут с возможностью отмены пользователя. Также стоит внедрить механизм защиты: после нескольких подряд неудачных попыток следует прекратить вызовы на время охлаждения и сразу предоставить альтернативный вариант, тем самым защитив как приложение, так и зависимости, находящиеся в процессе восстановления.

const signal = AbortSignal.any([
  AbortSignal.timeout(3000),
  userController.signal,
]);

try {
  const res = await fetch(url, { signal });
  breaker.recordSuccess();
  return res.json();
} catch (err) {
  breaker.recordFailure();
  if (err.name === 'TimeoutError') return cachedFallback();
  throw err;
}

Неправильный подход. Использование таймера вместе с функцией fetch и притворство, что процесс завершился из-за таймера. HTTP-запрос продолжает выполняться, занимает сокеты и может изменить состояние даже после окончания работы.

Дополнительно. Необходимо устанавливать таймауты как в браузере, так и на уровне API-шлюза, а также при вызовах в Node к тем же поставщикам, чтобы избежать нерешенных задач.

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

Память увеличивается при каждой смене маршрута

Сценарий. Инструмент поддержки, оставленный в работающем состоянии весь день, занимает 1,4 ГБ. Снимки кучи памяти показывают, что отдельные узлы DOM увеличиваются в объеме с каждой навигацией по заявкам.

Вопрос. Каковы обычные причины и как это проверить?

Ответ. Отдельные узлы остаются активными, потому что на них по-прежнему существует ссылка: слушатели window/document так и не были удалены, функция setInterval продолжает работать, объекты IntersectionObserver/ResizeObserver не были отключены, существуют глобальные слушатели хранилища, а замыкания в долгоживущих кэшах сохраняют информацию о DOM. Чтобы это проверить, необходимо сделать снимок кучи памяти, выполнить навигацию, вызвать сбор мусора, снова сделать снимок, а затем проследить за объектами, удерживающими память, до тех пор, пока не станет ясно, кто именно это делает.

useEffect(() => {
  const onResize = () => recalcLayout();
  const observer = new ResizeObserver(onResize);
  const id = setInterval(pollTicket, 5000);

  window.addEventListener('resize', onResize);
  observer.observe(panelRef.current);

  return () => {
    window.removeEventListener('resize', onResize);
    observer.disconnect();
    clearInterval(id);
  };
}, []);

Неправильный подход. Установка значения null для локальных переменных в процессе очистки с ожиданием снижения использования памяти. Доступ к данным по-прежнему обеспечивается через активные слушатели и таймеры, которые работают с теми же данными.

Дополнение. Такой тип утечки памяти можно назвать WeakMap; его использование позволяет эффективно устранить проблему, однако в некоторых ситуациях слабые ссылки оказываются не подходящим инструментом для решения проблемы.

Практическое применение. Практическая отладка кучи памяти и знание принципов доступа к данным.

Счётчик, который всегда показывает ноль

Сценарий. Виджет запрашивает данные каждые пять секунд и добавляет уведомления. Он всегда отображает одно уведомление; лог-запись, создаваемая во время интервала, бесконечно выводит начальное состояние.

Вопрос. Почему интервал видит устаревшее состояние, и как это исправить?

Ответ. Эффект был выполнен один раз с использованием [], поэтому кallback сохранял состояние первой отрисовки. Обновления создают новые значения; старый кlosure по-прежнему ссылается на старое значение. Лучше использовать функциональные обновители, чтобы React предоставлял самое свежее значение из очереди. Когда кallbackу нужны данные, которые невозможно передать через обновитель, их следует отражать в ref при каждой отрисовке.

// Broken: `alerts` is frozen at the first render
useEffect(() => {
  const id = setInterval(() => setAlerts([...alerts, poll()]), 5000);
  return () => clearInterval(id);
}, []);

// Fixed: functional update, no stale capture
useEffect(() => {
  const id = setInterval(() => setAlerts((prev) => [...prev, poll()]), 5000);
  return () => clearInterval(id);
}, []);

Неправильный подход. Указание alerts в качестве зависимости эффекта. Устаревшие кlosures исчезают, но интервал снова запускается при каждом добавлении элемента, в результате чего интервал в пять секунд нарушается.

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

Анализ. Кlosures, «путешествующие» во времени — классическая проблема среднего уровня при работе с React.

Двадцать тысяч строк в DOM

Сценарий. Таблица инвентаря отображает каждую запись API. Отрисовка первого элемента занимает шесть секунд; фильтрация вызывает задержки; таблетка потребляет сотни мегабайт.

Вопрос. Как сделать таблицу пригодной для использования и что измерять в первую очередь?

Ответ. Сначала проанализируйте производительность: скрипты, стиль, макет и процесс отрисовки. Для таблиц такого размера обычно главным фактором является количество узлов DOM. Виртуализируйте список, чтобы в DOM присутствовали только элементы, видимые на экране, плюс небольшой буфер для обработки дополнительных данных. Дополните это стабильными идентификаторами строк, тщательной мемоизацией и фильтрацией/сортировкой на стороне сервера, когда объем данных на клиентской стороне становится слишком большим.

// Row identity matters as much as row count
{visibleRows.map((row) => (
  <Row key={row.id} data={row} />   // stable id, not the array index
))}

Неправильный подход. Оборачивание всех строк в React.memo и остановка на этом. Использование новых атрибутов внутри элемента нарушает работу мемоизации, а проблемы с макетом и отрисовкой остаются из-за десятков тысяч узлов.

Дополнение. Что идет не так с фокусом и контролируемыми вводами, когда ключи строк являются индексами массива и удаляется средняя строка?

Измерения. Привычка сначала измерять и реалистичные ограничения мемоизации.

От ИИ

Проблемы с макетом из-за циклов фильтрации/изменения размера

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

Вопрос. Какая причина этого явления и как его исправить?

Ответ. Проблемы с макетом. Обращение к геометрическим API, таким как смещения высоты, прямоугольники ограничений или позиции прокрутки, заставляет движок синхронно обновлять стили и макет, чтобы можно было получить точные результаты. Перемежение таких операций чтения с записью стилей внутри цикла приводит к необходимости обновления макета при каждой итерации. Сначала собирайте данные о размерах, затем изменяйте стили, а запись выполняйте с помощью requestAnimationFrame. Для логики показа/скрытия используйте IntersectionObserver, чтобы элементы появлялись асинхронно, не вызывая изменений макета.

// Broken: read, write, read, write
panels.forEach((p) => { p.style.height = p.offsetHeight * 1.2 + 'px'; });

// Fixed: batch reads, then batch writes
const heights = panels.map((p) => p.offsetHeight);
requestAnimationFrame(() => {
  panels.forEach((p, i) => { p.style.height = heights[i] * 1.2 + 'px'; });
});

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

Дополнительный вопрос. Какие свойства CSS остаются на уровне композитора, и когда will-change приводит к большим затратам, чем экономит?

Измерено. Затраты на обработку в браузере представляют собой последовательность действий, а не какая-то таинственная сумма.

Ожидание синхронных вычислений не освобождает цикл событий.

Продвинутый уровень — безопасность, Node, тестирование и проектирование

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

Поле CMS, которое выполнило скрипт

Сценарий. Отдел маркетинга хранит разметку с богатым текстом в безголовом CMS. В ходе проверки безопасности было обнаружено, что на каждой странице товара выполняется код <img onerror=...>.

Вопрос. Как это произошло и как исправить ситуацию во всей системе?

Ответ. Значение попадает в innerHTML или функцию React dangerouslySetInnerHTML без промывки данных. По умолчанию происходит экранирование (в React — {value}, в DOM — textContent). Когда требуется HTML, необходимо использовать библиотеку с поддерживаемым списком разрешенных элементов, такую как DOMPurify, причем это делается и на сервере, поскольку клиентская часть не является надежной зоной. Также следует добавить политику безопасности контента, чтобы непроверенный код не мог выполнять встроенные скрипты.

import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(cmsHtml, {
  ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'li'],
  ALLOWED_ATTR: ['href', 'title'],
});
<div dangerouslySetInnerHTML={{ __html: clean }} />

Неправильный подход. Удаление тегов <script> с помощью регулярных выражений. Атрибуты обработчиков событий, URL-адреса в формате javascript:, векторные графики SVG и вложенная кодировка остаются без проверки.

Дополнительно. Системы аналитики вставляют встроенные скрипты, которые блокируются политикой CSP — необходимо восстановить эти теги, не включая опцию unsafe-inline.

Измерено. Многоуровневая защита от XSS с очисткой данных во время отрисовки.

Токен в localStorage

Сценарий. После атаки типа XSS в записках по безопасности упоминаются сессионные токены в localStorage, сохраняемые с помощью интерцептора Axios. Необходим план действий.

Вопрос. Каковы преимущества и недостатки использования localStorage и куки для хранения токенов аутентификации, и какова рекомендация?

Ответ. Скрипты на исходном сервере могут читать localStorage, поэтому один случай XSS может привести к утечке всей сессии. HttpOnly-куки с параметром SameSite=Lax остаются недоступными для JavaScript, однако браузеры автоматически добавляют их, поэтому при изменении запросов всё равно требуются меры защиты от CSRF. Распространённый подход: краткосрочные токены доступа в памяти, токен обновления в HttpOnly-куке, токены CSRF или параметр SameSite при изменениях, ротация токенов при обновлении. Ничто не остаётся неприкосновенным от XSS — предотвращение XSS остаётся приоритетным.

// Server side, Express
res.cookie('refresh_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  path: '/auth/refresh',
  maxAge: 1000 * 60 * 60 * 24 * 7,
});

Неправильный подход. Шифрование токенов в localStorage с использованием ключа, который также находится в JavaScript-коде страницы. Любой, кто может читать данные хранилища, может прочитать и ключ — это чистая формальность.

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

Измерено. Выбор способов хранения токенов на основе моделирования угроз вместо слоганов.

Конечная точка Node, замедляющая все остальные

Сценарий. Обработчик PDF-отчетов в Express при вызове приводит к тайм-аутам нерелевантных проверок работоспособности и резкому увеличению показателя p99 во всем сервисе.

Вопрос. Почему одна конечная точка влияет на все остальные и как это исправить?

Ответ. Node выполняет JavaScript-код приложения в одном потоке. Работа, требующая больших вычислительных ресурсов CPU, блокирует цикл событий — другие запросы, таймеры и обратные вызовы I/O не могут выполняться. Перенесите работу с высокой нагрузкой на CPU в worker_threads или во внешнюю очередь, чтобы обработчик HTTP только добавлял задачи в очередь и отвечал на них. Используйте потоковую передачу больших объемов данных вместо буферизации. Предпочитайте асинхронные API для работы с криптографией вместо их синхронных версий; никогда не используйте readFileSync на пути запроса.

import { Worker } from 'node:worker_threads';

app.post('/reports', async (req, res) => {
  const job = await queue.add('generate-report', req.body); // returns immediately
  res.status(202).json({ jobId: job.id, status: 'queued' });
});

// Or for in process CPU work
const worker = new Worker('./report-worker.js', { workerData: params });

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

Дополнение. Какие сигналы из продакшена позволяют обнаружить заблокированный цикл событий Node до того, как начнутся жалобы клиентов?

Измерения. Разделение задач, зависящих от ввода-вывода, и задач, зависящих от процессора, с использованием соответствующих сигналов из продакшена.

Неправильное отображение контента при загрузке (гидратация)

Сценарий. Приложение Next.js отрисовывает на сервере текст «Войти», а затем после загрузки показывает имя пользователя. Появляются предупреждения о несоответствии гидратации; иногда появляется сообщение Текстовый контент не совпадает с HTML, отрендеренным на сервере.

Вопрос. Что вызывает несоответствия при гидратации, и как это исправить?

Ответ. У сервера отсутствует состояние, характерное исключительно для браузера: куки, доступные только на стороне клиента, localStorage, window.matchMedia, Date.now(). Процесс гидратации предполагает, что первая отрисовка на стороне клиента будет соответствовать структуре данных сервера. В случае несоответствия React игнорирует маркировку сервера для данной подструктуры и выдает предупреждение. Чтобы отобразить данные сессии во время отрисовки на сервере, необходимо прочитать куки на сервере и передать полученные значения в структуру данных. Для значений, существующих исключительно в браузере, следует использовать стабильный заменитель, который будет обновлен после монтирования компонента.

// Server component reads the session, so no mismatch
export default async function Layout({ children }) {
  const session = await getSession(cookies());
  return <AuthProvider value={session}>{children}</AuthProvider>;
}

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

Дополнительный вопрос. Как можно отображать относительные временные метки на сервере без конфликта между часами сервера и часами клиента в процессе гидратации?

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

Кэш, который никогда не освобождается

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

Вопрос. Почему записи не удаляются, и как WeakMap влияет на результат?

Ответ. Механизм сбора мусора определяет доступность элементов от корней иерархии. Обычные записи в Map фиксируют как ключ, так и значение, поэтому отделенный элемент DOM, используемый в качестве ключа, сохраняет доступ к своему буферу холста. WeakMap хранит ключи слабо — когда ничто другое не ссылается на элемент, соответствующая запись может исчезнуть вместе с ним. WeakMap не является перечисляемым и не имеет свойства размера; именно этот компромисс обеспечивает безопасность использования слабых ссылок.

// Leaks: the Map keeps removed nodes alive
const cache = new Map();

// Collectable: entry dies with the element
const cache = new WeakMap();
cache.set(chartEl, renderedCanvas);

Неверный подход. Методы вроде Cron, которые удаляют записи кэша тех узлов, что покинули документ. Всё работает нормально, пока не забудут об этих процедурах; WeakMap избавляет от необходимости выполнять такие действия.

Дополнение. Когда целесообразно использование FinalizationRegistry, и почему корректность кода никогда не должна зависеть от момента его выполнения?

Измерение. Очистка памяти на основе проверки доступности элементов, без попыток контролировать время её выполнения.

Тест, который проваливается раз в двадцать запусков

Сценарий. Тест компонента поиска проходит локально, но проваливается примерно в 5% случаев в CI при поиске текста «Samantha». Кто-то уже добавил waitFor(3000), и CI пытается запустить тест снова.

Вопрос. Как сделать тест надежным, и что говорит о его нестабильности?

Ответ. Нестабильные асинхронные тесты обычно проверяют временные характеристики, а не поведение. Для устранения проблем с задержками используйте фиктивные таймеры, замените HTTP-запросы на имитации с помощью инструментов вроде MSW для стабильных данных, а также применяйте проверки, которые ожидают обновления DOM, вместо того чтобы ждать фиксированное количество миллисекунд. Если для прохождения теста требуется произвольная пауза, это часто означает настоящую конкуренцию за ресурсы в компоненте — тест работает корректно.

test('shows results for the final query', async () => {
  vi.useFakeTimers();
  render(<Search />);

await userEvent.type(screen.getByRole('searchbox'), 'samantha');
  await vi.advanceTimersByTimeAsync(300); // debounce window
  expect(await screen.findByText('Samantha')).toBeInTheDocument();
});

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

Дополнение. Как тест может заставить старый результат поиска прийти после нового?

Решение. Рассматривать нестабильность как сигнал и делать асинхронные тесты детерминированными.

Шестьдесят мест, где используется функция fetch

Сценарий. У четырехлетней базы кода функция fetch используется в шестидесяти компонентах. Обработка ошибок носит неоднородный характер; процедура обновления авторизации копируется одиннадцать раз; никто не знает значения ежедневных таймаутов.

Вопрос. Как спроектировать общий слой доступа к данным и как осуществить миграцию без приостановки работы?

Ответ. Соберите все общие функции в один клиент: базовый URL и заголовки, процедуру обновления авторизации с единственным вызовом, чтобы серия ошибок 401 приводила к одному обновлению, настройку таймаутов, повторные попытки только в идемпотентном режиме, нормализацию ошибок с учетом типов и интеграцию механизмов отслеживания. Сохраняйте простоту интерфейса, чтобы использование нового клиента превосходило желание обходить его. Мигрируйте поэтапно — сначала выпустите клиент, затем перенесите самые загруженные и наиболее подверженные ошибкам пути, запретите использование необработанной функции fetch в новом коде с помощью инструментов проверки, чтобы границы изменений не расширялись.

type ApiError =
  | { kind: 'network' }
  | { kind: 'timeout' }
  | { kind: 'http'; status: number; body: unknown }
  | { kind: 'parse' };

export async function apiRequest<T>(
  path: string,
  init: RequestInit & { timeoutMs?: number } = {},
): Promise<{ ok: true; data: T } | { ok: false; error: ApiError }> {
  // timeout, auth, retry, telemetry all live here
}

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

Дальнейшие шаги. Через шесть месяцев: как правила проверки кода и процесс ревью помогают не допустить возвращения использования прямого fetch?

Практическое применение. Проектирование API в масштабах команды и поэтапная миграция при давлении сроков.

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

Сознательно воспроизводите сбои. Отправьте поле поиска, которое работает медленно из-за ограничений сети, затем откройте вкладки «Производительность» и «Сеть», пока не станут заметны проблемы с отображением. Оставьте один из интервалов незаполненным, перейдите на другую вкладку и найдите соответствующие данные в разделе «Память». Практический опыт работы с инструментами DevTools помогает лучше, чем любые руководства.

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

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

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

Ведите краткий список сбоев с указанием причин и способов их устранения — это заметки, а не портфолио. Когда вас спрашивают о сложной ошибке, конкретная диагностика всегда лучше общих рассуждений.

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

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

Собеседования, основанные на реальных ситуациях из производства, также позволяют оценить навыки коммуникации. Описание ситуации без избытка жаргона, быстрое нарисование диаграммы последовательностей для AbortController или объяснение того, почему параметр allSettled сокращает радиус поражения, показывают, как человек будет вести себя во время инцидента. Ответы на тривиальные вопросы редко демонстрируют такие навыки, тогда как ответы на сценарные вопросы почти всегда их показывают, именно поэтому этот формат продолжает распространяться среди специалистов среднего и высокого уровня.