Output Buffering: Capturing, Transforming, and Streaming Responses

Control when and how generated output actually reaches the client using ob_start() and its family of functions -- capturing output for later processing, nesting buffers, compressing a response, and knowing when to disable buffering entirely for streamed, real-time responses.

By default, every echo, print, and bit of literal HTML output in a PHP script is a candidate for immediate transmission to the client -- but PHP actually maintains an internal output buffer, and you can take direct control of it. Output buffering lets you capture generated output as a string instead of sending it, transform it (compress it, post-process it, wrap it in a template), or decide when it's actually safe to flush to the browser. This module covers the ob_*() function family, output handlers, nested buffers, response compression, and -- just as important -- when buffering gets in the way, such as for Server-Sent Events (SSE) or any response that needs to reach the client incrementally rather than all at once.

ob_start(), Capturing, and the Core Function Family

ob_start() turns on output buffering: from that point forward, anything that would normally be sent to the client (echo, print, raw HTML outside PHP tags, printf, etc.) is instead appended to an internal buffer and held. ob_get_contents() returns the buffer's current contents as a string without emptying it or turning buffering off -- useful when you want to inspect or log what's been generated so far while still planning to send it normally later.

ob_get_clean() is the combination most code actually reaches for: it returns the buffer's contents as a string and simultaneously ends buffering and discards the buffer, in one call -- equivalent to calling ob_get_contents() followed by ob_end_clean(). This is the standard pattern for capturing a chunk of generated HTML into a variable, for example rendering a template partial into a string so it can be embedded inside a larger JSON response or cached.

ob_end_clean() ends buffering and discards the buffered content without sending it anywhere -- useful for throwing away partial, broken output (for example, after catching an error partway through rendering) so the client never sees a half-finished page. ob_end_flush() does the opposite: it ends buffering and sends the buffered content to the client (or to the next buffer down, if buffers are nested) exactly as if buffering had never been on, just delayed.

• ob_start() -- begins capturing output into an internal buffer instead of sending it immediately. • ob_get_contents() -- returns the buffer's current text without ending buffering or clearing it. • ob_get_clean() -- returns the buffer's text AND ends buffering AND discards it, in one call (the common pattern). • ob_end_clean() -- ends buffering and discards the content without ever sending it. • ob_end_flush() -- ends buffering and sends the content on (to the client, or to the next outer buffer).

Output Handlers and Nested Buffers

ob_start() optionally accepts a callback: an output handler function that receives the buffered string and returns a (possibly transformed) replacement string, which is what actually gets sent when the buffer flushes. ob_start('ob_gzhandler') is the built-in example -- it registers PHP's own gzip-compression handler, which checks the client's Accept-Encoding header and, if the client supports it, compresses the entire buffered response before sending it, setting the appropriate Content-Encoding header automatically. A custom handler is just any callable with the signature function(string $buffer, int $phase): string, letting you do things like minify HTML, inject a debug toolbar before the closing </body> tag, or redact sensitive fields from output right before it's sent.

Buffers can be nested: calling ob_start() while another buffer is already active pushes a new buffer on top of a stack, rather than replacing the existing one. Ending the inner buffer (with ob_end_flush() or ob_get_clean()) sends or returns only that inner buffer's content, and if flushed, that content becomes part of what the outer buffer is capturing -- not sent to the client directly, since the outer buffer is still active and intercepting everything below it. ob_get_level() tells you how many buffers are currently stacked, which is genuinely useful when writing code (like an error handler) that needs to unwind every active buffer safely regardless of how many other parts of the application may have started their own.

A common, real pattern combining both ideas: an outer buffer wraps an entire page for whole-response post-processing (e.g., minification or a security header injection based on content), while an inner buffer captures one specific widget's rendered HTML into a variable for caching, all without either buffer interfering with the other's accounting of what it's responsible for.

• ob_start($handler) registers a callback that transforms the buffer's content right before it's actually sent/returned. • ob_gzhandler is a built-in handler for transparent gzip compression, honoring the client's Accept-Encoding. • Buffers nest as a stack -- a new ob_start() while one is active pushes another level, it doesn't replace it. • Ending an inner buffer feeds its content into the outer buffer, not directly to the client, if an outer buffer is still open. • ob_get_level() returns the current nesting depth -- 0 means no buffer is active.

Flushing, Response Buffering, and Compression

flush() asks PHP to push whatever has already been sent past the output-buffering layer on to the actual client connection immediately, rather than letting it sit in lower-level buffers (PHP's own internal buffer, the web server's buffer, or proxy buffers in between) -- though whether the client actually receives partial data immediately also depends on those other layers, which PHP's flush() cannot fully control. It's typically called after ob_flush() (which pushes the current ob buffer's content down to the next layer without ending buffering) when you deliberately want the client to start receiving a long response before the whole thing is generated -- like a progress indicator during a long-running export.

Compressing a whole HTML response with ob_start('ob_gzhandler') (or the equivalent zlib.output_compression ini setting) is a simple, effective way to shrink response size for text-heavy pages, since HTML/JSON/CSS compress extremely well. The tradeoff is that compression requires the entire response to be buffered before the compressed output (and its Content-Length) can be finalized and sent -- which is precisely incompatible with any response style that needs to reach the client incrementally as it's generated.

Response buffering in general -- accumulating a full response before sending any of it -- is usually exactly what you want: it lets you set headers or handle errors right up until the last moment (since nothing has actually been sent yet, you can still change your mind), and it lets a handler like ob_gzhandler see and compress the whole thing. The cost is added latency before the client sees anything at all, which becomes a real problem for interactive or long-running responses.

• flush() pushes already-sent-past-buffering data toward the client immediately (subject to server/proxy-level buffering too). • ob_flush() pushes the current buffer's content to the next layer down without ending buffering -- pair it with flush(). • ob_start('ob_gzhandler') or zlib.output_compression compress the whole response transparently based on Accept-Encoding. • Compression needs the full response buffered first, so it directly conflicts with incremental/streamed output. • Full response buffering lets you set headers or discard broken output until the very last moment -- useful, but adds latency before anything is sent.

Streaming Responses: When to Disable Buffering

Server-Sent Events (SSE), long-polling progress updates, and any 'live' streamed response depend on the client receiving each chunk of data as soon as it's generated, not after the whole script finishes. Output buffering -- at any layer -- directly works against that goal, because its entire purpose is to hold data back until a buffer is explicitly flushed or ended. For a genuinely streamed response, the standard approach is to ensure no output buffer is left active (calling while (ob_get_level() > 0) { ob_end_flush(); } defensively at the start, since some environments start with an implicit buffer already open), set headers appropriate for streaming (Content-Type: text/event-stream for SSE, and Cache-Control: no-cache), and then call ob_flush() followed by flush() after every individual chunk you emit.

Compression and streaming are close to mutually exclusive for the same reason: ob_gzhandler needs the entire response before it can produce valid compressed output, so a script trying to stream SSE events through a gzip output handler will simply appear to hang until the connection eventually closes, defeating the entire point. If a response genuinely needs to stream, leave compression off for that response (compress at the reverse proxy layer instead, if compression is still wanted, using its own streaming-aware compression).

It's also worth remembering that PHP's flush()/ob_flush() calls are necessary but not always sufficient -- the web server (nginx, Apache with certain modules) and any reverse proxy in front of it can have their own buffering layers that silently swallow a 'flushed' chunk until they decide to send it on. Real streaming setups typically also need proxy-level configuration (like disabling proxy_buffering in nginx) to actually deliver bytes to the client as they're produced, not just tell PHP to stop holding them back.

• SSE and other streamed responses need each chunk delivered as it's produced -- buffering directly opposes that. • Defensively drain any active buffers at the start of a streaming response: while (ob_get_level() > 0) { ob_end_flush(); } • Call ob_flush() then flush() after every chunk you want the client to receive immediately. • Never combine ob_gzhandler (or full compression) with a streamed response -- compression needs the whole body first. • Web server and proxy layers can also buffer -- true streaming often needs server/proxy config changes too, not just PHP calls.

Basic Capture-Then-Reuse Pattern

ob_get_clean() is ob_get_contents() + ob_end_clean() combined -- the common idiom. Nothing generated between ob_start() and ob_get_clean() reaches the client automatically. Always pair a started buffer with exactly one ending call somewhere on every code path, including error paths.

ob_start();

echo "<div class='widget'>";
echo "Hello, " . htmlspecialchars($userName);
echo "</div>";

$html = ob_get_clean(); // returns the string AND ends+discards the buffer

// $html now holds the rendered markup as a string, nothing has been sent yet
cacheStore('widget:' . $userId, $html);

Code Examples

ob_start() captures every echo/include-driven output the template produces. On success, ob_get_clean() hands back the whole rendered string and cleanly ends buffering in one call. On failure, ob_end_clean() discards whatever partial markup was generated before the exception, so a broken template never leaks half-finished HTML to the caller. Expected result: 45 bytes captured, nothing sent to the client during rendering include $templateFile;... return ob_get_clean();

The inner ob_start()/ob_get_clean() pair captures just the clock widget's markup into $widgetHtml (for example, to cache it), while the outer buffer -- registered with the minifyHtml handler -- is still open and accumulating everything, including the re-echoed widget markup. Only when ob_end_flush() runs on the outer buffer does the whole page get minified and sent. ob_get_level() returning 1 confirms only the outer buffer is active at that point -- the inner one was already ended by ob_get_clean() a few lines earlier.

echo "Nesting depth before outer flush: " . ob_get_level() . "\n";

ob_gzhandler checks the request's Accept-Encoding header itself -- if the client supports gzip, the buffered JSON is compressed and the correct Content-Encoding: gzip header is set automatically; if not, the content passes through unchanged. Because compression needs the complete body, everything must be buffered first, which is exactly why this pattern is unsuitable for a streamed response.

Common mistake: Wrapping a Server-Sent Events endpoint in ob_start('ob_gzhandler') -- the handler waits for the buffer to end before producing any output, so the client never receives incremental events; the connection appears to hang.

No output buffer is left active, and no compression handler is attached, so each echo'd SSE event is a candidate for immediate delivery. Calling ob_flush() (only if a buffer somehow exists) followed by flush() after every event forces PHP to push that chunk on toward the client rather than waiting for the script to finish, which is what makes the progress genuinely appear incrementally instead of all at once at the end. X-Accel-Buffering: no is nginx-specific; the equivalent server-level setting differs per web server/proxy, but some such setting is usually required alongside the PHP-level flush() calls for true end-to-end streaming.

if (ob_get_level() > 0) { ob_flush(); } flush();

<?php
declare(strict_types=1);

function renderPartial(string $templateFile, array $vars): string
{
    extract($vars, EXTR_SKIP);

    ob_start();
    try {
        include $templateFile; // the template just uses plain echo/HTML
        return ob_get_clean();
    } catch (Throwable $e) {
        ob_end_clean(); // discard any partial output, never show a half-rendered template
        throw $e;
    }
}

// user-card.php contains: <div class="card"><?= htmlspecialchars($name) ?></div>
$html = renderPartial('user-card.php', ['name' => 'Ada Lovelace']);
echo strlen($html) . " bytes captured, nothing sent to the client during rendering\n";
<?php
declare(strict_types=1);

function minifyHtml(string $html): string
{
    return (string) preg_replace('/>\s+</', '><', trim($html));
}

ob_start('minifyHtml'); // outer buffer: whole-page minification handler

echo "<html>\n  <body>\n";

ob_start(); // inner buffer: just captures one widget for caching
echo "  <div class='clock'>\n    12:00\n  </div>\n";
$widgetHtml = ob_get_clean(); // ends inner buffer, its content flows into the outer buffer next

echo $widgetHtml; // re-emitted, now captured by the OUTER buffer, not sent directly
echo "  </body>\n</html>";

echo "Nesting depth before outer flush: " . ob_get_level() . "\n"; // still inside the outer buffer -> 1

ob_end_flush(); // ends+sends the outer (minified) buffer to the client
<?php
declare(strict_types=1);

header('Content-Type: application/json');
ob_start('ob_gzhandler'); // no-ops if the client sent no Accept-Encoding: gzip

$payload = [
    'users' => array_map(
        fn(int $i) => ['id' => $i, 'name' => "User {$i}"],
        range(1, 500),
    ),
];

echo json_encode($payload, JSON_THROW_ON_ERROR);

ob_end_flush(); // compresses (if applicable) and sends the full buffered JSON body
<?php
declare(strict_types=1);

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no'); // tells nginx specifically not to buffer this response

// Defensively unwind any buffer(s) already active in this environment.
while (ob_get_level() > 0) {
    ob_end_flush();
}

for ($i = 1; $i <= 5; $i++) {
    echo "event: progress\n";
    echo 'data: ' . json_encode(['step' => $i, 'of' => 5]) . "\n\n";

    if (ob_get_level() > 0) {
        ob_flush();
    }
    flush(); // push this chunk to the client immediately

    sleep(1); // simulate real, incremental work between updates
}

Nested Output Buffers as a Stack

Each ob_start() pushes a level; ending one only affects the top of the stack.

ob_start('minifyHtml')      <- outer buffer opens (level 1)
  echo "<html>..."          -> captured by outer buffer
  ob_start()                <- inner buffer opens (level 2)
    echo widget markup      -> captured by INNER buffer only
  ob_get_clean()             -> inner buffer ends, returns its text (level back to 1)
  echo $widgetHtml          -> re-emitted, now captured by outer buffer
ob_end_flush()               <- outer buffer ends, minifyHtml() runs, sent to client

Important Rules

Always pair ob_start() with exactly one ending call (ob_get_clean/ob_end_clean/ob_end_flush) on every code path, including error paths.

Use ob_get_clean() when you want the captured text as a string and don't need it forwarded to any outer buffer or client.

Check ob_get_level() before assuming zero or one buffer is active -- other code (frameworks, libraries) may already have one open.

Never combine full-response compression (ob_gzhandler / zlib.output_compression) with a response meant to stream incrementally.

For real streaming, drain existing buffers, disable compression for that response, and call ob_flush() + flush() after every chunk.

Remember PHP-level flush() is necessary but not sufficient -- web server/proxy buffering can still hold chunks back.

Why This Matters in Production

Output buffering is what lets a framework set a cookie or change the HTTP status code from deep inside a controller method, even after some template output has already been echoed -- because with buffering on, nothing has actually left the server yet, so headers can still change. It's also the exact mechanism behind gzip-compressed API responses that shrink payload size dramatically for JSON-heavy endpoints. The flip side shows up just as often in production: a live progress bar, a chat-style streaming AI response, or an SSE notification feed that mysteriously 'hangs' until the very end is, in the overwhelming majority of cases, an output buffer (often an implicit one from a framework, or a stray ob_gzhandler) silently holding every chunk back.

header('Content-Type: text/event-stream'); ob_start('ob_gzhandler'); foreach ($events as $e) { echo formatSse($e); ob_flush(); flush(); } header('Content-Type: text/event-stream'); while (ob_get_level() > 0) { ob_end_flush(); } foreach ($events as $e) { echo formatSse($e); if (ob_get_level() > 0) { ob_flush(); } flush(); } ob_gzhandler needs the complete response before it can emit valid compressed output, so it holds every event back until the script ends -- exactly defeating the purpose of a streamed SSE endpoint, no matter how many ob_flush()/flush() calls follow it.

ob_start(); include $templateFile; echo ob_get_contents(); // never ends buffering -- ob_get_level() keeps climbing on every request ob_start(); include $templateFile; echo ob_get_clean(); // returns the text AND ends the buffer in one call ob_get_contents() alone doesn't end buffering -- calling it without a matching ob_end_clean()/ob_end_flush() leaves the buffer open, and the un-ended buffer's content gets echoed right back into itself, duplicating output and leaking buffer levels across the request.

[multiple-choice] What does ob_get_clean() do that ob_get_contents() alone does not? A. It compresses the buffer B. It returns the buffer's content AND ends buffering, discarding the buffer, in one call -- ob_get_contents() only reads it, leaving buffering active C. It sends the buffer to the client immediately D. It nests a new buffer

B. It returns the buffer's content AND ends buffering, discarding the buffer, in one call -- ob_get_contents() only reads it, leaving buffering active — ob_get_contents() just reads the current buffered text without ending or clearing it. ob_get_clean() is the combination of reading it and then ending+discarding the buffer -- the standard pattern for capturing output into a variable.

[multiple-choice] Why is ob_gzhandler fundamentally incompatible with a genuinely streamed (e.g. SSE) response? A. ob_gzhandler only works with JSON responses B. Compression needs the entire response body available before it can produce valid compressed output, so the handler holds all data back until the buffer ends -- defeating incremental delivery C. SSE responses cannot use HTTP headers D. ob_gzhandler disables flush() entirely

B. Compression needs the entire response body available before it can produce valid compressed output, so the handler holds all data back until the buffer ends -- defeating incremental delivery — Gzip compression (as PHP's ob_gzhandler implements it) must see and encode the complete buffered content before producing output, so nothing streams incrementally -- the client receives everything at once, at the very end, regardless of how often ob_flush()/flush() are called in between.

[multiple-choice] When two ob_start() calls are made without an ob_end_*() call in between, what happens? A. The second ob_start() call is silently ignored B. The two buffers merge into one C. A new buffer is pushed on top of a stack (ob_get_level() increases); ending the inner one feeds its content into the still-open outer buffer, not directly to the client D. PHP throws a fatal error immediately

C. A new buffer is pushed on top of a stack (ob_get_level() increases); ending the inner one feeds its content into the still-open outer buffer, not directly to the client — Output buffers nest as a stack. Starting a second buffer while one is active increases ob_get_level() by one; ending the inner buffer sends its content to whatever buffer is now on top of the stack (the outer one), not straight to the client, as long as that outer buffer remains open.

Write a function captureAndCompress(callable $render): string that runs $render() (which echoes HTML), captures everything it outputs, gzip-compresses the captured string with gzencode(), and returns the compressed bytes -- without ever sending anything to the client.

function captureAndCompress(callable $render): string { ob_start(); $render(); $html = ob_get_clean(); return gzencode($html, 6); }

Use ob_start() with no handler, call $render(), grab the text with ob_get_clean(), then compress that string directly with gzencode() rather than relying on ob_gzhandler (since nothing should be sent here, just returned).