The Basics of Server-Sent Events (SSE) with FastHTML

Intro

Server-Sent Events (SSE) are a web technology that allow servers to push real-time updates to web clients over a single HTTP connection. Unlike traditional HTTP requests where the client must repeatedly ask for new data, SSE creates a one-way channel from the server to the client that stays open, allowing the server to send updates whenever they're available.

SSE Diagram

In this blog post, I will go through some examples of how to use SSE in FastHTML. FastHTML is a new web framework for building web applications with minimal compact code, and completely in Python! It would certainly help to have run some examples of FastHTML before reading this post. Some familiarity with htmx is also useful. The htmx.org website has a great explanation of how to use the htmx Server Sent Event (SSE) extension. MonsterUI is a library of components built on top of FastHTML. It will also be used in the examples below. Both FastHTML and MonsterUI are developed by Answer AI.

I wrote this post to help myself understand SSE in FastHTML and to share what I learned. It's not meant to be a comprehensive guide. Rather, it's a collection of examples that I hope will be helpful for myself to refer back to as I continue to learn more about FastHTML and SSE and work on personal projects.

Follow Along with the Code

If you want to try some of the examples yourself, you can copy any of the examples into a file called main.py and run it with python main.py. You will need to have FastHTML and MonsterUI installed. You can install them with

pip install python-fasthtml monsterui

There is only one example that requires more than this, example 7. This example requires the Google Gen AI SDK to be installed. You can install it with

pip install google-genai

You would also need an API key which you can get here for free.

Example 1: Basic SSE Implementation

Let's look at a basic example first to see how SSE works. In this example an SSE connection is setup when the page is loaded.

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),  # monsterui styling
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),  # Include this to use the SSE extension
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("Intro SSE Example"),
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            hx_ext="sse",  # To connect to an SSE server, use the hx_ext="sse" attribute to install the extension on that HTML element
            sse_swap="EventName",  # The default event name is "message" if we don't specify it otherwise
            sse_connect="/sse-stream",  # This is the URL of the SSE endpoint we create and connect to
            hx_swap="beforeend show:bottom",  # Determines how the content will be inserted into that target element. Here, each new message is added at the end of the div and the page automatically scrolls to show the new message
            hx_target=None,  # None is the default. By not specifying a target for the swap, it defaults to the element that triggered the request i.e. id="sse-content"
            id="sse-content",
        ),
    )


async def message_generator():
    # This sse_message function converts an HTML element into the specific format required for Server-Sent Events (SSE) streaming.
    # The first argument is an FT component (FastHTML element) that you want to send via SSE.
    # The second argument is the name of the SSE event (defaults to "message" if not specified).
    # It must match the sse_swap attribute above i.e. event="EventName"

    for i in range(10):
        yield sse_message(Div(P(f"message number {i}")), event="EventName")
        await sleep(0.5)

    yield sse_message(Div(P("DONE")), event="EventName")


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

Breaking Down The Example in Details

Initial Page Load

When a user navigates to the root URL, the browser sends a GET request to the server. The server responds with the HTML generated by the index() function, which creates:

  • A container with a heading "Intro SSE Example".

  • A div with ID "sse-content" containing an introductory paragraph. This div has several HTMX attributes that configure SSE behavior.

  • The browser renders this initial HTML, showing the heading and the introductory paragraph.

SSE Connection Establishment

The browser sees the hx_ext="sse" and sse_connect="/sse-stream" attributes on the div and recognizes that it needs to establish a Server-Sent Events connection. The browser automatically opens an EventSource connection to the /sse-stream endpoint. On the server, when this connection request arrives, it triggers the sse_stream() function, which:

  • Creates a new instance of the message_generator() coroutine
  • Wraps it in an EventStream response object
  • Sends the appropriate HTTP headers to establish an SSE connection

Message Streaming Process

Once the connection is established, the message_generator() coroutine begins execution: For each iteration (0-9):

  • It creates an HTML message containing "message number {i}"
  • Converts this to SSE format with the event name "EventName"
  • Yields this message, which is immediately sent to the browser
  • Pauses for 0.5 seconds using await sleep(0.5)
  • During this pause, the server can handle other requests because of the use of async

After the 10 numbered messages, it sends a final message containing "DONE".

Client-Side Processing

As each SSE message arrives at the browser, HTMX intercepts it because of the hx_ext="sse" attribute. It checks the event name in the message ("EventName") and matches it against the sse_swap="EventName" attribute. Since they match, HTMX processes this message

The content of each message is inserted into the div according to the hx_swap="beforeend show:bottom" attribute:

  • beforeend: Each new message is added at the end of the existing content
  • show:bottom: The page automatically scrolls to show the new content

The user sees each message appear approximately every half second, with the page scrolling to keep the latest message visible.

Connection Behavior After Completion

After the final "DONE" message, the generator is exhausted, but the SSE connection doesn't automatically close. The browser's EventSource implementation will detect the end of the stream and automatically attempt to reconnect after a brief delay. This reconnection will trigger another call to sse_stream(), creating a new instance of message_generator(), and the sequence will start over. This cycle will continue indefinitely, with the div accumulating more and more messages, unless the page is navigated away from or the connection is explicitly closed.

This was a surprise to me when first learning about SSE. I expected the connection to close after the initial for loop completed.

Example 2: How to Start SSE with Button Click

Example 2a: Not quite what I wanted

Next I wanted to show how to start a SSE connection with a button click. The first thing I tried didn't do completely what I wanted. So this example shows how NOT to do it. It's still a learning opportunity worth documenting.

It's the same code as in Example 1 with the following changes:

  • Add a form around a button and put the SSE connection attributes on the button.
# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("How NOT to Start SSE with Button Click"),
        Form(
            Button(
                "Start SSE",
                hx_ext="sse",
                sse_swap="EventName",
                sse_connect="/sse-stream",
                hx_swap="beforeend show:bottom",
                hx_target="#sse-content",  # This is the target element that will receive the SSE messages
            ),
        ),
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            id="sse-content",
        ),
    )


async def message_generator():
    for i in range(10):
        yield sse_message(Div(P(f"message number {i}")), event="EventName")
        await sleep(0.5)

    yield sse_message(Div(P("DONE")), event="EventName")


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

In this example, the SSE stream starts before the button is clicked because of how the HTMX SSE extension works with the attributes on the button. The key issue is that the SSE connection attributes are directly on the button element:

Button(
    "Start SSE",
    hx_ext="sse",
    sse_swap="EventName",
    sse_connect="/sse-stream",
    hx_swap="beforeend show:bottom",
    hx_target="#sse-content",  # This is the target element that will receive the SSE messages
)

With this configuration, the SSE connection is established immediately when the page is loaded because:

  • The HTMX SSE extension is loaded (hx_ext="sse").
  • The sse_connect attribute is present.
  • When both of these conditions are met, HTMX automatically initiates the SSE connection.

This was not quite what I was going for. Clicking the button does start a new SSE connection, however my plan was to start the SSE connection only when the button is clicked, not on the initial page load. In the next example I show one way of doing this.

Example 2b: Start SSE with Button Click (Not Page Load)

The main difference in this example is that SSE connection attributes are not on the main page which loads first. Instead, when we click the button, we trigger a GET request to a new endpoint, /start-sse. This endpoint returns a div with the SSE connection attributes, hence creating the initial SSE connection. It will replace the existing div with same id sse-content. The HTMX SSE connection is established and messages are streamed to the client.

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("How to Start SSE with Button Click"),
        Form(
            Button(
                "Start SSE",
                hx_get="/start-sse",
                hx_target="#sse-content",
            ),
        ),
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            id="sse-content",
        ),
    )


async def message_generator():
    for i in range(10):
        yield sse_message(Div(P(f"message number {i}")), event="EventName")
        await sleep(0.5)

    yield sse_message(Div(P("DONE")), event="EventName")


@rt("/start-sse")
def start_sse():
    return (
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            hx_ext="sse",
            sse_swap="EventName",
            sse_connect="/sse-stream",
            hx_swap="beforeend show:bottom",
            hx_target="#sse-content",
            id="sse-content",
        ),
    )


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

Note that after the SSE connection is established, it stays open and the messages are streamed to the client indefinitely as in the previous examples.

Example 3: Closing The SSE Connection Gracefully

In some cases you may want to close the SSE connection gracefully when a specific message is received. You can use the sse_close attribute in this case. This next example is the same as Example 2b but we add a special message to close the connection. The only difference from the previous example is the two lines of code which are both commented on below.

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("How to Start SSE with Button Click"),
        Form(
            Button(
                "Start SSE",
                hx_get="/start-sse",
                hx_target="#sse-content",
            ),
        ),
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            id="sse-content",
        ),
    )


async def message_generator():
    for i in range(10):
        yield sse_message(Div(P(f"message number {i}")), event="EventName")
        await sleep(0.5)

    yield sse_message(Div(P("DONE")), event="EventName")
    yield sse_message(Div(), event="close")  # A special event message to close the connection


@rt("/start-sse")
def start_sse():
    return (
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received."),
            hx_ext="sse",
            sse_swap="EventName",
            sse_connect="/sse-stream",
            sse_close="close",  # When this event is received, the SSE connection will be closed
            hx_swap="beforeend show:bottom",
            hx_target="#sse-content",
            id="sse-content",
        ),
    )


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

Example 4: Receiving Multiple Events

You can also listen to multiple events from a single EventSource. The listeners can be either:

  • the same element that contains the hx_ext and sse_connect attributes
  • or child elements of the element containing the hx_ext and sse_connect attributes

Example 4a: Multiple Listeners on the Same Element

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("Multiple Events in the Same Element"),
        Div(
            P("The contents of this <div> will be updated in real time with each SSE message received from both event1 and event2."),
            hx_ext="sse",
            sse_swap="event1,event2",  # Multiple events can be listened to
            sse_connect="/sse-stream",
            hx_swap="beforeend show:bottom",
            hx_target="#sse-content",
            id="sse-content",
            sse_close="close",
        ),
    )


async def message_generator():
    for i in range(10):
        event_name = "event1" if i % 2 == 0 else "event2"
        yield sse_message(Div(P(f"message number {i} from {event_name}")), event=event_name)
        await sleep(0.5)

    yield sse_message(Div(P("DONE event1")), event="event1")
    yield sse_message(Div(P("DONE event2")), event="event2")
    yield sse_message(Div(), event="close")


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

Example 4b: Multiple Listeners on Child Elements

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("Multiple events in different elements (from the same source)."),
        Div(
            DivFullySpaced(
                # child elements of the element containing the hx_ext and sse-connect attributes.
                Div(P("event1"), sse_swap="event1"),
                Div(P("event2"), sse_swap="event2"),
            ),
            hx_ext="sse",
            sse_connect="/sse-stream",
            hx_swap="beforeend show:bottom",
            sse_close="close",
        ),
    )


async def message_generator():
    for i in range(10):
        event_name = "event1" if i % 2 == 0 else "event2"
        yield sse_message(Div(P(f"message number {i} from {event_name}")), event=event_name)
        await sleep(0.5)

    yield sse_message(Div(P("DONE event1")), event="event1")
    yield sse_message(Div(P("DONE event2")), event="event2")
    yield sse_message(Div(), event="close")


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


serve(port=5010)

Example 5: Trigger Server Callbacks with hx_trigger example

Example 5a: Reminder of the hx_trigger attribute in HTMX

In HTMX, the hx_trigger attribute specifies which event should trigger an HTTP request. By default, elements use their "natural" events:

  • Forms trigger on submit
  • Inputs, selects, and textareas trigger on change
  • Everything else triggers on click

However, hx_trigger allows you to override this default behavior and specify a different event or set of conditions. Here is a straight forward example showing how hx_trigger works (not with SSE).

# ruff: noqa: F403, F405
from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(Theme.blue.headers(),),
    live=True,
)


@rt("/")
def index():
    return Container(
        H3("Move the Mouse Over Me", hx_get="/get-content", hx_target="#target-div", hx_swap="beforeend", hx_trigger="mouseover"),
        Div(id="target-div"),
    )


@rt("/get-content")
def get_content():
    return P("MORE CONTENT")


serve(port=5010)

Example 5b: hx_trigger with Server-Sent Events (SSE)

When used with Server-Sent Events, hx_trigger takes on a different and powerful role. Instead of listening for browser DOM events, it listens for named events coming from the SSE stream.

For SSE events, the syntax is different. It becomes:

hx_trigger="sse:EventName"

Where EventName is the name of the event sent from the server.

  • The element with hx_trigger="sse:EventName" doesn't need to have the SSE connection itself
  • It just needs to be inside an element that has established the SSE connection (with hx_ext="sse" and sse_connect)
  • When an event with the matching name arrives through the SSE connection, it triggers the HTTP request specified by other HTMX attributes (hx_get, hx_post, etc.)
  • Instead of waiting for DOM events, it listens for messages from the server (The server decides when to trigger client-side HTTP requests).

Let's look at an example.

# ruff: noqa: F403, F405
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(Theme.blue.headers(), Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js")),
    live=True,
)


@rt("/")
def index():
    return Container(
        Div(
            H3("This Div Opens Up the SSE Connection"),
            Div(
                P("This Child Div Counts the Messages. Total Message Count: "),
                hx_get="/count-messages",
                hx_swap="innerHTML",
                hx_trigger="sse:EventName",  # The SSE connection with event name "EventName" will trigger a request hx_get="/count-messages",
            ),
            Div(P("This Child Div Receives SSE Messages"), sse_swap="EventName", hx_swap="beforeend"),
            hx_ext="sse",
            sse_connect="/sse-stream",
            sse_close="close",
        ),
    )


async def message_generator():
    global count
    count = 0
    for i in range(10):
        yield sse_message(Div(P(f"message number {i}")), event="EventName")
        await sleep(0.5)

    yield sse_message(Div(P("DONE")), event="EventName")
    yield sse_message(Div(), event="close")


@rt("/sse-stream")
async def sse_stream():
    return EventStream(message_generator())


@rt("/count-messages")
def count_messages():
    global count
    count += 1
    return Div(P(f"This Child Div Counts the Messages. Total Message Count: {count}"))


serve(port=5010)

Example 6: Example from the FastHTML Documentation - Random Number Generator

As of writing this blog post, this is an example straight from the FastHTML Documentation. It is essentially copy and pasted. The reason I did not start with this example is because this example always confused me a little bit when first encountering it. I was confused by the role of the while loop and why messages kept coming through, even if the while loop was removed. However, after doing some reading and working through the above examples, it all makes sense to me now.

Example 6a: No While Loop

In this first example I remove the while loop and the shutdown_event = signal_shutdown(). Remember, that even without the while loop, the numbers will keep coming. This is because the SSE connection automatically attempts to reconnect after a brief delay. This means the endpoint "/number-stream" will keep being called and the numbers will keep coming.

# ruff: noqa: F403, F405
import random
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt
def index():
    return Titled(
        "SSE Random Number Generator",
        P("Generate pairs of random numbers, as the list grows scroll downwards."),
        Div(hx_ext="sse", sse_connect="/number-stream", hx_swap="beforeend show:bottom", sse_swap="message"),
    )


async def number_generator():
    data = Article(random.randint(1, 100))
    yield sse_message(data)
    await sleep(0.01)


@rt("/number-stream")
async def get():
    return EventStream(number_generator())


serve(port=5010)

Example 6b: With While Loop

In this example I add the while loop and the shutdown_event = signal_shutdown() back in. This means the endpoint "/number-stream" will only be called once, but the numbers will keep coming much faster than before.

The while not shutdown_event.is_set(): loop will continue generating random numbers and sending them as SSE messages until the server starts shutting down. When a shutdown signal is received, the event is set, the loop exits, and the generator stops producing new values.

# ruff: noqa: F403, F405
import random
from asyncio import sleep

from fasthtml.common import *
from monsterui.all import *

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@rt
def index():
    return Titled(
        "SSE Random Number Generator",
        P("Generate pairs of random numbers, as the list grows scroll downwards."),
        Div(hx_ext="sse", sse_connect="/number-stream", hx_swap="beforeend show:bottom", sse_swap="message"),
    )


shutdown_event = signal_shutdown()


async def number_generator():
    while not shutdown_event.is_set():
        data = Article(random.randint(1, 100))
        yield sse_message(data)
        await sleep(0.5)


@rt("/number-stream")
async def get():
    return EventStream(number_generator())


serve(port=5010)

Example 7: Streaming Text from an LLM and Thoughts on Rendering Markdown

This example could be useful in implementing a chat bot. We will not build a back and forth chat bot here, but rather a one way chat where the user types in a a single message and the LLM responds with a stream of text. We will make use of Google's Gemini Flash 2.0 model via the Google Gen AI SDK

The idea I want to focus on here is how to render the markdown from the LLM response as it streams in. As of writing this blog post, I still don't have a solid solution that I think is efficient.

Example 7a: Streaming Text from an LLM With No Rendering

This example is nice because it's efficient. The LLM response is streamed as the chunks of text become available. The issue with it is that the markdown is not rendered. It just shows up as plain text.

# ruff: noqa: F403, F405
import os
from asyncio import sleep
from urllib.parse import quote, unquote

from dotenv import load_dotenv
from fasthtml.common import *
from google import genai
from monsterui.all import *

load_dotenv()

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@app.route("/")
def get():
    return Container(
        H1("Streamed Chat"),
        Form(
            TextArea(
                type="text",
                name="msg",
                placeholder="Type a message",
            ),
            Button("Send"),
            hx_post="/send-message",
            hx_target="#chat-response",
            hx_swap="innerHTML",
        ),
        Div(
            id="chat-response",
        ),
    )


@app.post("/send-message")
def send_message(msg: str):
    msg = quote(msg)
    assistant_msg = Div(
        hx_ext="sse",
        sse_connect="/get-message?msg=" + msg,
        sse_swap="EventName",
        sse_close="close",
        hx_swap="beforeend show:bottom",
    )

    return assistant_msg


async def message_generator(msg: str):
    final_message = ""
    msg = unquote(msg)
    client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

    for chunk in client.models.generate_content_stream(model="gemini-2.0-flash-001", contents=msg):
        chunk = chunk.text
        final_message += chunk
        yield sse_message(chunk, event="EventName")
        await sleep(0.025)
    yield sse_message(Div(), event="close")


@app.get("/get-message")
async def get_message(msg: str):
    return EventStream(message_generator(msg))


serve(port=5010)

Example 7b: Streaming Text from an LLM With Rendering (Not Efficient)

You may think it's as simple as the small change

  • yield sse_message(render_md(chunk) event="EventName")

The issue with this is that the markdown is rendered for each chunk of text, and a chunk may not be complete markdown. For example a chunk may be the text **Hello and the next chunk may be world**. So the markdown will not render correctly. It will all be a jumble.

One solution that does work, but is not efficient, is to take example 7a and change the following two things:

  • yield sse_message(render_md(final_message), event="EventName")
  • change the hx_swap="beforeend show:bottom" to hx_swap="innerHTML" in the function send_message.

This works, but is not efficient because instead of streaming one smaller chunk at a time, it streams the entire full message every time. And the full_message just keeps getting longer and longer as we add more and more chunks to it.

# ruff: noqa: F403, F405
import os
from asyncio import sleep
from urllib.parse import quote, unquote

from dotenv import load_dotenv
from fasthtml.common import *
from google import genai
from monsterui.all import *

load_dotenv()

app, rt = fast_app(
    hdrs=(
        Theme.blue.headers(highlightjs=True),
        Script(src="https://unpkg.com/htmx-ext-sse@2.2.1/sse.js"),
    ),
    live=True,
)


@app.route("/")
def get():
    return Container(
        H1("Streamed Chat"),
        Form(
            TextArea(
                type="text",
                name="msg",
                placeholder="Type a message",
            ),
            Button("Send"),
            hx_post="/send-message",
            hx_target="#chat-response",
            hx_swap="innerHTML",
        ),
        Div(
            id="chat-response",
        ),
    )


@app.post("/send-message")
def send_message(msg: str):
    msg = quote(msg)
    assistant_msg = Div(
        hx_ext="sse",
        sse_connect="/get-message?msg=" + msg,
        sse_swap="EventName",
        sse_close="close",
        hx_swap="innerHTML",
    )

    return assistant_msg


async def message_generator(msg: str):
    final_message = ""
    msg = unquote(msg)
    client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))

    for chunk in client.models.generate_content_stream(model="gemini-2.0-flash-001", contents=msg):
        chunk = chunk.text
        final_message += chunk
        yield sse_message(render_md(final_message), event="EventName")
        await sleep(0.025)
    yield sse_message(Div(), event="close")


@app.get("/get-message")
async def get_message(msg: str):
    return EventStream(message_generator(msg))


serve(port=5010)

I would like to spend more time to figure out a more efficient solution. You can see there is also a bit of "jankiness" each time formulas and code blocks are rerendered. My goal would be to mimick what OpenAI and Anthropic do with streaming, and only have small chunks of text in the message come through the SSE connection. All while rendering the markdown correctly in real time and having everything look nice. Maybe I will put some more thought into this and update the blog post with a more efficient solution in the near future.

Conclusion and Recap of Main Attributes for Server Sent Events in FastHTML

Here's a quick summary of what we learned:

  • hx_ext="sse" - This is required to use the SSE extension.
  • sse_connect="<url>" - The URL of the SSE endpoint
  • sse_swap="<message-name>" - The name of the message to swap into the DOM.
  • sse_swap="event_name1,event_name2" - To listen for multiple message types on a single element.
  • hx_trigger="sse:<message-name>" - SSE messages can also trigger HTTP callbacks using the hx_trigger attribute.
  • sse_close=<message-name> - To close the EventStream gracefully when that message is received. This might be helpful if you want to send information to a client that will eventually stop.
  • hx_swap="beforeend show:bottom" - While not specific to SSE, this is commonly used with SSE to control how content is inserted (append at the end) and handled (scroll to show the new content).
  • hx_target="#element-id" - Specifies where the SSE message content should be inserted (if not the element itself).
  • On the server side, remember the key function: sse_message(element, event="message") - Converts an FT component into the format required for SSE, with an optional event name (defaults to "message").