NodeJSServing JSON Responses

Serving JSON Responses

JSON is the lingua franca of web APIs — the format your server uses to hand structured data to browsers, mobile apps, and other services. Serving it well is more than JSON.stringify: you need the right Content-Type, correct status codes, safe serialization, and a consistent shape so clients can rely on your responses. This page covers building a clean JSON API on raw http.

The basics

JS
import { createServer } from 'node:http'

createServer((req, res) => {
  const data = { id: 1, name: 'Ada', active: true }

  res.writeHead(200, { 'Content-Type': 'application/json' })
  res.end(JSON.stringify(data))     // serialize before sending
}).listen(3000)
You must set `Content-Type: application/json` — and stringify yourself
Raw `http` does **not** serialize for you. `res.end(obj)` with a plain object throws (the body must be a string/Buffer). Always `JSON.stringify` the value, and always send `Content-Type: application/json` — without it, clients (and `fetch().json()`) may refuse to parse the body or mis-handle it. This is the JSON equivalent of the [Content-Type rule](/nodejs/http-headers).
A reusable JSON helper

Repeating writeHead + stringify everywhere invites mistakes. Wrap it once:

JS
function sendJson(res, statusCode, payload) {
  const body = JSON.stringify(payload)
  res.writeHead(statusCode, {
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),   // bytes, not characters
  })
  res.end(body)
}

// Usage:
sendJson(res, 200, { users: [] })
sendJson(res, 404, { error: 'Not Found' })
sendJson(res, 201, { id: 42 })
Use `Buffer.byteLength` for Content-Length, not `body.length`
`Content-Length` is the size in **bytes**, but `string.length` counts UTF-16 characters — they differ whenever the JSON contains non-ASCII text (names, emoji, accents). Sending the wrong length can truncate or corrupt the response. `Buffer.byteLength(body)` gives the correct byte count, as covered in [Buffers](/nodejs/buffers).
Reading a JSON request body

Most APIs also accept JSON (POST/PUT). You must read the streamed body and parse it — guarding both size and parse errors:

JS
async function readJson(req, limit = 1e6) {
  let size = 0
  const chunks = []
  for await (const chunk of req) {
    size += chunk.length
    if (size > limit) throw Object.assign(new Error('Payload too large'), { status: 413 })
    chunks.push(chunk)
  }
  const raw = Buffer.concat(chunks).toString('utf8')
  if (!raw) return {}
  try {
    return JSON.parse(raw)
  } catch {
    throw Object.assign(new Error('Invalid JSON'), { status: 400 })
  }
}
Wrap `JSON.parse` in try/catch — malformed input must not crash the server
A client can send `{invalid json`, and `JSON.parse` throws a `SyntaxError`. If uncaught in an async handler, it can crash the process or leave the request hanging. Catch it and respond `400 Bad Request`. Also enforce a size limit (respond `413`) so a giant body can't exhaust memory — the same discipline as [reading any request body](/nodejs/request-response).
A small JSON API endpoint

JS
import { createServer } from 'node:http'

const users = new Map([[1, { id: 1, name: 'Ada' }]])

createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)

  try {
    if (req.method === 'GET' && url.pathname === '/users') {
      return sendJson(res, 200, [...users.values()])
    }
    if (req.method === 'POST' && url.pathname === '/users') {
      const body = await readJson(req)
      if (!body.name) return sendJson(res, 422, { error: 'name is required' })
      const id = users.size + 1
      users.set(id, { id, name: body.name })
      return sendJson(res, 201, { id, name: body.name })
    }
    sendJson(res, 404, { error: 'Not Found' })
  } catch (err) {
    sendJson(res, err.status || 500, { error: err.message })
  }
}).listen(3000)
Consistent response shape

Clients are far easier to write when every response follows a predictable structure. Pick a convention and stick to it — for example, a discriminator plus payload:

JS
// Success:
{ "data": { "id": 1, "name": "Ada" } }

// Error:
{ "error": { "code": "VALIDATION", "message": "name is required" } }

// Collection with pagination metadata:
{ "data": [/* ... */], "meta": { "page": 1, "total": 57 } }
Pair the JSON error shape with the right status code
A consistent error envelope is helpful, but it does **not** replace the HTTP status code — return `422`/`404`/`500` *and* the error JSON. Clients branch on the status code first (`if (!res.ok)`); the body explains the detail. Returning `200` with an error object is the [cardinal sin](/nodejs/http-status-codes) from the status-codes page.
Serialization gotchas

Value

JSON.stringify result

undefined (in object)

Key is omitted

undefined (top-level)

Returns undefined — not valid JSON!

NaN / Infinity

Becomes null

Date

ISO string (via toJSON)

BigInt

Throws a TypeError

Map / Set

{} — not serialized usefully

Circular reference

Throws a TypeError

BigInt and circular references throw — handle them before stringifying
`JSON.stringify` throws on `BigInt` values and on objects with circular references — an uncaught throw in a handler can crash the response. Convert `BigInt` to a string/number first, and avoid (or break) cycles. Watch `Map`/`Set` too: they serialize to `{}`, silently losing data — convert them to arrays/objects explicitly.
Next
Accept data from HTML forms — urlencoded and multipart: [Handling Form Data](/nodejs/handling-forms).