Building a Bounded NDJSON Streaming Reader in .NET
articleSeptember 30, 2026Updated October 7, 2026By Ali Haghighi
A line-delimited JSON reader sounds trivial until the input is large, records are untrusted, cancellation matters, and the code needs to survive Native AOT.
NDJSON, also called JSON Lines, is useful for logs, exports, batch results and evaluation datasets. Each physical line contains one complete JSON value. That framing makes incremental processing straightforward, but straightforward is not the same as bounded.
A first implementation often looks like this:
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
var item = JsonSerializer.Deserialize<T>(line);
Process(item);
}For many applications, that is enough. I did not want to build a package merely to replace those few lines. The interesting requirement was different: enforce an explicit byte limit for every record while keeping total-input size irrelevant to parser buffering.
The boundary is the feature
The core invariant in JsonStream.NET is one JSON value per physical line, with a configurable maximum number of UTF-8 bytes for that line. This is a line-framing guarantee, not a general JSON streaming requirement.
The reader consumes bytes directly from a Stream, searches for LF delimiters, and only accumulates data when a record crosses a read boundary. An oversized line fails before it can grow without bound.
await foreach (var item in JsonStreamReader.DeserializeAsync<Order>(
stream,
maxRecordBytes: 64 * 1024,
cancellationToken: cancellationToken))
{
Process(item);
}Parser buffering is proportional to the configured read buffer plus the maximum record size, rather than the complete input size. Objects retained by the application are outside that bound.
Cancellation is more than cancelling ReadAsync
Passing a token to Stream.ReadAsync handles blocked I/O, but one read can contain several complete records. The implementation checks cancellation both around asynchronous reads and while advancing through buffered records.
Keep System.Text.Json in charge
The library delegates JSON semantics to System.Text.Json and focuses on framing. It accepts either JsonSerializerOptions or JsonTypeInfo<T>. The latter supports source-generated metadata for trimming and Native AOT.
await foreach (var item in JsonStreamReader.DeserializeAsync(
stream,
OrderJsonContext.Default.Order,
cancellationToken: cancellationToken))
{
Process(item);
}
[JsonSerializable(typeof(Order))]
internal partial class OrderJsonContext : JsonSerializerContext;A Linux x64 Native AOT smoke application runs in CI with reflection-based JSON serialization disabled. That catches an important class of integration mistakes without pretending to prove every consumer scenario.
Test the awkward boundaries
Streaming code tends to fail at boundaries. Useful tests include records split across tiny reads, multibyte UTF-8 crossing a read boundary, exact and over-limit records, CRLF, a final record without LF, JSON null, invalid JSON after valid records, cancellation, and stream ownership.
Benchmark the alternative, not the story
The repository includes reproducible BenchmarkDotNet comparisons against a StreamReader implementation and the built-in top-level JSON reader. Early in-memory measurements reached approximate parity with the built-in reader in two tested workloads. I treat that as an implementation check, not a general performance claim.
.NET already has strong JSON streaming APIs. In .NET 9, System.Text.Json added support for streaming multiple top-level JSON values. If ordinary top-level streaming is the requirement, the built-in API should be considered first.
JsonStream.NET has a narrower purpose when physical line framing and an explicit per-record byte limit are part of the contract.
An AI use case without an AI dependency
Saved model predictions fit naturally in JSONL and can be processed incrementally. The repository includes a classification-evaluation sample that reads bounded prediction records, computes exact-match results and supports Ctrl+C without model calls or API credentials.
That is intentionally where the AI association stops. The core package does not depend on an AI SDK, parse SSE, reconstruct partial tool arguments or pretend to be a general AI streaming abstraction.
The harder decision was what not to build
A small library is easier to trust when its boundary is obvious. Here that means bounded, line-framed UTF-8 ingestion, predictable stream ownership, cancellation, and direct integration with System.Text.Json.
The project is still prerelease work. I am using the sample and benchmarks to decide whether the bounded JSONL use case is strong enough to justify a standalone NuGet dependency.