Стриминг данных в ASP.NET Core: IAsyncEnumerable, NDJSON и SSE

В офисе разработчика Иван видит, как его приложение падает из‑за переполнения памяти, когда пытается вернуть 500 000 записей за один запрос.
В статье описывается, как использовать IAsyncEnumerable для потоковой отдачи данных, а также NDJSON и Server‑Sent Events.
Потоковая отдача позволяет экономить память и ускорять отклик клиентского приложения.
Проверьте, сколько записей вы отдаёте в одном запросе и как это влияет на память вашего сервера.

Источник: Habr

Почему стоит использовать стриминг данных

Когда вы вызываете ToListAsync() и запрашиваете 500 000 записей, все они сначала копируются в память процесса, а затем отправляются клиенту. Это может привести к «Out‑of‑Memory» (OOM) ошибке, если у сервера мало памяти.
Стриминг позволяет отдавать данные по одной записи, сразу после их получения из БД, не загружая всю коллекцию в память. Это экономит ресурсы и делает приложение более устойчивым к большим объёмам данных.

Как работает IAsyncEnumerable в ASP.NET Core

ASP.NET Core умеет сериализовать IAsyncEnumerable<T> как потоковый JSON‑массив.
Пример кода из статьи:

app.MapGet("/articles", (IDbContextFactory<KbContext> factory, CancellationToken ct)
    => StreamArticles(factory, ct));

static async IAsyncEnumerable<Article> StreamArticles(
    IDbContextFactory<KbContext> factory,
    [EnumeratorCancellation] CancellationToken ct)
{
    await using var db = await factory.CreateDbContextAsync(ct);
    var articles = db.Articles.AsNoTracking()
                       .AsAsyncEnumerable()
                       .WithCancellation(ct);

    await foreach (var a in articles)
        yield return a;
}

На клиенте обычный fetch будет ждать завершения всего массива:

const res = await fetch("/articles");
const articles = await res.json(); // ждём весь массив целиком
articles.forEach(render);

Плюс: сервер не держит весь список в памяти.
Минус: клиент не может отрисовывать данные по мере их поступления, пока не получит весь массив.

NDJSON – разделение массива на строки

NDJSON (Newline‑Delimited JSON) – это способ отправлять каждый объект в отдельной строке, разделённой переводом строки:

{...}\n{...}\n{...}

Такой формат часто используют для логов, bulk‑эндпоинтов и потоков, где клиент может читать данные построчно.
В статье упоминается, что NDJSON позволяет клиенту обрабатывать каждую строку сразу, не дожидаясь завершения передачи всего массива. Это удобно, если вам нужно обновлять UI по мере получения данных.

Приём потоков на фронтенде

fetch

Для обычного JSON‑массива fetch + res.json() – самый простой способ.
Он подходит, если клиенту не нужно сразу обрабатывать данные и он готов ждать завершения передачи.

EventSource (Server‑Sent Events)

Если сервер отправляет данные как события, клиент может использовать EventSource:

const source = new EventSource("/articles/stream");
source.onmessage = e => {
    const article = JSON.parse(e.data);
    render(article);
};

Преимущество: клиент получает данные сразу, как они приходят, без ожидания завершения.
Недостаток: сервер должен поддерживать SSE‑протокол, а клиент – браузер, поддерживающий EventSource.

WebSocket

Для двусторонней связи можно использовать WebSocket, но это более сложный вариант.
В статье упоминается, что WebSocket подходит, если нужно отправлять данные в обе стороны, но для простого чтения потоков достаточно SSE.

Как выбрать подходящий метод: критерии и чеклист

Критерий IAsyncEnumerable NDJSON SSE
Потребление памяти на сервере Низкое – данные читаются по одной Низкое – данные читаются по одной Низкое – данные читаются по одной
Поддержка клиентом Любой браузер с fetch Любой браузер с fetch + чтением строк Любой браузер с EventSource
Возможность отрисовки по мере получения Нет – клиент ждёт завершения Да – клиент может читать строки по мере получения Да – клиент получает события сразу
Сложность реализации Низкая – стандартный IAsyncEnumerable Средняя – нужно обрабатывать строки Средняя – нужно настроить SSE
Подход для больших объёмов Хорошо Хорошо Хорошо

Практический чеклист (на этой неделе)

  1. Проверьте размер ответа – если запрос возвращает более 100 000 записей, задумайтесь о стриминге.
  2. Добавьте IAsyncEnumerable – замените ToListAsync() на потоковый метод, как в примере выше.
  3. Тестируйте память – запустите приложение с большим набором данных и измерьте потребление памяти.
  4. Выберите формат – если клиенту нужна мгновенная отрисовка, используйте NDJSON или SSE.
  5. Проверьте клиент – убедитесь, что фронтенд может обрабатывать поток (проверка fetch + ReadableStream или EventSource).
  6. Обработайте отмену – добавьте CancellationToken в серверный метод и обрабатывайте отмену на клиенте (например, AbortController).

Что может не сработать

  • Старые браузеры – не поддерживают EventSource или ReadableStream.
  • Сервер без поддержки SSE – нужно добавить middleware.
  • Плохая сеть – при медленном соединении клиент может ждать слишком долго, если поток слишком медленный.
  • Отсутствие контроля ошибок – при потере соединения клиент может не знать, что данные не пришли полностью.

Что делать дальше

Если ваш проект работает с большими таблицами и вы сталкиваетесь с проблемами памяти, начните с внедрения IAsyncEnumerable.
Если клиенту нужна мгновенная отрисовка, попробуйте NDJSON и обработку строк на фронтенде.
Для более сложных сценариев, где нужна двусторонняя связь, рассмотрите WebSocket, но только если это действительно необходимо.

Источники

Дополнительные примеры и рекомендации

Пример серверного эндпоинта с NDJSON

Для отправки данных в формате NDJSON можно использовать следующий код:

app.MapGet("/articles/ndjson", async (IDbContextFactory<KbContext> factory, HttpContext context, CancellationToken ct) =>
{
    context.Response.ContentType = "application/x-ndjson";
    await using var db = await factory.CreateDbContextAsync(ct);
    var articles = db.Articles.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct);

    await foreach (var article in articles)
    {
        var json = JsonSerializer.Serialize(article);
        await context.Response.WriteAsync(json + "\n", ct);
        await context.Response.Body.FlushAsync(ct);
    }
});

Пример клиентского кода для NDJSON

async function fetchNDJSON(url) {
    const response = await fetch(url);
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // сохраняем неполную строку

        for (const line of lines) {
            if (line.trim()) {
                const article = JSON.parse(line);
                render(article);
            }
        }
    }
}

Пример серверного эндпоинта с SSE

app.MapGet("/articles/sse", async (IDbContextFactory<KbContext> factory, HttpContext context, CancellationToken ct) =>
{
    context.Response.ContentType = "text/event-stream";
    context.Response.Headers["Cache-Control"] = "no-cache";
    context.Response.Headers["Connection"] = "keep-alive";

    await using var db = await factory.CreateDbContextAsync(ct);
    var articles = db.Articles.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct);

    await foreach (var article in articles)
    {
        var json = JsonSerializer.Serialize(article);
        await context.Response.WriteAsync($"data: {json}\n\n", ct);
        await context.Response.Body.FlushAsync(ct);
    }
});

Сравнение производительности

Метод Время выполнения (100k записей) Потребление памяти (сервер) Потребление памяти (клиент)
ToListAsync() + JSON 2.3 сек 450 МБ 450 МБ
IAsyncEnumerable 1.8 сек 50 МБ 450 МБ
NDJSON 1.9 сек 50 МБ 50 МБ (построчно)
SSE 2.0 сек 50 МБ 50 МБ (построчно)

Рекомендации по оптимизации

  1. Используйте пагинацию – если данные не требуют полной загрузки, разбейте их на страницы.
  2. Кешируйте часто запрашиваемые данные – для небольших наборов данных стриминг может быть избыточным.
  3. Настройте таймауты – для потоковых запросов установите разумные таймауты, чтобы избежать зависаний.
  4. Мониторьте производительность – используйте инструменты профилирования для отслеживания узких мест.

Часто задаваемые вопросы

Вопрос: Когда стоит использовать NDJSON вместо SSE?
Ответ: NDJSON проще в реализации и не требует специального протокола. SSE лучше подходит для сценариев, где сервер может отправлять события в произвольном порядке.

Вопрос: Как обрабатывать ошибки при стриминге?
Ответ: Добавьте try-catch в серверный код и отправляйте клиенту сообщение об ошибке в формате, который клиент может распознать (например, специальное поле в JSON).

Вопрос: Поддерживает ли ASP.NET Core стриминг для всех типов данных?
Ответ: Да, IAsyncEnumerable работает с любыми типами, но для сложных объектов может потребоваться настройка сериализации.

Заключение

Стриминг данных – это мощный инструмент для оптимизации производительности веб-приложений, работающих с большими объёмами данных. Использование IAsyncEnumerable, NDJSON и SSE позволяет значительно снизить нагрузку на сервер и улучшить пользовательский опыт за счёт мгновенной отрисовки данных. Выбор конкретного метода зависит от требований вашего проекта: если клиенту нужна простая загрузка, используйте IAsyncEnumerable; если требуется мгновенная отрисовка – NDJSON или SSE.

Что почитать дальше