Real-time "is typing" (SSE companion)
A long-lived Server-Sent Events stream delivers presence/typing/push events. HTMX swaps handle everything else.
#When to use
Push-shaped data: presence dots, chat typing indicators, live dashboards, collaborative cursors. Don't reach for WebSocket unless you need bidirectional binary or sub-100ms latency — SSE is simpler and plays nicely with HTTP middleware.
#Stream endpoint
public async Task OnGet(CancellationToken ct)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache");
await foreach (var evt in _presence.StreamAsync(ct))
{
await Response.WriteAsync($"event: {evt.Kind}\ndata: {evt.Json}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
}
#Client (option A — htmx SSE extension)
Add the htmx SSE extension once in your layout:
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
Then declare the connection + the swap targets in markup:
<div hx-ext="sse" sse-connect="/chat/@Model.RoomId/events" sse-swap="typing">
<span id="typing_indicator" aria-live="polite"></span>
</div>
The server emits event: typing followed by an HTML data payload, and htmx swaps it into the [sse-swap="typing"] element automatically.
#Client (option B — vanilla EventSource for non-HTML payloads)
<span id="typing_indicator" aria-live="polite"></span>
<script>
const sse = new EventSource('/chat/@Model.RoomId/events');
sse.addEventListener('typing', (e) => {
const { user } = JSON.parse(e.data);
document.getElementById('typing_indicator').textContent = `${user} is typing…`;
});
sse.addEventListener('typing-stop', () => {
document.getElementById('typing_indicator').textContent = '';
});
</script>
#Sending the "I'm typing" signal
Pure Alpine debounce on the input — no SSE on the way out, just a plain HTMX POST:
<input type="text" name="message"
hx-post="/chat/@Model.RoomId/typing"
hx-trigger="input changed delay:800ms"
hx-swap="none" />
#Hybrid with HTMX
SSE handles ephemeral signals (typing, online). HTMX swaps handle durable actions (send message, mark read). That division keeps each channel doing what it's best at.
#Reconnection
Browsers automatically reconnect a closed EventSource after ~3 seconds. Include Last-Event-ID handling on the server if you need exactly-once delivery across reconnects.
#Related
- Polling for progress — the pull-based alternative when you don't control the server.