DEV Community

Cover image for JSON to String Conversion: The Edge Cases That Actually Bite in Production
99Tools
99Tools

Posted on AI-assisted

JSON to String Conversion: The Edge Cases That Actually Bite in Production

Every JS developer writes JSON.stringify(data) a hundred times before they ever stop to think about what it's actually doing under the hood. It's one line, it "just works," and most of the time that's a completely fine way to live.

Then one day a field goes missing from a payload for no visible reason. Or a service throws TypeError: Converting circular structure to JSON in the middle of a request handler that was fine yesterday. Or a script tag gets closed early by user-generated content and half your page renders as plain text. None of these are exotic — they're the kind of thing that shows up in a Slack thread titled "why is prod broken" at 6pm on a Friday.

This is a walkthrough of the actual edge cases in converting JSON to strings (and back), why they happen, and how to handle them on purpose instead of discovering them in an incident channel.

The baseline

const user = { id: 1, name: "Ada", active: true };
JSON.stringify(user);
// '{"id":1,"name":"Ada","active":true}'
Enter fullscreen mode Exit fullscreen mode

This works cleanly because every value maps directly onto a JSON type: number, string, boolean. The interesting problems start the moment your data contains anything JSON has no native representation for — which, in a real codebase, is almost immediately.

Problem 1: Values that quietly disappear

JSON has no concept of undefined, functions, or Symbol. JSON.stringify doesn't throw when it meets them — it just drops them, and it does so differently depending on whether they're sitting in an object or an array. This asymmetry trips people up constantly.

const obj = {
  a: 1,
  b: undefined,
  c: () => {},
  d: Symbol("x"),
};
JSON.stringify(obj);
// '{"a":1}'   -- b, c, d are all gone

const arr = [1, undefined, () => {}, Symbol("x")];
JSON.stringify(arr);
// '[1,null,null,null]'  -- same values, but now they become null
Enter fullscreen mode Exit fullscreen mode

I've debugged this exact thing before: code worked fine against an object shape, then someone refactored the same data into an array of records and every "missing" field silently turned into null instead of vanishing. If a field genuinely matters, don't let undefined be the thing that represents its absence — normalize it to null or a real value before you serialize.

Problem 2: Values that silently change

Some types don't disappear — they get converted, and the conversion isn't always the one you'd expect.

JSON.stringify({ n: NaN, i: Infinity, ni: -Infinity });
// '{"n":null,"i":null,"ni":null}'

JSON.stringify({ date: new Date("2026-01-01") });
// '{"date":"2026-01-01T00:00:00.000Z"}'
Enter fullscreen mode Exit fullscreen mode

NaN and Infinity aren't valid JSON, so JSON.stringify folds all three of those distinct values into null — with nothing to tell you which one it originally was. If a downstream consumer needs to distinguish "not a number" from "no value," you need to encode that intent yourself before serializing.

Dates work because Date.prototype.toJSON() exists and internally calls toISOString(). This matters more than it looks: JSON.stringify always checks for a toJSON method on a value before falling back to default handling — and that's the exact mechanism you get to hook into for your own classes (more on that shortly).

Map and Set don't have a toJSON, so they serialize as empty objects, which is a nasty silent failure:

JSON.stringify(new Map([["a", 1]]));
// '{}'
Enter fullscreen mode Exit fullscreen mode

Convert them explicitly before handing them to stringify:

JSON.stringify(Object.fromEntries(myMap));
JSON.stringify([...mySet]);
Enter fullscreen mode Exit fullscreen mode

BigInt doesn't even get the courtesy of a silent conversion — it throws outright:

JSON.stringify({ big: 10n });
// Uncaught TypeError: Do not know how to serialize a BigInt
Enter fullscreen mode Exit fullscreen mode

The usual fix is to stringify it explicitly and give the consumer a hint that it needs to be parsed back as a big integer:

JSON.stringify({ big: 10n.toString() });
Enter fullscreen mode Exit fullscreen mode

Problem 3: Circular references

If an object references itself, directly or through a chain, JSON.stringify throws:

const node = { name: "root" };
node.self = node;

JSON.stringify(node);
// Uncaught TypeError: Converting circular structure to JSON
Enter fullscreen mode Exit fullscreen mode

This shows up more than people expect — DOM nodes, ORM entities with back-references (parent.children[0].parent === parent), event emitters attached to a data object. The fix isn't to redesign your data model around avoiding cycles; sometimes a cycle is the correct shape for your program. The fix is to strip it at serialization time with a replacer that tracks what it's already seen:

function getCircularReplacer() {
  const seen = new WeakSet();
  return (key, value) => {
    if (typeof value === "object" && value !== null) {
      if (seen.has(value)) return "[Circular]";
      seen.add(value);
    }
    return value;
  };
}

JSON.stringify(node, getCircularReplacer());
// '{"name":"root","self":"[Circular]"}'
Enter fullscreen mode Exit fullscreen mode

Controlling serialization on purpose

Rather than reacting to these one at a time, it's usually better to decide up front exactly how a value should serialize.

toJSON()

Any object can define a toJSON() method, and JSON.stringify will use its return value instead of the object itself — everywhere, automatically, without every call site needing to know about it:

class Money {
  constructor(cents, currency) {
    this.cents = cents;
    this.currency = currency;
  }
  toJSON() {
    return `${(this.cents / 100).toFixed(2)} ${this.currency}`;
  }
}

JSON.stringify({ price: new Money(1999, "USD") });
// '{"price":"19.99 USD"}'
Enter fullscreen mode Exit fullscreen mode

This is the cleanest way to make a custom class work correctly with JSON.stringify no matter where in the codebase it gets serialized.

The replacer function

For one-off control — redacting fields, transforming values in a specific context — pass a replacer function as the second argument. It's called for every key/value pair, starting with a synthetic top-level wrapper:

const payload = { username: "ada", password: "hunter2", email: "ada@example.com" };

JSON.stringify(payload, (key, value) => {
  if (key === "password") return undefined; // drop it
  return value;
});
// '{"username":"ada","email":"ada@example.com"}'
Enter fullscreen mode Exit fullscreen mode

You can also pass a plain array of allowed keys instead of a function, if all you need is a simple allow-list:

JSON.stringify(payload, ["username", "email"]);
// '{"username":"ada","email":"ada@example.com"}'
Enter fullscreen mode Exit fullscreen mode

The reviver function (parsing back)

The inverse problem shows up on JSON.parse: dates come back as plain strings, not Date objects, because JSON has no date type. A reviver lets you reconstruct richer types as parsing happens, rather than post-processing the whole tree afterward:

const json = '{"name":"Ada","createdAt":"2026-01-01T00:00:00.000Z"}';

const obj = JSON.parse(json, (key, value) => {
  if (key === "createdAt") return new Date(value);
  return value;
});

obj.createdAt instanceof Date; // true
Enter fullscreen mode Exit fullscreen mode

The reviver runs bottom-up on every node in the tree, so nested dates several levels deep get converted too — which is exactly why it's worth using instead of a manual walk after parsing.

Formatting: compact vs. readable

The third argument to JSON.stringify controls indentation:

JSON.stringify({ a: 1, b: { c: 2 } }, null, 2);
Enter fullscreen mode Exit fullscreen mode
{
  "a": 1,
  "b": {
    "c": 2
  }
}
Enter fullscreen mode Exit fullscreen mode

Use compact output (no third argument) for anything going over the network or into storage — indentation is pure overhead there. Use 2 or "\t" for logs, config files, and debug output that a human is actually going to read. It's an easy thing to leave on by accident in an API response and quietly bloat every payload by 20-30% for no reason at all.

Embedding a JSON string inside something else

This is where a lot of the subtle bugs actually live, because at this point you're not doing "JSON to string" once — you're doing it inside another format, and that outer format's own escaping rules now apply too.

Inside an HTML <script> tag

A classic mistake when server-rendering initial state into a page:

// DON'T do this directly:
`<script>window.__DATA__ = ${JSON.stringify(data)}</script>`
Enter fullscreen mode Exit fullscreen mode

If data contains the literal substring </script>, the browser's HTML parser closes your script tag early — before the JS parser ever gets a chance to look at the content — and the rest gets rendered as raw visible text. This is both a rendering bug and a potential XSS vector the moment any part of data comes from user input.

The fix is to escape <, >, and & inside the JSON string before embedding it:

function escapeForScriptTag(json) {
  return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
}

const safe = escapeForScriptTag(JSON.stringify(data));
`<script>window.__DATA__ = ${safe}</script>`
Enter fullscreen mode Exit fullscreen mode

\u003c is a valid escape sequence in both JSON and JS, so this doesn't change the parsed value at all — it just stops the HTML parser from misreading it before your JS ever runs.

JSON inside JSON

Sometimes a payload needs to carry an entire JSON document as a string field — this is a common pattern in message queues and audit logs. You stringify it twice:

const inner = { type: "click", x: 10, y: 20 };
const envelope = {
  eventId: "evt_123",
  payload: JSON.stringify(inner), // stringified once, embedded as a string
};

JSON.stringify(envelope);
// '{"eventId":"evt_123","payload":"{\\"type\\":\\"click\\",\\"x\\":10,\\"y\\":20}"}'
Enter fullscreen mode Exit fullscreen mode

Notice the escaped quotes (\\") — that's JSON.stringify correctly escaping the inner JSON string so it remains a valid string value inside the outer document. Trying to hand-build this with string concatenation is exactly how you end up with malformed JSON that only fails once real data hits it.

If you're doing this kind of escaping as a one-off — pasting a payload into a .env file, a Kubernetes secret, or a raw SQL INSERT while debugging something manually — it's not always worth writing a script for a single conversion. A browser-based JSON to string converter that just escapes quotes, backslashes, and newlines and hands you the quoted output can save the two minutes it'd take to spin up a snippet, since it runs client-side and doesn't require you to trust it with anything sensitive server-side. It's the same transformation as the code above, just without writing the code when you only need it once.

Key order isn't guaranteed the way you might assume

JSON.stringify preserves insertion order for string keys, with one twist: integer-like keys are always sorted numerically first, ahead of everything else, regardless of insertion order.

JSON.stringify({ b: 1, a: 2, 2: "x", 1: "y" });
// '{"1":"y","2":"x","b":1,"a":2}'
Enter fullscreen mode Exit fullscreen mode

This matters if you're using a stringified object as a cache key, a hash input, or for diffing two objects — two objects with identical data but different key insertion order will not produce the same string, and any hash comparison between them will fail. If you need a stable, comparable string, sort the keys yourself before serializing:

function stableStringify(obj) {
  if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
  if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(",")}]`;
  const keys = Object.keys(obj).sort();
  const pairs = keys.map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`);
  return `{${pairs.join(",")}}`;
}
Enter fullscreen mode Exit fullscreen mode

For anything beyond a quick script, reach for a well-tested library like fast-json-stable-stringify rather than maintaining this by hand — edge cases around NaN, undefined, and nested dates pile up faster than you'd expect.

Cross-language interop: the differences bite in production

If your JSON is produced by one language and consumed by another, some of this is not consistent across ecosystems — and it's rarely caught until two services that "both just use JSON" start talking to each other.

  • Python's json.dumps allows NaN and Infinity by default and emits the literals NaN, Infinity, -Infinity — none of which are valid JSON per spec. A JS JSON.parse on the receiving end throws a SyntaxError on that payload. Pass allow_nan=False if the consumer isn't Python, or sanitize those values before serializing.
  • Java (Jackson) and JS both fall back to some default behavior for types the format doesn't natively support, but the specifics — how enums, dates, and "null vs. absent field" are handled — differ enough that it's worth writing an actual contract test between services rather than assuming symmetry.
  • Key order is not part of the JSON spec at all. Don't design a system that depends on it surviving a network boundary, even if it happens to hold up in your current stack.

A quick checklist

Before shipping a JSON.stringify call, it's worth running through:

  • Does the data contain undefined, functions, Symbols, Map/Set, or BigInt that need explicit handling?
  • Could the object graph be circular?
  • Does this need a stable key order for hashing or diffing?
  • Is the result going into HTML, another JSON string, a URL, or a shell command — and does it need escaping for that context, not just JSON's own rules?
  • Is pretty-printing actually needed here, or is it wasted bytes on the wire?

None of these are exotic. They're the ones that surface in code review, in production incidents, and in "why does this only break in prod" debugging sessions. Handling them on purpose — with toJSON(), a replacer, or a small utility function — is a lot cheaper than finding out about them from a stack trace.

Further reading

  • MDN: JSON.stringify() — the full parameter reference, including replacer/space behavior.
  • MDN: JSON.parse() — reviver function details and examples.
  • RFC 8259 — the actual JSON specification, useful for settling arguments about what's "valid" JSON.
  • json.org — the original, much shorter grammar reference for JSON's syntax.
  • Python json module docs — worth skimming if you're integrating a Python service with a JS one; the allow_nan and default parameters are the ones to know.

Top comments (0)