A few years back, I spent about twenty minutes debugging why a user's shopping cart kept resetting on page refresh. The code looked completely fine:
localStorage.setItem("cart", cartItems);
Open dev tools, check localStorage, and there it is: "[object Object]". Not the data. Just that string, staring back at me.
That bug — and the dozens of variations of it I've seen since, in code reviews and Stack Overflow threads alike — comes down to one missing step: turning a JavaScript object into an actual JSON string before handing it to something that only understands strings. This guide covers that step in depth: how it works, where you'll use it, and the edge cases that will eventually get you if nobody tells you about them first.
TL;DR
- Use
JSON.stringify(value)to convert a JS value into a JSON-formatted string. - Use
JSON.stringify(value, null, 2)when you want it human-readable. -
undefined, functions, and symbols are silently dropped.NaNandInfinitybecomenull. - Circular references and
BigIntvalues throw errors — they don't fail quietly. -
JSON.parse(JSON.stringify(x))is not a safe deep clone. UsestructuredClone(x)instead. -
JSON.parse(str, reviver)lets you rebuild things likeDateobjects on the way back in.
If you already know all that, you probably don't need this article — but stick around anyway, there are a couple of gotchas below that surprise even people who've been writing JavaScript for years.
First, Let's Clear Up the Terminology
"JSON" stands for JavaScript Object Notation, and it's a text format — it's already string-shaped by definition. So when developers say "convert JSON to a string," what they almost always mean is:
"I have a JavaScript object (or array, or value), and I need to represent it as JSON-formatted text."
That distinction matters, because it's not the same operation as "converting an object to a string" in the generic sense. Which brings us to a trap a lot of beginners fall into first.
The Wrong Way (and Why It Fails)
If you're new to JavaScript, converting a value "to a string" might reasonably make you reach for String(), .toString(), or a template literal. Try that on an object, though, and here's what happens:
const user = { name: "Maya", age: 29 };
String(user); // "[object Object]"
`${user}`; // "[object Object]"
user.toString(); // "[object Object]"
All three quietly return the same useless placeholder. That's because objects don't have meaningful default string representations — [object Object] is just JavaScript telling you "yep, this is an object," which you already knew.
JSON.stringify() is a different operation entirely. Instead of asking "what's the string form of this object," it serializes the object — walking through its structure and producing a text representation of the data itself:
JSON.stringify(user);
// '{"name":"Maya","age":29}'
That's the method you actually want.
The Core Method: JSON.stringify()
const user = {
name: "Maya",
age: 29,
isActive: true
};
const jsonString = JSON.stringify(user);
console.log(jsonString);
// '{"name":"Maya","age":29,"isActive":true}'
console.log(typeof jsonString);
// "string"
Your object is now plain text. It looks like the object literal you started with, but functionally it isn't — you can't do jsonString.name anymore, because there's no object there to access. If you need the data back in object form, you'll need JSON.parse(), which we'll get to.
Making Output Readable: The Indentation Argument
Compact JSON is great for sending over a network, but it's rough to read in a console log or a debug file. JSON.stringify() accepts a third argument for that:
JSON.stringify(user, null, 2);
/*
{
"name": "Maya",
"age": 29,
"isActive": true
}
*/
The 2 means "indent with 2 spaces." You could pass 4, or even a string like "\t" for tabs. I default to 2 in almost every debugging session I run — it turns a wall of text into something I can actually scan.
Controlling What Gets Included: The Replacer
The second argument — the one we skipped with null above — is called the replacer, and it's more powerful than most quick tutorials let on.
As a function, it runs against every key/value pair, so you can filter or transform data on the way out:
const account = {
username: "maya_codes",
password: "supersecret123",
email: "maya@example.com"
};
const safeJson = JSON.stringify(account, (key, value) => {
if (key === "password") return undefined;
return value;
});
console.log(safeJson);
// '{"username":"maya_codes","email":"maya@example.com"}'
This is genuinely useful for scrubbing sensitive fields — tokens, passwords, internal IDs — before you log an object or send it somewhere it shouldn't end up.
As an array, it works as a simple whitelist instead:
JSON.stringify(account, ["username", "email"]);
// '{"username":"maya_codes","email":"maya@example.com"}'
Same result, less code — but no custom logic, so pick whichever fits your situation.
Custom Serialization With toJSON()
Here's a trick that doesn't get nearly enough attention: any object can define its own toJSON() method, and JSON.stringify() will call it automatically instead of serializing the object's raw properties.
class Money {
constructor(cents) {
this.cents = cents;
}
toJSON() {
return (this.cents / 100).toFixed(2);
}
}
const price = new Money(1999);
console.log(JSON.stringify({ price }));
// '{"price":"19.99"}'
This is exactly how Date objects manage to serialize cleanly (more on that below) — Date.prototype.toJSON() is built in. If you're building your own classes and want them to serialize predictably, this is the hook to use instead of fighting with replacer functions everywhere they're used.
Where You'll Actually Use This
Storing Data in localStorage or sessionStorage
Both storage APIs only accept strings — this is the bug from the intro:
const cartItems = [{ id: 1, qty: 2 }, { id: 2, qty: 1 }];
localStorage.setItem("cart", JSON.stringify(cartItems));
// later:
const cart = JSON.parse(localStorage.getItem("cart"));
Sending Data With fetch()
Request bodies are strings too, not live objects:
fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "Keyboard", quantity: 1 })
});
Skip this step, and your server usually receives something it can't parse, no matter what the Content-Type header claims.
Logging and Debugging
Deeply nested objects often get truncated or collapsed in browser and Node consoles. Stringifying first gives you a flat snapshot you can actually read or paste into a bug report:
console.log(JSON.stringify(apiResponse, null, 2));
Comparing or Hashing Objects
JSON.stringify() is also a common (if imperfect) shortcut for comparing two objects by value, since JavaScript's === only checks reference equality:
const a = { x: 1, y: 2 };
const b = { x: 1, y: 2 };
a === b; // false
JSON.stringify(a) === JSON.stringify(b); // true
It's worth knowing this trick exists — just also worth knowing it breaks if the keys are in a different order or the objects contain any of the quirks below. For anything beyond quick scripts or tests, a proper deep-equality check (like lodash.isEqual) is more reliable.
The Gotchas (Learned the Hard Way)
Some Values Just Disappear
JSON.stringify() silently drops undefined values, functions, and Symbol keys — no error, no warning:
const obj = {
name: "test",
greet: function () { return "hi"; },
id: undefined,
tag: Symbol("x")
};
console.log(JSON.stringify(obj));
// '{"name":"test"}'
If a field you swear you set is missing from an API payload or a stored object, this is worth checking first.
NaN and Infinity Become null
JSON.stringify({ score: NaN, limit: Infinity });
// '{"score":null,"limit":null}'
JSON has no concept of these values, so JSON.stringify() falls back to null instead of throwing.
Circular References Throw
If an object references itself — directly or through a chain — JSON.stringify() doesn't skip it quietly. It throws:
const node = { name: "root" };
node.self = node;
JSON.stringify(node);
// TypeError: Converting circular structure to JSON
This tends to catch people off guard with things like DOM nodes, certain framework internals, or ORM models that reference their own parent. If you genuinely need to serialize a structure like that, you'll need a custom replacer that tracks visited references, or a library built specifically for that case.
BigInt Isn't Supported — At All
JSON.stringify({ total: 10n });
// TypeError: Do not know how to serialize a BigInt
There's no built-in workaround here. If you're working with BigInt, convert it to a Number or String before stringifying.
Date Serializes Cleanly, But Doesn't Come Back the Same Way
Thanks to toJSON(), dates convert into ISO strings automatically:
JSON.stringify({ createdAt: new Date() });
// '{"createdAt":"2026-07-30T09:15:00.000Z"}'
The catch: JSON.parse() has no idea that string used to be a Date. You get a plain string back, and if you need real Date behavior again, you have to reconstruct it yourself — which is exactly what the reviver function below is for.
Map, Set, and Class Instances Don't Serialize the Way You'd Expect
JSON.stringify(new Map([["a", 1]])); // '{}'
JSON.stringify(new Set([1, 2, 3])); // '{}'
Both come back as empty objects, because JSON.stringify() only looks at enumerable own properties, and neither Map nor Set store their data that way. If you need to serialize either, convert them first:
JSON.stringify(Array.from(myMap.entries()));
JSON.stringify(Array.from(mySet));
Key Order Isn't Always What You Typed
For most objects, JSON.stringify() preserves the order you added keys in. But if any of your keys look like array indexes ("0", "1", "2"), JavaScript sorts those numerically before everything else, regardless of insertion order. It's a small quirk, but it's caused real confusion in code that assumed key order was purely insertion-based.
The Security Gotcha Nobody Mentions
If you're doing server-side rendering and embedding data directly into an HTML <script> tag — a common pattern for hydrating client-side state — there's a real risk hiding in plain sight:
app.get("/", (req, res) => {
const data = { bio: userInput }; // e.g. "</script><script>alert(1)</script>"
res.send(`
<script>
window.__DATA__ = ${JSON.stringify(data)};
</script>
`);
});
If userInput contains </script>, the browser closes your script tag early — and anything after it gets parsed as raw HTML, including a second <script> tag an attacker controls. JSON.stringify() does not escape this for you by default.
The fix is to escape the characters that matter before embedding:
function safeStringify(data) {
return JSON.stringify(data)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
(The last two handle the Unicode line/paragraph separators, which are valid in JSON strings but invalid inside a JavaScript string literal — another one that's caused real production bugs.) If you're already using something like serialize-javascript on the server, it handles this for you — but it's worth knowing why that step exists rather than treating it as boilerplate.
The "Quick Deep Clone" Trick Has Real Limits
You'll see this pattern used constantly for cloning objects:
const copy = JSON.parse(JSON.stringify(original));
It works fine for flat, plain data — but given everything above, it quietly breaks on dates, undefined values, functions, Map/Set instances, and circular references. In modern JavaScript, there's a better option:
const copy = structuredClone(original);
structuredClone() is built into modern browsers and Node.js 17+, and it correctly handles dates, Map, Set, and circular references — no serialization round-trip required.
Going the Other Direction: JSON.parse()
You'll use JSON.parse() just as often as JSON.stringify(), so it's worth covering properly rather than as an afterthought.
The Basics
const raw = '{"name":"Maya","age":29}';
const parsed = JSON.parse(raw);
console.log(parsed.name); // "Maya"
Always Wrap It in try/catch
Any time the string comes from outside your code — an API, a file, user input — treat it as untrustworthy. Malformed JSON throws a SyntaxError, and an uncaught one will crash whatever function it's sitting in:
try {
const data = JSON.parse(userSuppliedString);
} catch (err) {
console.error("That wasn't valid JSON:", err.message);
}
Rebuilding Types With a Reviver Function
Remember how dates turn into plain strings when they come back through JSON.parse()? A reviver function fixes exactly that:
const raw = '{"createdAt":"2026-07-30T09:15:00.000Z","count":5}';
const parsed = JSON.parse(raw, (key, value) => {
if (key === "createdAt") return new Date(value);
return value;
});
console.log(parsed.createdAt instanceof Date); // true
It runs on every key/value pair, bottom-up, letting you rebuild Date objects, class instances, or anything else that got flattened into plain data during serialization.
When You Just Need a Quick, One-Off Conversion
Not every situation calls for opening an editor. Sometimes you've copied JSON from an API response, a bug report, or a teammate's Slack message and simply need to convert it into an escaped string or verify that the output looks correct before using it in code.
While you can always use JSON.stringify() in JavaScript, a browser-based tool can be quicker when you don't want to write a temporary script. For example, this JSON to String Converter lets you paste JSON and instantly see the escaped string output, which is handy for debugging, creating test data, or preparing JSON for configuration files.
JSON.stringify() vs. the Alternatives
| Method | Output | Handles nested objects? | Notes |
|---|---|---|---|
String(obj) / `${obj}`
|
"[object Object]" |
No | Never use this for data — it's not serialization |
obj.toString() |
"[object Object]" (unless overridden) |
No | Same issue, unless the class defines its own toString()
|
JSON.stringify(obj) |
Full JSON text | Yes | The correct tool for this job |
util.inspect(obj) (Node.js) |
Formatted, colorized text | Yes | Great for terminal debugging, but not valid JSON — don't send this anywhere |
Quick Reference: Best Practices
-
Always
JSON.stringify()before storing or transmitting —localStorage,fetchbodies, and WebSocket messages all expect strings, not live objects. -
Always wrap
JSON.parse()intry/catchwhen the source isn't fully trusted. -
Use the indentation argument (
JSON.stringify(data, null, 2)) for readable logs and debug output. - Use a replacer function to strip sensitive fields before logging or transmitting data.
-
Give custom classes a
toJSON()method if you want predictable, reusable serialization instead of scattering replacer logic everywhere. -
Don't rely on
JSON.parse(JSON.stringify(x))for deep cloning — reach forstructuredClone()instead. -
Escape user-controlled data before embedding JSON in a
<script>tag —JSON.stringify()alone isn't safe for that context. -
Remember what silently disappears:
undefined, functions, and symbols vanish without warning;NaNandInfinitybecomenull;MapandSetserialize as{}unless converted first.
Wrapping Up
JSON.stringify() and JSON.parse() look like two-line utility functions, and most of the time, that's exactly how simple they are to use. The edge cases in this article are the ones that don't show up until you hit them in production — a Date that silently loses its type, a circular reference that crashes a request handler, a password that leaked into a log file because nobody added a replacer.
None of these are hard to avoid once you know they exist. That's really the whole point of writing them down: so the next twenty minutes you'd have spent debugging one of them, you spend building something instead.
If you want to see these behaviors for yourself, the fastest way is to open your browser console right now and run a few of the examples above — especially the circular reference one. Watching it throw is a lot more memorable than reading about it.
Top comments (0)