Performance and Security FAQ: Slow, Janky, and Untrusted Data
Bottom line: Why a JSON file of tens of MB will not open, how expensive deep cloning is, how to prevent __proto__ pollution, and where XSS gets in — ten first-aid conclusions arranged by symptom.

Bottom line: diagnose by symptom first — parsing, rendering, or memory — then work through the fix order: cut the data volume, stop deep cloning, move parsing into a Worker with virtual scrolling, and stream anything above 50 MB. For untrusted input, cap size and nesting depth before filtering __proto__, constructor and prototype.
"JSON is slow" is inaccurate nine times out of ten; what is actually slow is usually rendering, deep cloning, or the wrong order of operations.
1. Why it is slow
Parsing a large JSON file is slow. How do I optimize it?
Locate the bottleneck first, talk about optimization second. The stages differ by several orders of magnitude:
| Stage | Rough time for 10 MB of data | Why it is the bottleneck |
|---|---|---|
| Network transfer | Depends on bandwidth | Uncompressed JSON usually compresses to about 1/5 |
JSON.parse |
20–80 ms | A native implementation, already fast |
| Recursive traversal / deep clone | 200–2000 ms | Pure JS node-by-node processing |
| DOM rendering (tree view) | Seconds, easily freezes | The node count explodes |
So the optimization order should be:
- Reduce the data volume: make the API paginate, return only the fields that are needed, and enable gzip/brotli;
- Avoid deep cloning: see the next section; this is the most common hidden cost;
- Lazy rendering: in the tree view render only the expanded levels instead of generating tens of thousands of DOM nodes at once;
- Streaming: do not parse everything at once when the data exceeds 10 MB.
If the bottleneck really is JSON.parse, that means the data is already tens of MB or more, and the right move is a streaming parser:
import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';
// Handle array elements one by one; memory usage is independent of the total size
const pipeline = fs.createReadStream('big.json')
.pipe(parser())
.pipe(streamArray());
pipeline.on('data', ({ value }) => {
processItem(value);
});
A JSON of tens of MB crashes the browser as soon as it opens. What now?
First confirm whether it "crashes" or "freezes". The two need completely different handling:
- Freeze: the tab shows "Page unresponsive" and may recover after tens of seconds. The cause is long tasks saturating the main thread;
- Crash: the tab turns into "Aw, Snap", usually memory exhaustion (OOM).
Either way, do not process it on the main thread. Move parsing into a Web Worker:
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
A Worker solves "the UI does not freeze", not the memory ceiling. A browser tab typically has 1–4 GB available; a 200 MB JSON parsed into objects can occupy 1 GB or more (every key-value pair carries object overhead), and with rendering on top it easily hits the limit.
The real solution is to never load it all:
| Data size | Approach |
|---|---|
| < 5 MB | Parse with JSON.parse directly, watch the rendering |
| 5–50 MB | Worker parsing + virtual scrolling |
| > 50 MB | Streaming parsing, keep only the fields you need |
| > 500 MB | Change the format (Parquet / SQLite / sharded JSON Lines) |
Is JSON.parse(JSON.stringify(obj)) a deep clone?
It is a deep clone, but it "silently changes data" in several places, so it is not suitable as a general cloning tool.
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 becomes a string
// nan: null, // NaN becomes null
// nested: { a: 1 } // only this level is correct
// }
// fn and undef disappear, map becomes {}
The list of problems:
| Type | Result |
|---|---|
Date |
Becomes an ISO string |
NaN / Infinity |
Becomes null |
undefined / functions / Symbol |
Disappears inside objects, becomes null inside arrays |
Map / Set |
Becomes {} |
BigInt |
Throws TypeError |
| Circular references | Throws TypeError |
Modern browsers have structuredClone, which supports Date, Map, Set, and circular references but not functions:
const copy = structuredClone(source);
If it is only a shallow config merge, the spread operator is the cheapest option: { ...config, timeout: 3000 }.
Why is expanding the tree view so janky?
Because every node is a DOM element, and once the node count reaches tens of thousands the browser's layout cost explodes.
A 10 MB JSON can contain 50k–100k leaf nodes. Even if you render only the visible area, constructing the DOM once takes hundreds of milliseconds to seconds, and every subsequent scroll triggers a reflow.
The right approach:
- Virtual scrolling: render only the few dozen rows inside the viewport and reuse DOM nodes while scrolling;
- Collapse by default: expand only the first two levels and render deeper levels on demand;
- Limit the render scale: above 10k nodes, show "Data too large, use search to locate it" instead of forcing a render.
This is also why the editor on this site degrades gracefully for large files — not because it technically cannot render them, but because rendering them would be unusable.
2. Why it gets attacked
How does __proto__ injection work?
Prototype pollution. An attacker constructs JSON with a __proto__ key; if your code merges it into an object without filtering,
it overwrites Object.prototype and affects every object in the runtime.
const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');
// Dangerous code: recursive merge without checking key names
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 — every object has been polluted
The key fact: JSON.parse itself does not pollute the prototype; {"__proto__": ...} parses into an ordinary own property.
What is dangerous is merging, assignment, Object.assign, lodash.merge, and similar operations after parsing.
Three defenses:
- Filter key names: skip
__proto__,constructor, andprototypewhen merging; - Use
Object.create(null)as the container so there is no prototype chain to pollute; - Use a
Mapto store untrusted key-value pairs.
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;
}
Also note that for...in walks inherited properties; Object.keys() is safer.
What should I watch out for when parsing untrusted JSON?
Do it in this order — low cost, high payoff:
- Cap the size: reject anything over the threshold immediately, without reading it into memory first;
- Cap the depth: a JSON nested hundreds of levels deep can overflow the stack of a recursive parser;
- Cap the key count: prevents hash collision attacks and memory amplification;
- Filter dangerous keys:
__proto__,constructor,prototype; - Validate the structure: run it through a Schema to confirm types and required fields;
- Do not trust
Content-Type: the declared type and the actual content may disagree.
A depth check is cheap; you can use a rough regex filter before parsing, or traverse after parsing:
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;
}
But this code is itself recursive and may still overflow the stack on maliciously deep data. The safe approach is to enforce the limit during parsing,
using a parser that supports a depth option (some libraries provide a maxDepth parameter).
How does XSS through JSON happen?
JSON itself is not HTML and does not execute. XSS happens at the point where "a JSON value is concatenated into HTML".
Three common paths:
| Path | Example | Defense |
|---|---|---|
| Server-side template concatenation | res.send('<div>' + data.name + '</div>') |
HTML-escape on output |
Frontend innerHTML |
el.innerHTML = data.html |
Use textContent or sanitize |
| Inline script embedding | <script>var d = ${JSON.stringify(data)}</script> |
Escape <, >, & |
The third is the subtlest. JSON.stringify does not escape <, so data containing </script><script>alert(1)</script>
can close the script tag early:
const data = { name: '</script><script>alert(1)</script>' };
// Output: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>
The fix is to convert dangerous characters into Unicode escapes:
function safeJsonForHtml(data) {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Should I limit nesting depth?
Yes. This is one of the defenses with the best cost-benefit ratio.
The consequences of not limiting it: recursive functions overflow the stack, JSON.parse crashes on some implementations when the depth is too great,
and serialization blows the stack in the same way. A 1 KB [[[[...]]]] is enough to take down an unprotected parser.
A reasonable threshold depends on the business:
| Data type | Suggested limit |
|---|---|
| Ordinary business objects | 10–20 levels |
| Tree structures (org charts, directories) | 50–100 levels |
| General API gateway | 20 levels |
What is JSON hijacking?
A historical vulnerability: early browsers allowed cross-origin <script src> to load and execute JSON, and attackers stole data by redefining
the Array constructor or Object.prototype.__defineSetter__.
Modern protections are fairly complete:
- Browsers no longer allow hijacking array literals with
__defineSetter__; - A correct
Content-Type: application/jsonprevents script execution; X-Content-Type-Options: nosniffprevents MIME sniffing.
Older systems still carry residual risk. Practical advice: use an object at the top level, not a bare array.
A wrapper such as {"data": [...]} keeps the response from being a valid JavaScript statement ({ is treated as a code block),
blocking hijacking at the syntax level.
What are the risks of logging JSON?
Three:
- Sensitive information written to disk: phone numbers, ID numbers, tokens, and plaintext passwords land in the logging system, which usually has looser permissions and longer retention;
- Log injection: a JSON value containing newlines and forged log prefixes splits one record into several and pollutes the audit trail;
- Size explosion: writing an entire 10 MB response into the log fills the logging system quickly.
Approach:
// Redact and truncate before logging
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;
}
Structured logging systems (such as pino) escape newlines themselves, but redaction and truncation are still your job.
3. Quick reference
| Symptom | Check first | Fix |
|---|---|---|
| Slow parsing | Data volume, whether deep cloning is involved | Chunking/streaming, remove JSON.parse(JSON.stringify()) |
| Page freezes | Long tasks on the main thread, number of rendered nodes | Worker + virtual scrolling |
| Tab crashes | Memory usage | Do not load everything; switch to streaming |
| Deep clone loses data | Date, NaN, Map |
Use structuredClone |
| Objects suddenly have extra properties | Prototype pollution | Filter __proto__, use a Map |
| A script pops up | Strings concatenated into HTML | Escape <, use textContent |
| Stack overflow | Nesting depth | Limit to 10–20 levels |
| Logs fill up | The whole response is logged | Truncate + redact |