Часті питання про продуктивність і безпеку: повільно, гальмує й недовірені дані
Головне: Чому файл JSON на десятки МБ не відкривається, скільки коштує глибоке клонування, як запобігти забрудненню __proto__ і звідки входить XSS — десять висновків першої допомоги, впорядкованих за симптомом.

Коротко: спершу діагностуйте за симптомом — розбір, відображення чи памʼять — потім проходьте порядок виправлень: скоротіть обсяг даних, припиніть глибоке клонування, перенесіть розбір у Worker із віртуальною прокруткою й потоково обробляйте все понад 50 МБ. Для недовіреного вводу обмежте розмір і глибину вкладеності, перш ніж фільтрувати __proto__, constructor і prototype.
«JSON повільний» неточно в девʼяти випадках із десяти; насправді повільним зазвичай є відображення, глибоке клонування або неправильний порядок операцій.
1. Чому повільно
Розбір великого файлу JSON повільний. Як його оптимізувати?
Спершу локалізуйте вузьке місце, потім говоріть про оптимізацію. Етапи різняться на кілька порядків:
| Етап | Приблизний час для 10 МБ даних | Чому це вузьке місце |
|---|---|---|
| Передача мережею | Залежить від пропускної здатності | Нестиснений JSON зазвичай стискається приблизно до 1/5 |
JSON.parse |
20–80 мс | Нативна реалізація, уже швидка |
| Рекурсивний обхід / глибока копія | 200–2000 мс | Чиста JS-обробка вузол за вузлом |
| Відображення DOM (деревний вигляд) | Секунди, легко замерзає | Кількість вузлів вибухає |
Тож порядок оптимізації має бути таким:
- Скоротіть обсяг даних: нехай API пагінує, повертає лише потрібні поля й увімкніть gzip/brotli;
- Уникайте глибокого клонування: дивіться наступний розділ; це найпоширеніша прихована ціна;
- Ліниве відображення: у деревному вигляді відображайте лише розгорнуті рівні, замість генерувати десятки тисяч DOM-вузлів одразу;
- Потокова обробка: не розбирайте все одразу, коли дані перевищують 10 МБ.
Якщо вузьке місце справді JSON.parse, це означає, що дані вже десятки МБ чи більше, і правильний хід — потоковий парсер:
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// Обробляйте елементи масиву один за одним; використання памʼяті не залежить від загального розміру
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
JSON на десятки МБ валить браузер у момент відкриття. Що тепер?
Спершу підтвердьте, чи він «валить», чи «замерзає». Ці два потребують цілком різного поводження:
- Замерзає: вкладка показує «Page unresponsive» і може відновитися за десятки секунд. Причина — довгі задачі, що насичують головний потік;
- Валить: вкладка перетворюється на «Aw, Snap», зазвичай вичерпання памʼяті (OOM).
Так чи інакше не обробляйте це на головному потоці. Перенесіть розбір у Web Worker:
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
Worker розвʼязує «UI не замерзає», а не стелю памʼяті. Вкладка браузера зазвичай має доступні 1–4 ГБ; JSON на 200 МБ, розібраний у обʼєкти, може зайняти 1 ГБ чи більше (кожна пара ключ-значення несе накладні витрати обʼєкта), а з відображенням згори легко сягає ліміту.
Справжнє рішення — ніколи не завантажувати все:
| Розмір даних | Підхід |
|---|---|
| < 5 МБ | Розбирайте безпосередньо JSON.parse, стежте за відображенням |
| 5–50 МБ | Розбір у Worker + віртуальна прокрутка |
| > 50 МБ | Потоковий розбір, тримайте лише потрібні поля |
| > 500 МБ | Змініть формат (Parquet / SQLite / шардований JSON Lines) |
Чи є JSON.parse(JSON.stringify(obj)) глибокою копією?
Це глибока копія, але вона «мовчки змінює дані» в кількох місцях, тож не підходить як загальний інструмент клонування.
const source = {
date: new Date(),
fn: () => {},
undef: undefined,
nan: NaN,
map: new Map(),
nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
// date: "2026-10-30T03:00:00.000Z", // Date стає рядком
// nan: null, // NaN стає null
// nested: { a: 1 } // лише цей рівень правильний
// }
// fn і undef зникають, map стає {}
Список проблем:
| Тип | Результат |
|---|---|
Date |
Стає рядком ISO |
NaN / Infinity |
Стає null |
undefined / функції / Symbol |
Зникає всередині обʼєктів, стає null усередині масивів |
Map / Set |
Стає {} |
BigInt |
Кидає TypeError |
| Циклічні посилання | Кидає TypeError |
Сучасні браузери мають structuredClone, що підтримує Date, Map, Set і циклічні посилання, але не функції:
const copy = structuredClone(source);
Якщо це лише поверхневе злиття конфігурації, оператор розширення — найдешевший варіант: { ...config, timeout: 3000 }.
Чому розгортання деревного вигляду так гальмує?
Бо кожен вузол — DOM-елемент, і щойно кількість вузлів сягає десятків тисяч, ціна компонування браузера вибухає.
JSON на 10 МБ може містити 50 тис.–100 тис. листових вузлів. Навіть якщо ви відображаєте лише видиму область, побудова DOM один раз займає сотні мілісекунд до секунд, а кожна наступна прокрутка спричиняє перекомпонування.
Правильний підхід:
- Віртуальна прокрутка: відображайте лише кілька десятків рядків усередині вікна перегляду й повторно використовуйте DOM-вузли під час прокрутки;
- Згортання за замовчуванням: розгортайте лише перші два рівні й відображайте глибші рівні на вимогу;
- Обмежте масштаб відображення: понад 10 тис. вузлів покажіть «Дані завеликі, скористайтеся пошуком для локалізації» замість примусового відображення.
Саме тому редактор на цьому сайті коректно деградує для великих файлів — не тому, що технічно не може їх відобразити, а тому, що відображення було б непридатним.
2. Чому його атакують
Як працює інʼєкція __proto__?
Забруднення прототипу. Атакувальник конструює JSON із ключем __proto__; якщо ваш код зливає його в обʼєкт без фільтрації,
він перезаписує Object.prototype і впливає на кожен обʼєкт у середовищі виконання.
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Небезпечний код: рекурсивне злиття без перевірки назв ключів
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object') {
target[key] = target[key] || {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, payload);
({}).isAdmin; // true — кожен обʼєкт забруднено
Ключовий факт: сам JSON.parse не забруднює прототип; {"__proto__": ...} розбирається в звичайну власну властивість.
Небезпечні — злиття, присвоєння, Object.assign, lodash.merge та подібні операції після розбору.
Три захисти:
- Фільтруйте назви ключів: пропускайте
__proto__,constructorіprototypeпід час злиття; - Використовуйте
Object.create(null)як контейнер, щоб не було ланцюга прототипів для забруднення; - Використовуйте
Mapдля зберігання недовірених пар ключ-значення.
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
continue;
}
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(target[key] ?? {}, value);
} else {
target[key] = value;
}
}
return target;
}
Зауважте також, що for...in обходить успадковані властивості; Object.keys() безпечніше.
На що звертати увагу під час розбору недовіреного JSON?
Робіть у такому порядку — низька ціна, висока віддача:
- Обмежте розмір: негайно відхиляйте все понад поріг, не читаючи в памʼять спершу;
- Обмежте глибину: JSON, вкладений на сотні рівнів, може переповнити стек рекурсивного парсера;
- Обмежте кількість ключів: запобігає атакам колізій хешу й підсиленню памʼяті;
- Фільтруйте небезпечні ключі:
__proto__,constructor,prototype; - Перевірте структуру: проженіть через Schema, щоб підтвердити типи й обовʼязкові поля;
- Не довіряйте
Content-Type: оголошений тип і фактичний вміст можуть не збігатися.
Перевірка глибини дешева; ви можете використати грубий фільтр регулярним виразом перед розбором або обійти після розбору:
function measureDepth(value, depth = 0) {
if (value === null || typeof value !== 'object') {
return depth;
}
let maxDepth = depth;
for (const key of Object.keys(value)) {
maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
}
return maxDepth;
}
Але цей код сам рекурсивний і може все одно переповнити стек на зловмисно глибоких даних. Безпечний підхід — забезпечувати ліміт під час розбору,
використовуючи парсер із підтримкою опції глибини (деякі бібліотеки надають параметр maxDepth).
Як трапляється XSS через JSON?
Сам JSON не є HTML і не виконується. XSS трапляється в точці, де «значення JSON склеюється в HTML».
Три поширені шляхи:
| Шлях | Приклад | Захист |
|---|---|---|
| Склеювання в шаблоні на сервері | res.send('<div>' + data.name + '</div>') |
HTML-екранування під час виводу |
innerHTML на фронтенді |
el.innerHTML = data.html |
Використовуйте textContent або санітизацію |
| Вбудовування в інлайн-скрипт | <script>var d = ${JSON.stringify(data)}</script> |
Екрануйте <, >, & |
Третій найтонший. JSON.stringify не екранує <, тож дані, що містять </script><script>alert(1)</script>,
можуть передчасно закрити тег script:
const data = { name: '</script><script>alert(1)</script>' };
// Вивід: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
Виправлення — перетворити небезпечні символи на Unicode-escape:
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Чи обмежувати глибину вкладеності?
Так. Це один із захистів із найкращим співвідношенням ціни й результату.
Наслідки необмеження: рекурсивні функції переповнюють стек, JSON.parse валиться в деяких реалізаціях, коли глибина завелика,
а серіалізація так само зриває стек. [[[[...]]]] розміром 1 КБ достатньо, щоб покласти незахищений парсер.
Розумний поріг залежить від бізнесу:
| Тип даних | Пропонований ліміт |
|---|---|
| Звичайні бізнес-обʼєкти | 10–20 рівнів |
| Деревні структури (оргсхеми, каталоги) | 50–100 рівнів |
| Загальний шлюз API | 20 рівнів |
Що таке викрадення JSON?
Історична вразливість: ранні браузери дозволяли міжсайтовому <script src> завантажувати й виконувати JSON, і атакувальники крали дані, перевизначивши
конструктор Array або Object.prototype.__defineSetter__.
Сучасні захисти досить повні:
- Браузери більше не дозволяють викрадати масивні літерали через
__defineSetter__; - Правильний
Content-Type: application/jsonзапобігає виконанню скрипта; X-Content-Type-Options: nosniffзапобігає визначанню MIME.
Старіші системи все ще несуть залишковий ризик. Практична порада: використовуйте обʼєкт на верхньому рівні, а не голий масив.
Обгортка на кшталт {"data": [...]} не дає відповіді бути валідним твердженням JavaScript ({ трактується як блок коду),
блокуючи викрадення на синтаксичному рівні.
Які ризики логування JSON?
Три:
- Чутлива інформація, записана на диск: номери телефонів, номери ID, токени й паролі в чистому вигляді потрапляють у систему логування, що зазвичай має слабші дозволи й довше зберігання;
- Інʼєкція в лог: значення JSON, що містить переведення рядків і підроблені префікси логу, розбиває один запис на кілька й забруднює слід аудиту;
- Вибух розміру: запис усієї 10-мегабайтної відповіді в лог швидко заповнює систему логування.
Підхід:
// Редагуйте й обрізайте перед логуванням
function safeLog(data) {
const cloned = structuredClone(data);
for (const key of ['password', 'token', 'idCard', 'phone']) {
if (key in cloned) cloned[key] = '***';
}
const text = JSON.stringify(cloned);
return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}
Структуровані системи логування (як-от pino) самі екранують переведення рядків, але редагування й обрізання все ще ваша робота.
3. Стислий довідник
| Симптом | Перевірте спершу | Виправлення |
|---|---|---|
| Повільний розбір | Обсяг даних, чи задіяне глибоке клонування | Чанкування/потокова обробка, приберіть JSON.parse(JSON.stringify()) |
| Сторінка замерзає | Довгі задачі на головному потоці, кількість відображених вузлів | Worker + віртуальна прокрутка |
| Вкладка валиться | Використання памʼяті | Не завантажуйте все; перейдіть на потокову обробку |
| Глибока копія втрачає дані | Date, NaN, Map |
Використовуйте structuredClone |
| Обʼєкти раптом мають зайві властивості | Забруднення прототипу | Фільтруйте __proto__, використовуйте Map |
| Вискакує скрипт | Рядки, склеєні в HTML | Екрануйте <, використовуйте textContent |
| Переповнення стека | Глибина вкладеності | Обмежте 10–20 рівнями |
| Логи заповнюються | Логується вся відповідь | Обрізання + редагування |