TL;DR: JsonSerializer.SerializeAsyncEnumerable(stream, items, topLevelValues: true) writes canonical JSON Lines: one compact value per line, LF after every one. No more WriteLine(JsonSerializer.Serialize(x)) loops.
This ships in .NET 11, which is at Release Candidate 2 with a go-live license, but is not GA yet. Everything below was run on the .NET 11 RC 1 SDK.
The loop everybody has written
JSON Lines (JSONL) is one JSON value per line. It is the format of choice for log files, data exports, and anything you want to append to without rewriting the whole file.
System.Text.Json could already read it. On .NET 10, DeserializeAsyncEnumerable(stream, topLevelValues: true) streams top-level values just fine. Writing it was on you:
await using var writer = new StreamWriter("orders.jsonl"); await foreach (var order in LoadOrders()) { await writer.WriteLineAsync(JsonSerializer.Serialize(order, options)); } It works until someone reuses an options with WriteIndented = true. I ran exactly that with two orders, and the "JSON Lines" file came out as 10 lines, each ending in \r\n because the test machine runs Windows. Two records, ten lines, zero happy parsers.
What .NET 11 adds
Four new JsonSerializer.SerializeAsyncEnumerable overloads. They take an IAsyncEnumerable<T> and write to a Stream or a PipeWriter, with either JsonSerializerOptions or a source-generated JsonTypeInfo<T>.
The interesting part is one bool:
-
topLevelValues: false(the default) writes one JSON array, same asSerializeAsyncdoes today. -
topLevelValues: truewrites JSON Lines.
Per the docs, JSONL mode writes each value compactly with a \n after it, including the last one. It always uses LF, whatever JsonSerializerOptions.NewLine says, and it ignores WriteIndented.
The minimal version
using System.Text.Json; var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); await using var file = File.Create("orders.jsonl"); await JsonSerializer.SerializeAsyncEnumerable( file, LoadOrders(), topLevelValues: true, options: options); static async IAsyncEnumerable<Order> LoadOrders() { yield return new Order(1, "Ada", 42.50m); await Task.Yield(); yield return new Order(2, "Linus", 7m); } record Order(int Id, string Customer, decimal Total); The output:
{"id":1,"customer":"Ada","total":42.50} {"id":2,"customer":"Linus","total":7} I ran this into a MemoryStream with the same "bad" options as the loop above, WriteIndented = true and all. JSONL mode still wrote 78 bytes: two lines, 2 LF, 0 CR. The hand-rolled loop wrote 114 bytes over 10 lines.
Approach, 2 orders, WriteIndented = true | Shape | Bytes | LF | CR |
|---|---|---|---|---|
WriteLineAsync(Serialize(x)) loop | broken JSONL | 114 | 10 | 10 |
SerializeAsync on the IAsyncEnumerable | one array | 139 | 11 | 11 |
SerializeAsyncEnumerable, default | one array | 139 | 11 | 11 |
SerializeAsyncEnumerable, topLevelValues: true | JSONL | 78 | 2 | 0 |
Reading it back is the method you already had:
await foreach (var order in JsonSerializer.DeserializeAsyncEnumerable<Order>( stream, topLevelValues: true, options)) { Console.WriteLine(order); } Both orders came back intact.
The realistic one: a streaming JSONL endpoint
The PipeWriter overload is made for ASP.NET Core, where Response.BodyWriter is already a PipeWriter. Here is an endpoint that streams orders as they come out of a slow source:
using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; var app = WebApplication.CreateSlimBuilder(args).Build(); app.MapGet("/orders.jsonl", async (HttpContext ctx, CancellationToken ct) => { ctx.Response.ContentType = "application/jsonl"; await JsonSerializer.SerializeAsyncEnumerable( ctx.Response.BodyWriter, LoadOrders(ct), AppJson.Default.Order, topLevelValues: true, cancellationToken: ct); }); app.Run(); static async IAsyncEnumerable<Order> LoadOrders( [EnumeratorCancellation] CancellationToken ct = default) { for (int i = 1; i <= 3; i++) { await Task.Delay(500, ct); // pretend this is a slow database page yield return new Order(i, $"customer-{i}", i * 10.5m); } } record Order(int Id, string Customer, decimal Total); [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(Order))] partial class AppJson : JsonSerializerContext; I called it from an HttpClient with ResponseHeadersRead and read the body with DeserializeAsyncEnumerable(..., topLevelValues: true). The three orders arrived at 927 ms, 1431 ms, and 1945 ms: one every 500 ms or so, as each was produced, not all at the end. The first one includes server warm-up. The raw body was 138 bytes, 3 lines, no \r anywhere.
The client starts working on order 1 while the server is still fetching order 3. Nobody waits for a giant array to close.
Can I use it?
- .NET 11 only. I checked: the .NET 10
JsonSerializerhas theDeserializeAsyncEnumerableoverloads withtopLevelValues, but noSerializeAsyncEnumerableat all. - Status: .NET 11 is at RC 2 with a go-live license; GA is still ahead.
- No package needed. It is in the shared framework's System.Text.Json.
Gotchas
The default is still an array. Forget
topLevelValues: trueand you get[...], byte for byte the same asSerializeAsync. The method name says "async enumerable", not "JSON Lines".Your formatting options are ignored on purpose.
WriteIndentedandNewLinedo nothing in JSONL mode. That is the point, but do not expect pretty output for a debug dump.It only takes
IAsyncEnumerable<T>. Got aList<T>?list.ToAsyncEnumerable()from the in-boxSystem.Linq.AsyncEnumerableworked fine in my test.Native AOT wants the
JsonTypeInfooverload. TheJsonSerializerOptionsoverloads are markedRequiresUnreferencedCodeandRequiresDynamicCode. File-based apps (dotnet run app.cs) default to AOT settings too, which is why the endpoint above uses a source-generatedAppJsoncontext.Using a
PipeWriteryou created yourself? Complete it. The docs example callsCompleteAsync()after serializing to aPipeWriter.Create(stream). ASP.NET Core handles that forResponse.BodyWriter.
Wrap-up
JSON Lines finally has a writer to match the reader, and it streams. Grab the .NET 11 RC SDK, find your WriteLine(JsonSerializer.Serialize(...)) loop, and replace it with one call.
Sources
- What's new in .NET 11 libraries: JSON Lines output - Microsoft Learn
- JsonSerializer.SerializeAsyncEnumerable method - .NET API reference
- JsonSerializer.DeserializeAsyncEnumerable method - .NET API reference
- Supported types in System.Text.Json - Microsoft Learn
- .NET 11 Release Candidate 2 - .NET Blog
- JSON Lines format
This article was written with the help of AI and checked against the sources above.

