Templ Streaming and Partial Rendering for Faster Pages
Stream templ pages with templ.Flush and WithStreaming so the shell reaches the browser before slow data is ready, and see what a failed streamed render does.

A page that waits for its slowest query before sending any bytes feels slower than it is. The browser sits on a blank tab while the server finishes work that only affects the bottom third of the page. HTTP has supported the alternative since the beginning: send the first part now, with chunked transfer encoding, and the rest when it is ready. templ makes that a two-line change, and also makes its main danger easy to see.
Tested with Go 1.25.6 and templ v0.3.1001, macOS on Apple Silicon (arm64)
This article measures the effect with a page that has one 300 ms section, compares buffered and streamed responses, and then makes a streamed render fail on purpose, which is where the trade-off lives. It follows the tutorial, relates to the HTMX approach that solves the same problem differently, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with templ v0.3.1001.
The components
package main
import (
"errors"
"time"
)
// slow stands in for a database query or a call to another service.
func slow() (string, error) {
time.Sleep(300 * time.Millisecond)
return "the slow numbers", nil
}
func broken() (string, error) {
time.Sleep(50 * time.Millisecond)
return "", errors.New("upstream failed")
}
templ Shell() {
<!DOCTYPE html>
<html lang="en">
<head><title>Report</title></head>
<body>
<h1>Report</h1>
<p>The page frame and heading are ready immediately.</p>
{ children... }
</body>
</html>
}
// WithFlush sends everything so far before the slow part runs.
templ WithFlush() {
@Shell() {
@templ.Flush()
<section id="numbers">{ slow() }</section>
<footer>done</footer>
}
}
// WithoutFlush has the same content but no explicit flush.
templ WithoutFlush() {
@Shell() {
<section id="numbers">{ slow() }</section>
<footer>done</footer>
}
}
templ FailsLate() {
@Shell() {
@templ.Flush()
<section id="numbers">{ broken() }</section>
<footer>done</footer>
}
}Shell is the frame: a heading and a paragraph that need no data. WithFlush puts @templ.Flush() between the heading and the slow section; WithoutFlush has the same content with no flush; FailsLate flushes and then calls a function that errors. slow() sleeps for 300 ms to stand in for a query.
templ.Flush() is a component whose job is to flush the writer if it supports http.Flusher. Everything the page wrote before it is pushed to the client at that point.
Measuring four responses
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/a-h/templ"
)
type result struct {
status int
firstByte time.Duration
total time.Duration
body string
chunked bool
readErr error
}
func fetch(url string) result {
start := time.Now()
res, err := http.Get(url)
if err != nil {
panic(err)
}
defer res.Body.Close()
r := result{status: res.StatusCode, chunked: res.ContentLength == -1}
buf := make([]byte, 4096)
var sb strings.Builder
for {
n, err := res.Body.Read(buf)
if n > 0 {
if r.firstByte == 0 {
r.firstByte = time.Since(start)
}
sb.Write(buf[:n])
}
if err != nil {
if err != io.EOF {
r.readErr = err
}
break
}
}
r.total, r.body = time.Since(start), sb.String()
return r
}
func main() {
mux := http.NewServeMux()
mux.Handle("/buffered", templ.Handler(WithoutFlush()))
mux.Handle("/streamed", templ.Handler(WithFlush(), templ.WithStreaming()))
mux.Handle("/late-error-buffered", templ.Handler(FailsLate()))
mux.Handle("/late-error-streamed", templ.Handler(FailsLate(), templ.WithStreaming()))
srv := httptest.NewServer(mux)
defer srv.Close()
ms := func(d time.Duration) string {
return fmt.Sprintf("%d ms", d.Round(50*time.Millisecond)/time.Millisecond)
}
show := func(name, path string) {
r := fetch(srv.URL + path)
fmt.Printf("%-22s status=%d first byte~%-7s total~%-7s chunked=%-5v footer=%v\n", name, r.status, ms(r.firstByte), ms(r.total), r.chunked, strings.Contains(r.body, "<footer>"))
}
show("buffered (default)", "/buffered")
show("streamed + Flush", "/streamed")
show("failing, buffered", "/late-error-buffered")
show("failing, streamed", "/late-error-streamed")
r := fetch(srv.URL + "/late-error-streamed")
fmt.Printf("streamed failure body ends: %q\n", r.body[max(0, len(r.body)-60):])
}go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
go run . 2>&1 | sed -E 's/^[0-9]{4}\/[0-9]{2}\/[0-9]{2} [0-9:]{8} //'buffered (default) status=200 first byte~300 ms total~300 ms chunked=false footer=true
streamed + Flush status=200 first byte~0 ms total~300 ms chunked=true footer=true
failing, buffered status=500 first byte~50 ms total~50 ms chunked=false footer=false
http: superfluous response.WriteHeader call from github.com/a-h/templ.(*ComponentHandler).handleRenderErr (handler.go:25)
failing, streamed status=200 first byte~0 ms total~50 ms chunked=true footer=false
http: superfluous response.WriteHeader call from github.com/a-h/templ.(*ComponentHandler).handleRenderErr (handler.go:25)
streamed failure body ends: "</p> <section id=\"numbers\">templ: failed to render template\n"The test client reads the body in chunks and records when the first byte arrived and when the body ended. The numbers are rounded to 50 ms, because the point is the shape.
The first line is the default behavior. The buffered response's first body byte arrived at about 300 ms, the same moment the whole thing finished: templ.Handler rendered the component into a buffer, waited for the slow function, then wrote everything at once. The content length is known (chunked=false) because the handler could set it.
The second line is the same content streamed. The shell, with the heading and the paragraph, arrived at about zero, and the section and footer followed at 300 ms. The total time did not change, since the slow query still takes 300 ms, but the visitor sees the frame of the page 300 ms earlier, which is the difference between a page that feels broken and one that feels fast. The response is chunked, since there was no way to know the length in advance.
What streaming costs you
Lines three and four are the part that should make you think. A component that fails when rendered behind the default handler yields 500 and a plain message, with no half page, because nothing had been sent yet. The same failure with streaming gives status 200: the headers were sent with the first flush, before the error happened, and no later code can change the status. The visitor gets the shell, the beginning of the section, and the text templ: failed to render template inside the markup. The final line of output shows the end of the body, with that error message sitting inside <section id="numbers">. And the server logged http: superfluous response.WriteHeader call because the error handler tried to set a 500 after the status was already out.
So the choice is not free. With buffering, the status code is always truthful and the cost is waiting. With streaming, you get speed, and every failure after the first flush becomes a broken page with a success code. Monitoring that counts 5xx responses will not see those failures, so log render errors yourself with templ.WithErrorHandler, and count them.
Where streaming pays and where it does not
Use it when the page has a fast shell and one or two slow regions, and the browser can do useful work with the shell: load the stylesheet and scripts (the <head> is the most valuable thing to send early), paint the header and navigation, start fetching images. Place the flush right after the head and the above-the-fold content, before the first slow call. Do not flush before every component: each flush is a write to the connection, and a stream of tiny chunks costs more than it saves.
Be careful about what comes after the flush. Anything that needs to set a header, such as a cookie or a redirect, must happen before the first flush. Content that depends on a query that might fail (the user's name, an access check) should be resolved before streaming begins, so the failure can still be a proper error page.
Also consider the alternative that avoids the trade-off. Render the shell with a placeholder, return it normally with a correct status, and let the browser fetch the slow part afterwards with a second request, as in the HTMX version: <div hx-get="/report/numbers" hx-trigger="load">Loading...</div>. The slow part now has its own request, its own status code and its own error handling, and the main page is cached or buffered as usual. The cost is one more round trip and a bit of markup. For many pages that is the better design, and streaming is reserved for the cases where the extra request is not acceptable.
Making sure the stream arrives
A stream only helps if nothing between your handler and the browser holds it back. Check three places. First, compression middleware: a gzip writer that does not flush on Flush() buffers your chunks and delivers them at the end, so confirm that yours passes flushes through (the gzip middleware article covers writing one that does). Second, your own response writer wrappers: they must implement Flush or expose an Unwrap method for http.ResponseController, or the flush silently does nothing. Third, reverse proxies: nginx buffers upstream responses by default, so set proxy_buffering off for the route or send the header X-Accel-Buffering: no; CDNs may need the equivalent setting. The quickest check is the one this article uses: a client that records when the first byte arrives, run against the deployed URL.
Finally, remember server timeouts. A long stream is a long response, and a WriteTimeout shorter than the stream cuts it off, as discussed in server timeouts; extend the deadline for streaming routes with http.ResponseController. The options referenced here are documented in the templ guide and in the package reference.


