Server-Sent Events
Pagelove streams document mutations to connected clients over Server-Sent Events. When a document changes, every subscriber receives the mutation in real time without polling.
When to reach for it
Use SSE when a client needs to react to content changes as they happen — live dashboards, collaborative editing indicators, or cache invalidation. The wire format described here is the HTTP-level protocol. For a client-side JavaScript wrapper, see Server-Sent Events (JavaScript).
Subscribing
Open a stream by sending a GET request with Accept: text/event-stream:
% curl -si https://example.pagelove.org/pages/index.html -H 'Accept: text/event-stream'
HTTP/2 200 OK
content-type: text/event-stream
cache-control: no-cache
The connection remains open. Events arrive as they occur.
A subscribe request is subject to the same authorization rules as any other read — including on a host with no explicit rule for the resource: unlike an ordinary GET, a subscribe request is never granted by a host's default-GET mode, so a resource with no matching rule always denies it.
Mutation events
When a subscribed document is modified, the server sends a mutation event. The payload is an HTML fragment annotated with Microdata:
id: 1709942400000-0
event: mutation
data: <article itemscope itemtype="https://pagelove.org/Mutation">
data: <span itemprop="method">PUT</span>
data: <span itemprop="selector">main > h1</span>
data: <span itemprop="etag">a1b2c3...</span>
data: <span itemprop="path">/pages/index.html</span>
data: <span itemprop="host">example.com</span>
data: <div itemprop="body"><h1>Hello</h1></div>
data: </article>
Mutation event fields
| Property | Description |
|---|---|
method |
The HTTP method that caused the mutation (PUT, POST, or DELETE) |
selector |
CSS selector identifying the mutated element |
etag |
Element-level ETag of the mutated content |
path |
Document path |
host |
Virtual host |
body |
The mutated HTML fragment (empty for DELETE) |
placement |
POST and MOVE only. Where the content was inserted relative to the element selector matched: append, prepend, before, or after. Absent for other methods. |
The id field is a server-assigned event identifier used for reconnection.
Applying a POST event
A POST does not always append. The writer may have asked for a different insertion site with placement= on Range:, and the event reports the position actually used — so insert body relative to the element selector matched rather than assuming a trailing append:
append— last child of the matched elementprepend— first child of the matched elementbefore/after— preceding / following sibling of the matched element
Every POST event carries a placement; a POST that omitted placement= reports an explicit append. If placement is missing or holds a value you don't recognise, refetch the document instead of guessing a position.
Reset events
When the server cannot guarantee stream continuity, it sends a reset event:
event: reset
data: <article itemscope itemtype="https://pagelove.org/StreamReset">
data: <span itemprop="reason">events-expired</span>
data: </article>
| Reason | Meaning |
|---|---|
events-expired |
The last-known event has aged out of the server's retention window |
session-expired |
The subscriber's session has passed its expiry time |
session-invalidated |
The subscriber's session is gone, corrupt, or otherwise unusable |
A reset indicates that the client should re-fetch the full document to re-synchronize.
Reconnection
SSE clients reconnect automatically when the connection drops. On reconnection the browser sends the last received event ID via Last-Event-ID:
GET /pages/index.html HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1709942400000-0
The server replays any events that occurred after that ID. Events are retained for 10 minutes. If the ID has expired, the server sends a reset event with reason events-expired.
If your client stops reading
Each subscription has a bounded send buffer. A client that stops consuming its stream — a paused tab, a stalled reader, a connection that is open but no longer draining — fills that buffer, and the server then closes the stream rather than waiting for it.
This is not an error condition to guard against, and it is not data loss: your client reconnects as above, sends Last-Event-ID, and receives everything it missed within the retention window. From the client's point of view it is an ordinary reconnect.
The reason the server closes rather than waits is that subscribers share one delivery path. A stream held open by a client that never reads would otherwise delay delivery for every other subscriber on that server, so a client which cannot keep up is disconnected and invited to catch up through replay.
Practically, this only affects clients that stop reading for long enough to fall many events behind. Consume events as they arrive — do not block inside an event handler on a slow operation — and you will never see it.
Keepalives
The server sends SSE comment lines every 20 seconds to prevent proxies from closing idle connections:
: ping
Conformant SSE clients ignore these comments.
Echo suppression
A mutation is never delivered back to the connection that originated it — a client that writes to a document it also subscribes to does not receive its own write echoed to it (this covers both the mutation event and any paired crdt-delta).
By default this is keyed on session id. Since a browser's per-origin session cookie is shared across every tab, all same-origin tabs are treated as one unit: a write from tab A is suppressed for every tab sharing that session, not just tab A itself — so tab B would not see tab A's write live either.
To distinguish tabs, use the connection token the server assigns to every stream. The first event on each stream is pagelove-connection, and its data is an opaque token for that connection:
<script>
const source = new EventSource("/notes.html"); // plain URL — nothing to mint
let conn = null;
source.addEventListener("pagelove-connection", (evt) => {
conn = evt.data; // arrives before any mutation event
});
</script>
Send that token back as a Pagelove-Connection header on every mutating request (PUT/POST/DELETE/PATCH) from that tab.
When both the subscription and the write carry the token, suppression narrows to an exact match: the originating tab is still suppressed, but every other same-origin tab keeps receiving the mutation live. Omitting the header — including a write sent before the pagelove-connection event has arrived — falls back to the session-id behavior above, so existing clients are unaffected.
The pre-2026 legacy channels (a self-minted ?conn=<token> query parameter and the X-Pagelove-Connection write header) have been removed: a ?conn= parameter is ignored — the server always assigns the token — and the legacy header is not read, so a writer still sending it falls back to the session-id behavior above.
Composed resources
When a resource included via includes is mutated, subscribers to the parent document also receive the event. A page that includes a shared header or footer receives live updates when those fragments change.
Live example
The following demonstrates a complete SSE round-trip: subscribe to a document, mutate it, and receive the mutation event.
PUT /sse-roundtrip-test.html HTTP/2
Content-Type: text/html
<!DOCTYPE html>
<html><body><h1>Original</h1><p>Content here.</p></body></html>
SUBSCRIBE /sse-roundtrip-test.html
Accept: text/event-stream
PUT /sse-roundtrip-test.html HTTP/2
Range: selector=h1
Content-Type: text/html
<h1>Updated</h1>
HTTP/2 206 Partial Content
SSE mutation
data-contains: itemprop="method"
data-contains: PUT
data-contains: Updated
See also
-
Server-Sent Events (JavaScript) — client-side JavaScript library for subscribing to mutation streams
-
Triggers — fire actions before request processing
-
Processors — shape responses after request processing