Large JSON and NDJSON
WebResponse.jsonArray(…) and WebResponse.ndjson(…) write elements as they are produced, for an answer too large to hold.
WebResponse.json(list, JsonWriter.list(CARD)) writes every element into one buffer, and only then sends a byte.
A hundred thousand cards are therefore in memory as one document before the client sees the first one.
That is the right trade for an answer that fits: it is one line, and the length is known, so the response carries a Content-Length.
It is the wrong one for an answer that does not.
A streamed array
WebResponse.jsonArray(sink → …) answers with an ordinary JSON array, written a value at a time.
The media type is application/json, and the document is the one a held response would have produced:
app.get("/api/decks/{deckId}/cards", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.jsonArray(sink -> cardService.eachCard(deckId,
card -> sink.write(card, Codecs.CARD_ROW)));
});
The response writes the brackets and the commas, so the writer supplies only the next element, and the output’s buffer goes to the container as it fills, so no element is ever a document of its own.
An array nothing was written to is still an array: [].
sink.write(value, writer) takes the same JsonWriter<T> as everything else, hand-written or generated.
Streaming changes how the output is framed, not how a value is mapped.
Writing throws UncheckedIOException rather than a checked IOException, so card → sink.write(card, CARD) is an ordinary Consumer and fits inside a row callback over a database cursor.
NDJSON
WebResponse.ndjson(sink → …) writes one complete JSON value per line as application/x-ndjson:
app.get("/api/decks/{deckId}/cards.ndjson", req -> {
long deckId = req.pathParamLong("deckId");
return WebResponse.ndjson(sink -> cardService.eachCard(deckId,
card -> sink.write(card, Codecs.CARD_ROW)));
});
{"id":1,"text":"pomme","meaning":"apple","createdAt":"2026-03-01T09:00:00"}
{"id":2,"text":"poire","meaning":"pear","createdAt":"2026-03-01T09:01:00"}
Prefer NDJSON over an array for bulk data, because a client can use a partial answer.
Each line stands alone, so a consumer acts on record one without waiting for record one hundred thousand, and a transfer that is cut off leaves complete records rather than a document missing its closing bracket.
A value can never contain a newline of its own: Json escapes one inside a string as \n and puts no whitespace between members.
NDJSON here is bulk transfer, not live events. The response ends when the data ends, and nothing flushes between lines beyond what the container’s buffer decides. A stream that stays open because more will happen later is Server-Sent Events, which flushes per event and reconnects.
Reading NDJSON
req.bodyNdjson(reader) reads NDJSON, and reads it lazily.
The body is parsed as the stream is consumed, so an upload of a hundred thousand records is never a list.
app.post("/api/decks/{deckId}/cards.ndjson", req -> {
long deckId = req.pathParamLong("deckId");
int imported = cardService.importCards(deckId, req.bodyNdjson(Codecs.CARD_DRAFT));
JsonObject resBody = Json.object().put("imported", imported);
return WebResponse.json(resBody);
});
Blank lines are skipped: an empty line, and one of spaces, tabs, and CRs, the whitespace JSON allows.
A line of any other whitespace, such as a form feed, is a line that is not valid JSON.
A line that is not valid JSON, or that the reader rejects with IllegalArgumentException or DateTimeException, answers 400 naming the line.
That is the other reason to read NDJSON rather than one large array: the report says where the body went wrong, not just that it did.
A line over 1MB answers 413 naming the line, and is refused as soon as it outgrows the limit.
The number of lines is not limited, and Body size limits describes how to change the line limit.
Because the stream is lazy, those failures happen where it is consumed.
Consume it before returning the response.
Inside a stream or ndjson writer, handler exception handling has finished and a rejection no longer produces a 400.
Reading the whole import inside one transaction makes a malformed line safe: the exception leaves the service, the transaction rolls back, and the client is told which line rather than being left with a deck half filled.
What the streamed kinds inherit
Both are a WebResponse.stream(…) body underneath.
There is no new body kind, so the servers, the filters, and the compression needed no change for them.
Every caveat follows from that inheritance:
-
The writer runs outside handler exception handling. A failure before commitment gets a generic 500. After commitment the status cannot change, so the transfer is aborted instead. The client sees an incomplete response rather than a partial one that looks complete.
RequestCompletion.writeFailure()reports the write failure independently of the status. Anything that can fail in a way the client should hear about, such as a missing deck or an unauthorized caller, belongs before the response is returned. The example looks the deck up first for exactly this reason. -
A HEAD runs the writer. Only producing the body gives its length, and running the writer is also what closes whatever the writer opened.
-
gzip covers them.
application/jsonandapplication/x-ndjsonare both inGzip.DEFAULT_TYPES, and a streamed body is compressed as it goes with theContent-Lengthdropped.
Reading a single large document
req.bodyJson(reader) holds the body as bytes under the 1MB body limit, so a larger document answers 413 unless bodyLimits raises the limit.
A reader written against the JsonInput, or generated from the type, reads those bytes one value at a time and builds no tree, while req.bodyJson() and JsonReader.object build the tree a hand-written reader reads.
For a single JSON document too large to hold, reach for NDJSON: a body of one value per line never needs a streaming parser, however large it is.
Where the format is not yours to choose, req.bodyStream() hands the unread bytes to a streaming parser from another library, as External JSON Binding describes.