Why streaming matters

When a service receives a 50 MB JSON array, loading the whole thing into memory just to filter a few records is a recipe for GC pressure and out‑of‑memory crashes. I learned this the hard way on a project that ingested nightly audit logs from dozens of micro‑services. The fix was to treat the payload as a stream and deserialize element‑by‑element.

The problem with JsonSerializer.Deserialize<T[]>

The one‑shot API allocates a single giant array and all its objects at once. For a 100 k‑item array that’s fine, but for millions of rows the Gen 2 heap fills up fast. You also lose the ability to back‑pressure the upstream producer — everything arrives before you can start processing.

Enter IAsyncEnumerable<T>

Since .NET 6, System.Text.Json can deserialize directly into an IAsyncEnumerable<T>. The parser reads the UTF‑8 stream, yields each object as soon as the closing brace is seen, and lets you await foreach over the results. Memory stays flat because only one element lives on the heap at a time.

Production‑ready example

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;

public sealed record AuditEntry(
    long Id,
    DateTimeOffset Timestamp,
    string Service,
    string Level,
    string Message
);

public static class AuditLogStreamer
{
    /// 
    /// Streams audit entries from a UTF‑8 JSON array without loading the whole payload.
    /// 
    public static async IAsyncEnumerable ReadAsync(Stream utf8JsonStream)
    {
        // Options tuned for streaming: ignore trailing commas, allow comments.
        var options = new JsonSerializerOptions
        {
            PropertyNameCaseInsensitive = true,
            AllowTrailingCommas = true,
            ReadCommentHandling = JsonCommentHandling.Skip
        };

        // The magic: JsonSerializer.DeserializeAsyncEnumerable returns IAsyncEnumerable
        await foreach (var entry in JsonSerializer.DeserializeAsyncEnumerable(utf8JsonStream, options))
        {
            // Defensive null‑check – the parser can yield null for malformed elements.
            if (entry is not null)
                yield return entry;
        }
    }

    /// 
    /// Example consumer: filter only Error‑level entries and write to a downstream sink.
    /// 
    public static async Task ProcessErrorEntriesAsync(Stream source, IAsyncCollector sink)
    {
        await foreach (var entry in ReadAsync(source))
        {
            if (string.Equals(entry.Level, "Error", StringComparison.OrdinalIgnoreCase))
                await sink.AddAsync(entry);
        }
    }
}

// Minimal async collector interface for demo purposes
public interface IAsyncCollector
{
    ValueTask AddAsync(T item);
}

Why this works

  • Zero‑allocation per element – the parser reuses a single Utf8JsonReader and only allocates the target AuditEntry record.
  • Back‑pressure built‑in – the await foreach pauses automatically when the consumer (e.g., a database bulk insert) can’t keep up.
  • Cancellation support – pass a CancellationToken to DeserializeAsyncEnumerable to abort early without leaking the underlying stream.

Tip: If you need to transform each element before yielding, wrap the await foreach in a SelectAwait extension (available in System.Linq.Async) to keep the pipeline fully asynchronous.

Common pitfalls

  1. Forgetting to dispose the source stream – the enumerator implements IAsyncDisposable; always await using the stream or call DisposeAsync on the enumerator.
  2. Mixing sync and async APIs – calling .Result on the enumerable defeats streaming and re‑introduces the memory spike.
  3. Invalid JSON shapes – the method expects a top‑level array. A single object or NDJSON requires a different reader configuration.

Real‑world impact

On the audit‑log service, switching to streaming cut peak memory from 1.2 GB to under 30 MB and reduced end‑to‑end latency by 40 % because the downstream writer could start persisting rows while the network was still delivering the tail of the payload. The code change was under 30 lines, yet the operational win was massive.

Takeaway

Whenever you face a JSON array that could grow beyond a few megabytes, reach for JsonSerializer.DeserializeAsyncEnumerable. It gives you a clean, alloc‑friendly pipeline that plays nicely with the rest of the async ecosystem — no custom parsers, no manual buffer management, just idiomatic C#.