Enforce JSON Request Byte Limits with TextEncoder

A profile update fits a client-side length check but is rejected by an API’s body-size limit. The check counted JavaScript string units, while the endpoint limits UTF-8 bytes. Accented text, emoji, and JSON escaping can make those measurements differ.

For a small JSON endpoint, serialize the complete value once, encode that text, and compare the resulting byte count with the endpoint’s documented limit. Then send those same bytes. The examples use standard JavaScript APIs available in modern browsers and Node.js.

Count UTF-8 Bytes Instead of Code Units

JavaScript’s length counts UTF-16 code units. It does not measure the UTF-8 representation used by a JSON request:

1
2
3
4
5
6
7
8
const encoder = new TextEncoder();

console.log("cafe".length);                       // 4
console.log(encoder.encode("cafe").byteLength);   // 4
console.log("caf\u00e9".length);                  // 4
console.log(encoder.encode("caf\u00e9").byteLength); // 5
console.log("\u{1F3AE}".length);                  // 2
console.log(encoder.encode("\u{1F3AE}").byteLength); // 4

The Unicode escapes keep the source easy to copy; JavaScript evaluates them into actual characters. The String length documentation explains the code-unit count. The WHATWG Encoding Standard specifies that TextEncoder produces UTF-8 in a Uint8Array.

Counting code points with [...text].length also does not give a byte count. Use the encoded representation when the limit is expressed in bytes.

Serialize the Whole JSON Body Once

Measuring only a nickname misses property names, quotes, braces, and escaped control characters. Save this helper as json-body.mjs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export function encodeJsonBody(value, maxBytes) {
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
    throw new RangeError("maxBytes must be a nonnegative safe integer");
  }

  const text = JSON.stringify(value);
  if (typeof text !== "string") {
    throw new TypeError("value must serialize to JSON text");
  }

  const body = new TextEncoder().encode(text);
  if (body.byteLength > maxBytes) {
    throw new RangeError(
      `JSON body is ${body.byteLength} bytes; limit is ${maxBytes}`,
    );
  }
  return body;
}

An exact match is accepted; one byte over the limit is rejected. The returned array contains the entire compact JSON document, including its syntax. No whitespace formatting or replacement rules are added.

Serialize only once because getters and toJSON() methods can run during serialization. Repeating JSON.stringify(value) for the request could produce different text from the text you measured. Reusing the encoded body avoids that mismatch.

This helper allocates both the JSON text and its encoded bytes before checking the limit. It suits bounded application data such as a profile update. It does not bound memory consumption while serializing a huge object.

Send the Validated Bytes with Fetch

Import the helper into a module and pass its returned array directly to fetch:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { encodeJsonBody } from "./json-body.mjs";

export async function postJson(url, value, maxBytes) {
  const body = encodeJsonBody(value, maxBytes);
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response;
}

Call this with your application’s endpoint, payload, and documented byte limit; catch the returned promise’s rejection in the calling UI. Validation finishes before fetch is invoked. A failed size check therefore prevents this function from starting a request.

The Fetch Standard’s body extraction rules accept a buffer source and copy its bytes. Set the JSON content type explicitly because a typed array does not identify its contents as JSON. The caller can parse the returned response according to the endpoint’s contract, including handling an empty response body.

The byte count covers this uncompressed JSON body. It excludes headers and transport framing. Confirm whether the service’s limit applies to these body bytes, a compressed representation, or a decoded field. The server still needs to enforce its own limits. An HTTP rejection also needs the response.ok check shown above; the Fetch guide describes that behavior.

Keep Serialization Failures Visible

The helper preserves the normal behavior of JSON.stringify:

InputResult
A serializable object, array, or primitiveEncode the resulting JSON text
Top-level undefined, function, or symbolReject because no JSON text was produced
A circular reference or an unhandled BigIntPropagate the serialization error
An object property whose value is undefinedOmit the property under normal JSON rules
A throwing getter or toJSON() methodPropagate that error

These serialization rules are documented in JSON.stringify . The helper checks size, not whether the resulting document has the fields your endpoint requires. Validate that schema separately.

Do not replace a serialization error with an empty object merely to make the request fit. Report it at the call site so the application can correct its payload. Likewise, avoid slicing the encoded byte array to the maximum length: the cut can break a UTF-8 sequence or leave incomplete JSON. Reduce optional fields or split a batch using a contract the server supports, then serialize and measure again.

Test Exact Byte Boundaries

Save this test beside json-body.mjs as json-body.test.mjs, then run node json-body.test.mjs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import assert from "node:assert/strict";
import { encodeJsonBody } from "./json-body.mjs";

const payload = { name: "caf\u00e9 \u{1F3AE}" };
const body = encodeJsonBody(payload, 21);
assert.equal(body.byteLength, 21);
assert.deepEqual(JSON.parse(new TextDecoder().decode(body)), payload);
assert.throws(() => encodeJsonBody(payload, 20), RangeError);
assert.equal(encodeJsonBody(payload, 22).byteLength, 21);

// Quotes and the escaped newline count toward the serialized body.
assert.equal(encodeJsonBody("\n", 4).byteLength, 4);
assert.throws(() => encodeJsonBody("\n", 3), RangeError);

assert.equal(encodeJsonBody(null, 4).byteLength, 4);
assert.throws(() => encodeJsonBody(null, 0), RangeError);
assert.throws(() => encodeJsonBody({}, -1), RangeError);
assert.throws(() => encodeJsonBody({}, 1.5), RangeError);
assert.throws(() => encodeJsonBody(undefined, 100), TypeError);
assert.throws(() => encodeJsonBody({ id: 1n }, 100), TypeError);

const circular = {};
circular.self = circular;
assert.throws(() => encodeJsonBody(circular, 100), TypeError);

console.log("JSON body byte-limit checks passed.");

The first case includes both an accented letter and an emoji, and verifies that decoding the measured body preserves the payload. The newline case proves that JSON escaping is included in the budget.

Also test the request wrapper with a mocked fetch: an oversized payload must make zero calls, while an accepted payload must send the validated bytes with the JSON content type. Finally, test the actual endpoint at its documented boundary to confirm that the client and server measure the same representation.