DEV Community

Cover image for JSON to String in JavaScript: The Complete Guide (And the Gotchas That Bite Even Experienced Devs)
99Tools
99Tools

Posted on

JSON to String in JavaScript: The Complete Guide (And the Gotchas That Bite Even Experienced Devs)

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);
Enter fullscreen mode Exit fullscreen mode

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. NaN and Infinity become null.
  • Circular references and BigInt values throw errors — they don't fail quietly.
  • JSON.parse(JSON.stringify(x)) is not a safe deep clone. Use structuredClone(x) instead.
  • JSON.parse(str, reviver) lets you rebuild things like Date objects 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]"
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
}
*/
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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 })
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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]));  // '{}'
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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>
  `);
});
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode

(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));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 transmittinglocalStorage, fetch bodies, and WebSocket messages all expect strings, not live objects.
  • Always wrap JSON.parse() in try/catch when 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 for structuredClone() instead.
  • Escape user-controlled data before embedding JSON in a <script> tagJSON.stringify() alone isn't safe for that context.
  • Remember what silently disappears: undefined, functions, and symbols vanish without warning; NaN and Infinity become null; Map and Set serialize 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)