http clients and apis

Streaming Large JSON Payloads

Large JSON payloads can use a lot of memory if you build the entire response before sending it. This matters for exports, reports, logs, analytics events, and APIs that return thousands or millions of records.

Streaming lets the server produce records gradually and lets the client process data as it arrives. It changes the API contract, so use it deliberately.

Newline-delimited JSON

Newline-delimited JSON, often called NDJSON or JSON Lines, writes one JSON object per line.

PHP example
<?php

declare(strict_types=1);

$rows = [
    ['id' => 1, 'email' => 'a@example.com'],
    ['id' => 2, 'email' => 'b@example.com'],
];

foreach ($rows as $row) {
    echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL;
}

// Prints:
// {"id":1,"email":"a@example.com"}
// {"id":2,"email":"b@example.com"}

This is easier to stream than one huge JSON array because each line is complete JSON on its own.

Full JSON arrays are different

A normal JSON array must include opening and closing brackets and commas between records.

PHP example
<?php

declare(strict_types=1);

$rows = [
    ['id' => 1],
    ['id' => 2],
];

echo '[';

foreach ($rows as $index => $row) {
    echo ($index === 0 ? '' : ',') . json_encode($row, JSON_THROW_ON_ERROR);
}

echo ']' . PHP_EOL;

// Prints:
// [{"id":1},{"id":2}]

This can be streamed too, but error handling is more awkward. If the server fails halfway through, the client may receive invalid JSON.

Use generators to avoid building everything

Generators let PHP produce one row at a time.

PHP example
<?php

declare(strict_types=1);

/**
 * @return Generator<int, array{id: int, email: string}>
 */
function userRows(): Generator
{
    yield ['id' => 1, 'email' => 'a@example.com'];
    yield ['id' => 2, 'email' => 'b@example.com'];
}

foreach (userRows() as $row) {
    echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL;
}

// Prints:
// {"id":1,"email":"a@example.com"}
// {"id":2,"email":"b@example.com"}

In real code, the generator might read from a database cursor, file, or paginated API without loading everything into memory.

Flushing output

In a web response, PHP, the web server, proxies, and the browser may buffer output. Calling flush() can help in some environments, but it does not guarantee every layer sends bytes immediately.

PHP example
<?php

declare(strict_types=1);

echo json_encode(['id' => 1], JSON_THROW_ON_ERROR) . PHP_EOL;

if (function_exists('flush')) {
    flush();
}

// Prints:
// {"id":1}

Test streaming behaviour through the real web server and proxy setup if it matters to the product.

Client compatibility

Not every client wants streamed data. Many frontend applications and API consumers expect a normal JSON array. NDJSON is excellent for batch processing, logs, exports, queues, and data pipelines, but it must be documented.

Use a media type such as application/x-ndjson when the response is newline-delimited JSON.

Partial failure

Streaming can fail after some records have already been sent. At that point, you cannot change the HTTP status code or return a normal JSON error body.

For critical exports, consider generating the file in a background job and letting the client download it after it is complete. Streaming is not always the right answer.

What to check in a project

Check whether the response contract is a JSON array or NDJSON.

Check memory usage when exporting large datasets.

Check database access. A streaming response should not still load all rows into an array first.

Check client expectations and documentation.

Check how partial failures are logged and surfaced.

Check buffering behaviour in the real deployment path.

What you should be able to do

After this lesson, you should be able to explain why large JSON payloads can be expensive, generate NDJSON, stream a JSON array carefully, use generators for row-by-row processing, and recognise when a background export job is safer than streaming.

Practice

Task: Stream NDJSON Rows

Write a small PHP script that emits rows as newline-delimited JSON.

Requirements

  • Use declare(strict_types=1);.
  • Create a generator that yields at least two rows.
  • Encode each row with json_encode(..., JSON_THROW_ON_ERROR).
  • Print one JSON object per line.
  • Include an empty generator case and show that it prints nothing except a label or count.

Check Your Work

Run the script and confirm each data row is complete JSON on its own line.

Show solution

This solution uses generators so rows can be processed one at a time.

PHP example
<?php

declare(strict_types=1);

/**
 * @return Generator<int, array{id: int, email: string}>
 */
function userRows(): Generator
{
    yield ['id' => 1, 'email' => 'a@example.com'];
    yield ['id' => 2, 'email' => 'b@example.com'];
}

/**
 * @return Generator<int, array{id: int, email: string}>
 */
function emptyRows(): Generator
{
    if (false) {
        yield ['id' => 0, 'email' => 'nobody@example.com'];
    }
}

function printNdjson(iterable $rows): int
{
    $count = 0;

    foreach ($rows as $row) {
        echo json_encode($row, JSON_THROW_ON_ERROR) . PHP_EOL;
        $count++;
    }

    return $count;
}

$count = printNdjson(userRows());
echo 'Rows streamed: ' . $count . PHP_EOL;

$emptyCount = printNdjson(emptyRows());
echo 'Empty rows streamed: ' . $emptyCount . PHP_EOL;

// Prints:
// {"id":1,"email":"a@example.com"}
// {"id":2,"email":"b@example.com"}
// Rows streamed: 2
// Empty rows streamed: 0

In a real HTTP response, the NDJSON rows would usually use Content-Type: application/x-ndjson.

Why This Works

Each row is valid JSON by itself, which lets a client process records incrementally. The empty case proves the stream can complete without pretending there is a null row.