Fixing Double-Encoded JSON Strings (JSON.stringify Twice)

By · Published

Double encoding happens when a value goes through JSON.stringify (or your language's equivalent) twice before anything reads it back. The output still parses as valid JSON. The problem is that one of its values is a string containing an escaped JSON document, where you expected an object or an array.

// The data you wanted:
{ "id": 1, "name": "Ada" }

// What you actually got, after one extra stringify pass:
"{\"id\":1,\"name\":\"Ada\"}"

// And if a proxy or logging layer stringifies the whole envelope too:
{ "payload": "{\"id\":1,\"name\":\"Ada\"}" }

How it happens

Nobody does this on purpose. It happens at a boundary: one side already holds a JSON string, the other side doesn't know that, and it stringifies again. These are the usual culprits:

How to recognize it

Look for a string value that is unusually long, starts with { or [, and is full of \" sequences where you'd expect plain ". Ordinary text almost never has escaped quotes at that density. A field like "payload": "{\"id\":1}" is JSON, encoded as a string, sitting inside more JSON.

Fix: parse the envelope, then parse the field

You need one extra JSON.parse, applied to the field that was double-encoded. Leave the rest of the document alone:

const envelope = JSON.parse(rawResponse)   // outer parse — gets you { payload: "..." }
const payload = JSON.parse(envelope.payload) // inner parse — gets you the real object

If the whole response body is the JSON string (the double encoding happened at the top level), parse the same variable twice in a row:

const once = JSON.parse(rawResponse)        // "{"id":1,"name":"Ada"}" — still a string
const twice = JSON.parse(once)              // { id: 1, name: "Ada" } — the actual object

The trap: parsing something that isn't double-encoded

Calling JSON.parse a second time on something that's already an object (not a string) fails right away, because JSON.parse wants a string. That one is loud and easy to catch.

The opposite case is quieter. A plain string can be valid JSON by itself without being meant for another parse: a numeric-looking ID, for example. JSON.parse("123") succeeds and hands back the number 123, with no error, so a field you treated as a string can change type if you parse it one time too many. Check the value's runtime type before deciding it needs another pass. Don't parse in a loop until it stops looking like JSON.

Where you'll run into it

Two setups produce this all the time, and it comes from how they work, not from carelessness.

Advertisement

AWS API Gateway's Lambda proxy integration hands your handler the request body as event.body, and that's always a string, even when the request's Content-Type was application/json. If a handler wraps event.body straight into another JSON object without calling JSON.parse(event.body) first, every caller gets a double-encoded field.

The other is a database that keeps JSON in a text or varchar column instead of a native JSON type. The row comes back with a string that is already JSON, and an ORM or serializer that doesn't know this will stringify it again when it builds the API response.

Detecting it before it reaches a consumer

If you're writing a normalization layer instead of fixing one call site, a simple check finds most cases without a schema. For each string value, trim it, see whether the first character is { or [, and only then try to parse it. That skips ordinary text like names and URLs and still catches anything shaped like a JSON document.

It can be wrong. A string that starts with { as plain text (rare, but it happens) gets flagged. Use it as a debugging aid to find where the double encoding comes from. Don't leave it running in production as a permanent fix while the source stays broken.

Reproducing it in five lines

It's easy to trigger on purpose, and once you've seen the output you'll recognize it. Here is a small Node server that makes the mistake:

// server.js — deliberately buggy: body is already a JSON string, stringified again
const http = require('http')
http.createServer((req, res) => {
  const alreadyJsonString = '{"id":1,"name":"Ada"}'   // e.g. a DB text column
  res.setHeader('Content-Type', 'application/json')
  res.end(JSON.stringify({ payload: alreadyJsonString }))  // bug: stringify() wraps a string that's already JSON
}).listen(3000)
$ curl -s http://localhost:3000 | head -c 200
{"payload":"{\"id\":1,\"name\":\"Ada\"}"}

The value of payload is a string with escaped quotes, not a nested object. That is the shape from the top of this page, and it comes from passing a value that was already a JSON string into JSON.stringify instead of parsing it first.

Preventing it in APIs and middleware

Inside one handler, a targeted JSON.parse fixes it. To stop it coming back across many endpoints, change how the pipeline treats bodies:

Fix it where it's produced

Unescaping after the fact handles the symptom on every response, forever. If you own the side that produces the data, stop calling JSON.stringify on a value that is already a string. Carry the parsed object through the pipeline and let the outermost layer serialize it once.

When you see double encoding, some layer is treating a JSON string as opaque text where it shouldn't. Deserialize it as early as you can, so the later layers never have to guess what they're holding.

Advertisement

Try the JSON String Unescape / Escape →

Related tools & guides

Part of JSON Tools: Format, Validate, Diff, Convert, and Generate Types