Envelope vs Top-Level Arrays in REST Responses
This decision guide belongs to Response Envelopes & Metadata in the Query Patterns & Data Shaping Strategies reference. The choice looks cosmetic — one level of nesting — and is in fact the single hardest response decision to reverse, because the top-level type is what every client’s deserialiser is bound to.
The decision trigger
| Symptom | What it means |
|---|---|
| Pagination is needed and there is nowhere to put a cursor | bare array has no extension point |
| A total count has to travel in a custom header | metadata pushed out of the body |
| Clients cannot tell a truncated page from a complete list | no has_more signal available |
| Adding a warnings field would break every consumer | top-level type change |
| Generated types differ from every other endpoint | array response vs object responses |
| A filter was ignored and nobody noticed | no place to echo applied filters |
Spec snippet: both shapes, side by side
// Bare array — nothing can be added without changing the top-level type
[
{ "id": "inv_1", "total_minor": 4200, "currency": "GBP" },
{ "id": "inv_2", "total_minor": 990, "currency": "GBP" }
]
// Envelope — every future addition is a new key, not a new type
{
"data": [
{ "id": "inv_1", "total_minor": 4200, "currency": "GBP" },
{ "id": "inv_2", "total_minor": 990, "currency": "GBP" }
],
"meta": { "has_more": true, "next_cursor": "eyJpZCI6Imludl8yIn0" }
}
The security argument, honestly stated
The historical case against top-level arrays was JSON hijacking: in older browsers, a script tag could load a cookie-authenticated JSON array cross-origin and capture its contents by overriding Array’s constructor or setter behaviour. That specific attack no longer works in current browsers, and modern APIs authenticated with bearer tokens rather than ambient cookies were never exposed to it anyway.
So do not argue for envelopes on security grounds; argue for them on extensibility, and note that the risk profile depends on your auth model:
| Condition | Hijacking-style exposure |
|---|---|
| Bearer token auth, no cookies | none — the cross-origin request carries no credentials |
| Cookie auth, modern browsers | closed by browser changes |
| Cookie auth, legacy browser targets | residual — do not return bare arrays |
SameSite=Lax/Strict cookies |
mitigated at the cookie layer |
Step-by-step: migrating an existing array endpoint
Changing the shape in place is a breaking change under any reasonable classification — see Detecting Breaking Changes with oasdiff — so migrate it like one.
Step 1 — Serve both, negotiated
// invoices-route.ts — content negotiation between the two representations
import type { Request, Response } from "express";
const ENVELOPE_TYPE = "application/vnd.example.invoices+json;v=2";
export async function listInvoices(req: Request, res: Response) {
const page = await queryInvoices(req.query);
const wantsEnvelope =
req.accepts(ENVELOPE_TYPE) === ENVELOPE_TYPE || req.query.envelope === "true";
if (wantsEnvelope) {
res.type(ENVELOPE_TYPE).json({
data: page.rows,
meta: { has_more: page.nextCursor !== null, next_cursor: page.nextCursor },
});
return;
}
// Legacy representation: bare array, with retirement signalled in-band.
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "Tue, 31 Mar 2026 23:59:59 GMT");
res.setHeader("Link", `<${req.path}>; rel="successor-version"; type="${ENVELOPE_TYPE}"`);
res.type("application/json").json(page.rows);
}
Media-type negotiation keeps one URL, which matters when the URL is embedded in customer configuration. The mechanics and trade-offs are covered in Media-Type Versioning with Content Negotiation.
Step 2 — Measure who is still on the array
-- Which API keys still receive the legacy representation?
SELECT api_key_id,
count(*) AS legacy_responses,
max(occurred_at) AS last_seen
FROM api_access_log
WHERE route = '/invoices'
AND response_content_type = 'application/json' -- not the envelope media type
AND occurred_at > now() - interval '30 days'
GROUP BY api_key_id
ORDER BY legacy_responses DESC;
Retirement decisions made without this query are guesses. With it, you can contact the six remaining integrations by name instead of announcing to everyone and hoping.
Step 3 — Retire on the announced date
After the date, a request that explicitly asks for the retired representation should get 406 Not Acceptable with a problem body naming the replacement — not a silent switch to the envelope, which would break the client just as badly while hiding the cause.
Standard compliance
| Concern | Standard | Clause | Note |
|---|---|---|---|
| Top-level array legality | RFC 8259 | §2 | Any JSON value may be top-level |
| Content negotiation | RFC 9110 | §12 | Accept drives representation choice |
406 Not Acceptable |
RFC 9110 | §15.5.7 | No acceptable representation available |
| Deprecation signalling | RFC 8594 | §2, §3 | Deprecation and Sunset headers |
| Successor links | RFC 8288 | §3 | rel="successor-version" |
For an endpoint that is already live, the retirement path is the same one used for removing any representation:
Performance and streaming
The overhead argument against envelopes does not survive measurement: the wrapper adds tens of bytes to responses measured in kilobytes, and compresses to almost nothing. Streaming is the one real consideration — a client parsing incrementally wants items first — and it is solved by serialisation order:
// stream-envelope.ts — emit data first so consumers can start parsing immediately
export async function streamEnvelope(res: Response, rows: AsyncIterable<Invoice>) {
res.type("application/json");
res.write('{"data":[');
let first = true;
let count = 0;
for await (const row of rows) {
if (!first) res.write(",");
res.write(JSON.stringify(row));
first = false;
count++;
}
// Metadata that depends on the whole stream goes last, by necessity.
res.write(`],"meta":{"has_more":false,"returned":${count}}}`);
res.end();
}
SDK / codegen downstream effect
- export type ListInvoicesResponse = Invoice[];
- const invoices = await api.listInvoices(); // Invoice[]
+ export interface ListInvoicesResponse {
+ data: Invoice[];
+ meta: PageMeta;
+ }
+ const { data: invoices, meta } = await api.listInvoices();
The migration cost for consumers is exactly one destructuring change per call site — small, but multiplied across every integration, which is why doing it before the first release is worth so much more than doing it afterwards.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Changing an array response to an envelope in place | Negotiate representations and retire the old one on a date |
| Justifying envelopes purely on security grounds | Argue extensibility; the hijacking risk is largely historical |
Serving the envelope silently to legacy Accept headers |
Honour the request; 406 after retirement |
| Retiring without measuring adoption | Instrument per-client usage before setting a date |
Emitting metadata before data when streaming |
Write data first so clients can parse incrementally |
| Enveloping single-resource responses too | Reserve the envelope for collections |
FAQ
Is returning a top-level JSON array a security risk?
It was, in a specific and now largely closed way. Before browsers changed the behaviour, an attacker’s page could include a cookie-authenticated JSON array endpoint via a script tag and capture the values by tampering with Array’s constructor or property setters. That vector is closed in current browsers, and it never applied to APIs authenticated with bearer tokens, since a cross-origin script tag sends no Authorization header. If you still support cookie-authenticated endpoints for legacy browsers, avoid bare arrays; otherwise make the extensibility argument, which is the one that actually holds today.
Can I add an envelope to an existing array endpoint without breaking clients?
Not by changing the response in place — the top-level type is what deserialisers bind to, so Invoice[] becoming {data: Invoice[]} fails at parse time in every statically typed client and at property access in every dynamic one. Treat it as the breaking change it is: introduce the envelope under a new media type (or a new path version), let clients opt in by negotiation, measure who is still on the legacy representation, announce a retirement date through Deprecation and Sunset, and only then remove it. The one shortcut worth considering is a query flag like ?envelope=true for internal consumers, which is easier to adopt than a custom Accept header.
Does an envelope hurt payload size or performance?
Not measurably. The wrapper is roughly thirty bytes of structural overhead against responses that are typically kilobytes, and gzip or brotli removes nearly all of it. The one dimension where it can matter is streaming: a client parsing a large response incrementally wants items as early as possible, so serialise data first and put whole-stream metadata at the end. Done that way, an incremental parser reaches the first item at the same point it would with a bare array, and the metadata arrives when it is computable anyway.
Related
- Response Envelopes & Metadata — up-link: the section defining envelope contents and its metadata rules
- Query Patterns & Data Shaping Strategies — up-link: the parent reference for pagination, filtering and projection
- Exposing Pagination Metadata Without Breaking Clients — what to add once the envelope exists
- JSON:API vs HAL vs Plain JSON Contracts — adopting a specified envelope instead of a bespoke one
- Implementing the Sunset HTTP Header — signalling the retirement date this migration depends on