Головна / Статті / Двадцять п’ять сценаріїв співбесід з JavaScript, пов’язаних із проблемами в продакшені.

Двадцять п’ять сценаріїв співбесід з JavaScript, пов’язаних із проблемами в продакшені.

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

4513 слів

Від AI

Двадцять п’ять запитань з 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 у циклі з високою навантаженням на CPU та називання його неблокуючим. Додаткових потоків не створюється; синхронна робота все одно заморожує вкладку.

Додатково. Поясніть різницю між мікрозавданнями та макрозавданнями у цьому випадку заморожування та чому використання await Promise.resolve() все одно залишає довгі синхронні періоди.

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

Від AI

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

Сценарій. При введенні “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;
  }
}

Неправильний підхід. Розтягування часу дії механізму debounce до цілої секунди та оголошення про перемогу. Конкуренція стає рідшою, користувацький досвід погіршується, а повільні мережі все одно переупорядковують відповіді.

Додатково. Коли ідентифікатор запиту, який постійно зростає, може перевершити AbortController у відкиданні застарілих даних пошуку?

Вимірювання. Правильна назва конкуренції та уникнення впливу механізму скасування на панелі помилок.

Той самий запит, п’ять разів

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

Питання. Як усунути дублювання запитів під час виконання, не переписуючи всю шару даних?

Відповідь. Зберігайте у кеші promise, а не результат. Використовуйте ідентифікатор запиту як ключ у мапі, повертайте спільний promise кожному користувачеві, видаляйте ключ після того, як promise буде виконаний, щоб можна було спробувати знову у разі помилки та отримати актуальні дані пізніше. Бібліотеки на кшталт 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()));

Неправильний підхід. Вічне зберігання виконаних даних у змінній на рівні модуля. Дублікати запитів зникають, але інтерфейс продовжує відображати дані користувача з минулого дня, поки хтось не виконає жорстке оновлення сторінки.

Додатково. Якщо два користувачі додають різні сигнали припинення до спільного обіцяння, яка політика скасування забезпечить точність обох?

Рішення. Розумне спільне використання обіцянь та попереднє планування їх скасування.

Один ненадійний постачальник заважає роботі сторінки

Сценарій. Профіль, оплата та віджет рекомендацій від стороннього постачальника завантажуються одночасно. Надійність постачальника становить приблизно 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 ефективно їх усуває, але слабкі посилання є неправильним інструментом для вирішення проблеми.

Практичне застосування. Безпосередня налагодка купи пам’яті та розуміння механізмів доступу до даних.

Лічильник, який завжди фіксує нуль

Сценарій. Віджет перевіряє стан кожні п’ять секунд та додає сповіщення. Він завжди відображає лише одне сповіщення; журнал подій у межах цього інтервалу постійно виводить початковий стан.

Питання. Чому інтервал бачить застарілий стан, та як це виправити?

Відповідь. Ефект був виконаний один раз за допомогою [], тому керуюча функція працювала зі станом першого відображення. Оновлення створюють нові значення; стара керуюча функція все ще посилається на старе значення. Краще використовувати функціональні оновлювачі, щоб React надавав найсвіжіше значення з черги. Якщо керуючій функції потрібні дані, яких не може надати оновлювач, їх потрібно копіювати у 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 як залежності ефекту. Старі керуючі функції зникають, проте інтервал починає працювати знову після кожного додавання елемента, тому інтервал у п’ять секунд порушується.

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

Аналіз. Керуючі функції, які „подорожують у часі“ — класична проблема середнього рівня у 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 Secure-кукі з параметром 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, блокує цикл подій — жодних інших запитів, таймерів чи обробників вводу-виводу. Перенесіть роботу, що вимагає багато 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 });

Неправильний підхід. Чекається виконання завдань, що залежать від CPU, у надії на те, що цикл подій продовжуватиме працювати. Модель масштабування шляхом додавання нових інстанцій лише посилює ситуацію, оскільки кожна з них все одно застрягає на обробці аналогічних запитів.

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

Вимірювання. Розділення завдань, що залежать від IO та CPU, із використанням відповідних сигналів з продакшну.

Неправильний вміст під час завантаження (гідратація)

Сценарій. Додаток 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 повторює спробу.

Питання. Як зробити тест надійним, і що може сказати про його ненадійність?

Відповідь. Нестабільні асинхронні тести зазвичай перевіряють час виконання, а не поведінку. Використовуйте фейкові таймери для реалізації механізму debounce, замінюйте 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 зменшує радіус ураження, показують, як людина буде поводитися під час інциденту. Відповіді на дрібні запитання рідко демонструють такі навички, тоді як відповіді на сценарні запитання майже завжди їх показують, саме тому цей формат продовжує поширюватися серед команд, які займаються наймом спеціалістів середнього та вищого рівня.