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:
- A backend gets its body as a string, say from a template or from a database column that stores serialized JSON, and passes that string to
JSON.stringifyagain while wrapping it in a response envelope. It should have parsed it first, or kept the parsed object all the way through. - A webhook relay or logging middleware captures the request body as a string (to log it, or to verify a signature) and then serializes that string into a JSON field of its own payload instead of forwarding the original bytes.
- A queue message body is JSON, and the queue client stringifies the whole message (headers plus body) as one envelope. The body was already a JSON string when it went in.
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 objectIf 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 objectThe 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.
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:
- Check
Content-Typebefore touching a body. A proxy or gateway that forwards requests shouldn't assume every body needs re-serializing. If the incomingContent-Typeis alreadyapplication/json, pass the raw bytes through. Parsing and re-stringifying is extra work, and it's one more place to double-encode by mistake. - Never call
JSON.stringifyon a value that might already be serialized. JSON from a text or varchar column, a queue message body, a webhook payload captured as raw text: parse each of these into an object as early as you can. After that, every layer works with the object, and serialization happens once, at the end, right before the bytes go out. - In gateway and proxy code (API Gateway Lambda integrations, reverse proxies that rewrite bodies), treat the body as opaque bytes to forward unless you have a specific reason to parse it. Each parse-and-restringify round trip is somewhere this bug can creep in, even when the code looks fine.
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.