Rendering & Performance

Where output goes, and what it costs to put it there.

The @render Macro

@render turns a template into output. Where that output goes depends on the destination it is given.

Basic Rendering

With no destination @render returns a String. A block renders each element in turn:

using HypertextTemplates
using HypertextTemplates.Elements

# Render to String (default)
html = @render @div "Hello, World!"

<div>Hello, World!</div>

# Render multiple elements
html2 = @render begin
    @h1 "Title"
    @p "Paragraph"
end

<h1>Title</h1>
<p>Paragraph</p>

Rendering to IO

Pass an IO as the first argument and the render writes through to it, with no string built along the way. That is what a server response or a file wants.

using HypertextTemplates
using HypertextTemplates.Elements

# Render to IOBuffer
buffer = IOBuffer()
@render buffer @div begin
    @h1 "Small Document"
    for i in 1:3
        @p "Paragraph $i"
    end
end
result = String(take!(buffer))

<div>
  <h1>Small Document</h1>
  <p>Paragraph 1</p>
  <p>Paragraph 2</p>
  <p>Paragraph 3</p>
</div>

Output Type Control

Specify the desired output type:

using HypertextTemplates
using HypertextTemplates.Elements

# Render to String (explicit)
str = @render String @div "Content"
println(typeof(str), ": ", str)

# Render to Vector{UInt8}
bytes = @render Vector{UInt8} @div "Binary content"
println(typeof(bytes), ": ", String(bytes))
String: <div>Content</div>
Vector{UInt8}: <div>Binary content</div>

What a Render Allocates

A render allocates a few hundred bytes for the context it carries, then nothing per element. There is no DOM, nothing is concatenated, and escaping a value builds no intermediate string. A three item list and a three thousand item one cost the same. What grows with the page is the destination's own storage, the buffer behind a String or a Vector{UInt8}; write to a socket or a file and even that goes away.

Direct IO Streaming

Given an IO, the render writes to it as it goes:

using HypertextTemplates
using HypertextTemplates.Elements

title = "My Article"
paragraphs = ["First paragraph.", "Second paragraph.", "Third paragraph."]

io = IOBuffer()
@render io @article begin
    @h1 $title
    for paragraph in paragraphs
        @p $paragraph
    end
end

result = String(take!(io))

# The writes it makes are: "<article>", "<h1>", the escaped title,
# "</h1>", and so on to the closing tag.

<article>
  <h1>My Article</h1>
  <p>First paragraph.</p>
  <p>Second paragraph.</p>
  <p>Third paragraph.</p>
</article>

Static Structure Becomes Constants

A tag name and the run of literal attributes that follows it are known while the macro expands, so they are merged into single string constants there. Rendering writes those constants out; the work left at run time is the interpolated values and the control flow around them.

using HypertextTemplates
using HypertextTemplates.Elements

@component function static_heavy()
    @div {class = "container"} begin
        @header {class = "header"} begin
            @nav begin
                @a {href = "/"} "Home"
                @a {href = "/about"} "About"
            end
        end
        @article {class = "content"} begin
            @__slot__  # Only dynamic part
        end
    end
end

@deftag macro static_heavy end

html = @render @static_heavy begin
    @p "This is the dynamic content that goes in the slot."
end

<div class="container">
  <header class="header">
    <nav><a href="/">Home</a><a href="/about">About</a></nav>
  </header>
  <article class="content">
    <p>This is the dynamic content that goes in the slot.</p>
  </article>
</div>

StreamingRender

For large documents or slow-loading content, use StreamingRender to send content as it becomes available.

Basic Streaming

using HypertextTemplates
using HypertextTemplates.Elements

# Create a streaming render iterator
stream = StreamingRender() do io
    @render io @div begin
        @h1 "Streaming Example"

        # Render multiple sections
        for i in 1:3
            @section begin
                @h2 "Section $i"
                @p "This is paragraph $i"
            end
        end
    end
end

# Consume the stream
chunks = String[]
for chunk in stream
    push!(chunks, String(chunk))
end

println("Streamed $(length(chunks)) chunks:")
for (i, chunk) in enumerate(chunks)
    println("Chunk $i: ", repr(chunk))
end
Streamed 1 chunks:
Chunk 1: "<div><h1>Streaming Example</h1><section><h2>Section 1</h2><p>This is paragraph 1</p></section><section><h2>Section 2</h2><p>This is paragraph 2</p></section><section><h2>Section 3</h2><p>This is paragraph 3</p></section></div>"

HTTP Streaming

Each chunk is a Vector{UInt8} ready to write to a response body. HTTP.jl is not a dependency of this manual, so the example below is not executed here:

using HTTP
using HypertextTemplates, HypertextTemplates.Elements

function handle_request(req)
    return HTTP.Response(200, ["Content-Type" => "text/html"]) do io
        for chunk in StreamingRender() do render_io
            @render render_io @html begin
                @head @title "Streaming Page"
                @body begin
                    @h1 "Live Data"
                    for i in 1:1000
                        @p "Item $i"
                    end
                end
            end
        end
            write(io, chunk)
        end
    end
end

Micro-batching

A render makes thousands of small writes. Handing each one to the consumer separately would cost more than the render does, so they are batched. A write of immediate_threshold bytes or more goes over as its own chunk. Smaller ones accumulate until the batch fills, until 64 of them have arrived, or until a timer fires a millisecond after the last flush. Chunk boundaries therefore follow the batching and have nothing to do with where elements begin and end.

using HypertextTemplates
using HypertextTemplates.Elements

chunks = String[]
for chunk in StreamingRender() do io
    @render io @ul begin
        for i in 1:20
            @li "Item $i"
        end
    end
end
    push!(chunks, String(chunk))
end

println("Total chunks: ", length(chunks))
println("First chunk: ", repr(first(chunks)))
Total chunks: 2
First chunk: "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li><li>Item 4</li><li>Item 5</li><li>Item 6</li><li>Item 7</li><li>Item 8</li><li>Item 9</li><li>Item 10</li><li>Item 11</li><li>Item 12</li><li>Item 13</li><li>Item 14</li><li>Item 15</li><li>Item 16"

StreamingRender Configuration

Three keywords control the batching. chunk_size is how many bytes a batch may reach before it is flushed, clamped to 256. immediate_threshold is the write size that bypasses batching. buffer_size is how many chunks the channel holds before the render task blocks, which is what applies backpressure when a consumer reads more slowly than the render produces.

using HypertextTemplates
using HypertextTemplates.Elements

@component function sample_document()
    @div begin
        @h1 "Document Title"
        for i in 1:10
            @section begin
                @h2 "Section $i"
                @p "Content for section $i"
            end
        end
    end
end

@deftag macro sample_document end

chunks = String[]
for chunk in StreamingRender(;
    chunk_size = 128,
    immediate_threshold = 128,
    buffer_size = 8,
) do render_io
    @render render_io @sample_document
end
    push!(chunks, String(chunk))
end

println("Configured streaming produced ", length(chunks), " chunks")
Configured streaming produced 6 chunks

Stopping a Stream Early

Rendering runs in its own task, so a consumer that stops reading part way through leaves that task blocked with nowhere to put the next chunk. close releases it:

using HypertextTemplates
using HypertextTemplates.Elements

stream = StreamingRender() do io
    @render io @ul begin
        for i in 1:10_000
            @li "Item $i"
        end
    end
end

first_chunk = nothing
for chunk in stream
    global first_chunk = String(chunk)
    break
end
close(stream)

println("Read ", length(first_chunk), " bytes, then closed the stream")
Read 246 bytes, then closed the stream

Reading a stream to the end closes it for you, so close is only needed when a client disconnects or the consumer has seen enough.

Advanced Patterns

Buffered Rendering

A fragment can be rendered on its own and spliced in later, which is what caching part of a page needs. The buffer holds markup this code produced, so it goes back in as a SafeString:

using HypertextTemplates
using HypertextTemplates.Elements

@component function left_content()
    @nav begin
        @h3 "Navigation"
        @ul begin
            @li @a {href = "/"} "Home"
            @li @a {href = "/about"} "About"
            @li @a {href = "/contact"} "Contact"
        end
    end
end

@component function right_content()
    @article begin
        @h2 "Main Content"
        @p "This is the main content area."
        @p "It contains the primary information."
    end
end

@component function two_column_layout(; left_content, right_content)
    left_buffer = IOBuffer()
    right_buffer = IOBuffer()

    @render left_buffer @div {class = "column-left"} @<left_content
    @render right_buffer @div {class = "column-right"} @<right_content

    @div {class = "two-column"} begin
        @text SafeString(String(take!(left_buffer)))
        @text SafeString(String(take!(right_buffer)))
    end
end

@deftag macro two_column_layout end

html = @render @two_column_layout {left_content, right_content}

<div class="two-column">
  <div class="column-left">
    <nav>
      <h3>Navigation</h3>
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
        <li><a href="/contact">Contact</a></li>
      </ul>
    </nav>
  </div>
  <div class="column-right">
    <article>
      <h2>Main Content</h2>
      <p>This is the main content area.</p>
      <p>It contains the primary information.</p>
    </article>
  </div>
</div>

Loading Data During a Render

A component runs while the page renders. It can fetch what it needs at that point, and the caller does not have to prepare everything up front:

using HypertextTemplates
using HypertextTemplates.Elements

@component function render_data(; data)
    @div begin
        @h3 $(data.title)
        @ul begin
            for item in data.items
                @li $item
            end
        end
    end
end

@component function lazy_section(; data_loader)
    @div {class = "lazy-load"} begin
        data = data_loader()

        if isnothing(data)
            @p "No data available"
        else
            @<render_data {data}
        end
    end
end

@deftag macro lazy_section end

# Stands in for a database call
function expensive_database_query()
    return (title = "Query Results", items = ["Result 1", "Result 2", "Result 3"])
end

function empty_query()
    return nothing
end

# Example with data
html1 = @render @lazy_section {
    data_loader = () -> expensive_database_query()
}

<div class="lazy-load">
  <div>
    <h3>Query Results</h3>
    <ul>
      <li>Result 1</li>
      <li>Result 2</li>
      <li>Result 3</li>
    </ul>
  </div>
</div>

# Example without data
html2 = @render @lazy_section {
    data_loader = () -> empty_query()
}

<div class="lazy-load">
  <p>No data available</p>
</div>

Progressive Enhancement

Render basic content first, enhance later:

using HypertextTemplates
using HypertextTemplates.Elements

@component function progressive_gallery(; images)
    @div {class = "gallery"} begin
        for img in images
            @img {
                src = img.thumbnail,
                "data-full-src" := img.full_size,
                loading = "lazy",
                alt = img.alt
            }
        end

        # Enhancement script
        @script """
        // Progressively load full images
        document.querySelectorAll('[data-full-src]').forEach(img => {
            // Load full size when visible
        });
        """
    end
end

@deftag macro progressive_gallery end

# Example usage
images = [
    (thumbnail = "/thumb1.jpg", full_size = "/full1.jpg", alt = "Image 1"),
    (thumbnail = "/thumb2.jpg", full_size = "/full2.jpg", alt = "Image 2"),
    (thumbnail = "/thumb3.jpg", full_size = "/full3.jpg", alt = "Image 3")
]

html = @render @progressive_gallery {images}

<div
  class="gallery"><img src="/thumb1.jpg" data-full-src="/full1.jpg" loading="lazy" alt="Image 1"><img src="/thumb2.jpg" data-full-src="/full2.jpg" loading="lazy" alt="Image 2"><img src="/thumb3.jpg" data-full-src="/full3.jpg" loading="lazy" alt="Image 3"><script>// Progressively load full images
document.querySelectorAll('[data-full-src]').forEach(img => {
    // Load full size when visible
});
</script></div>

Best Practices

1. Choose the Right Output

  • Use IO for server responses
  • Use String for testing or caching
  • Use StreamingRender for large/slow content

2. Minimize Dynamic Content

using HypertextTemplates
using HypertextTemplates.Elements

# Good: Static structure, dynamic content
@component function good_list(; items)
    @ul {class = "static-class"} begin
        for item in items
            @li $item  # Only content is dynamic
        end
    end
end

@deftag macro good_list end

# Less optimal: Dynamic structure
function compute_class(items)
    length(items) > 5 ? "long-list" : "short-list"
end

function compute_item_class(item)
    startswith(item, "A") ? "a-item" : "other-item"
end

@component function suboptimal_list(; items)
    @ul {class = compute_class(items)} begin  # Computed every render
        for item in items
            @li {class = compute_item_class(item)} $item
        end
    end
end

@deftag macro suboptimal_list end

# Example usage
items = ["Apple", "Banana", "Cherry"]

@render @good_list {items}

<ul class="static-class">
  <li>Apple</li>
  <li>Banana</li>
  <li>Cherry</li>
</ul>

@render @suboptimal_list {items}

<ul class="short-list">
  <li class="a-item">Apple</li>
  <li class="other-item">Banana</li>
  <li class="other-item">Cherry</li>
</ul>