# Realtime

*5 August 2026*

New replies, new topics and new messages appearing without a refresh, and a
line that says somebody is writing one.

## The constraint that decides everything

Convoro is a PHP forum you can put on ordinary hosting. That is not a
limitation to work around, it is the reason to build it: a community that has
to rent a VPS and supervise a daemon to see a typing indicator will not have a
typing indicator. So the question is not "what is the best realtime transport"
— it is "what is the best realtime transport that survives a £5 shared host,
and can be replaced by a better one later without rewriting the client".

Three candidates.

**WebSockets.** The right answer at scale and the wrong one here. It needs a
long-running process — Ratchet, Workerman, or an external server like
Centrifugo — plus a supervisor, plus an nginx upgrade route, plus a second
thing to be down at 3am. On the host this forum is being developed against
there are already twelve services; adding a thirteenth for typing indicators is
not a trade an operator would make, and on shared hosting it is not a trade
they are offered.

**Server-sent events.** The tempting one, because it is one HTTP response and
no dependencies. It is unusable on PHP-FPM, and the arithmetic is not close.
An SSE connection holds a worker for as long as it is open. This machine's pool
is `pm.max_children = 20`; typical shared hosting is 5 to 10. Twenty people
reading a thread with SSE open take every worker in the pool, and the site
stops serving anything at all — not slowly, entirely, including the page that
would tell you why. The failure mode of SSE on FPM is a hard outage triggered
by ordinary success.

**Polling.** Unfashionable, and correct here. A request that asks "anything new
since X" costs one indexed query and returns a few hundred bytes. Twenty
readers on a five-second interval is four requests a second, which is not a
number worth discussing. It works on every host, needs nothing installed, and
degrades by getting slower rather than by falling over.

So: polling. Not "polling for now, until we do it properly" — polling, done
properly, which is a different and better thing than the polling people mean
when they say it apologetically.

**What is left open.** The client never learns how the data reached it. It
subscribes to channels, sends back cursors, and receives a payload. A push
transport delivering that same payload is a change to one file and no change
to the contract. There is deliberately no `RealtimeDriver` interface today —
writing an abstraction for an implementation that does not exist produces an
abstraction shaped like nothing. The payload is the seam, and it is enough.

## Channels and cursors

A channel is a string naming something you can watch:

```
topic.412     a thread
forum.3       a forum's topic list
pm.87         a conversation
site          new topics anywhere (the activity stream, the index)
```

One endpoint, one round trip, however many channels the page needs:

```
GET /realtime?topic.412=c:8891&pm.87=c:340
```

**The cursor is opaque and the server issues it.** The client sends back
whatever it was last given and never constructs one. This is not ceremony: a
client-generated timestamp cursor is wrong the moment a phone's clock is a few
seconds fast, and the symptom is a reply that never appears for one person and
nobody can reproduce. An opaque server cursor makes clock skew structurally
impossible rather than unlikely.

## What comes back

Rendered HTML, from the same templates that render the page.

The alternative — send JSON and build the post in JavaScript — means the markup
of a post exists twice, in two languages, and the second copy is the one that
drifts. Convoro has no front-end framework and is not getting one to solve a
problem it can avoid. The server renders `partials/post`, the client inserts it.
One source of truth for what a post looks like.

```json
{
  "cursor":  { "topic.412": "c:8894" },
  "topic.412": {
    "posts":  [ { "id": 8892, "html": "<div class=\"post\" …>" } ],
    "typing": [ "Ernest" ]
  },
  "next": 5000
}
```

`next` is the server telling the client when to come back. It is there so that
an operator whose site is under load can slow every connected browser down from
one setting, without shipping anything, and without waiting for people to close
their tabs. A client that ignores it is a client that turns a busy morning into
an outage.

## Typing

Ephemeral, so it does not go in the database.

A key per channel in the cache, holding who is typing and when that claim
expires. The composer sends a heartbeat every three seconds while there is
something in it; the claim lives eight. Nothing has to clean up: a browser that
crashes, a laptop lid that closes, a person who wanders off — all of them stop
typing on their own, because the claim was always going to expire and nothing
was going to renew it.

Written to the database instead, it would need a row per person per channel, a
write every three seconds per person composing, and a cron job to sweep the
rows left behind by every browser that ever closed mid-sentence. The reason to
use a cache here is not that it is faster. It is that expiry is the correct
semantics, and a TTL is what expiry looks like.

Two rules about what it says:

**It names people, and only while they are actually writing.** "Someone is
typing" is worth nothing. Three or more becomes "Ernest, Sam and 2 others",
because the point is recognition, not a roll call.

**Your own typing is never shown back to you**, and a heartbeat stops the
moment the composer is empty — deleting what you wrote is a decision not to
say it, and the indicator should not still be telling the room you are about
to.

## The page does not move

This is the part that is a product decision rather than a mechanism, and it is
the part most likely to be got wrong.

**New replies do not insert themselves into a page somebody is reading.** They
arrive as a bar: *3 new replies — show*. Text moving under a thumb mid-sentence
is worse than not having realtime at all, and on a phone it is how you end up
tapping something you never saw.

**Unless you are already at the bottom.** Somebody sitting at the end of a live
thread is watching it happen, and making them press a button for each reply is
pedantry. So: at the bottom, replies append; anywhere else, a bar.

**New topics never insert themselves.** A list you are scanning must not
reorder. The bar says *2 new topics* and waits.

**The bar is polite when the tab is not.** Nothing animates and nothing makes a
sound. The title gets a count, because that is what a background tab is for.

## When it polls, and when it stops

Adaptive, because a fixed interval is either wasteful or slow and is usually
both at different times of day.

| Situation | Interval |
|---|---|
| Tab hidden | **stopped** |
| Composer open | 3s |
| Channel busy (something in the last 2 min) | 5s |
| Quiet 2–10 min | 15s |
| Quiet over 10 min | 30s |
| Server said otherwise | whatever `next` says |

Stopping on a hidden tab is the single largest saving available and costs
nothing: `visibilitychange` is in every browser Convoro supports, and a tab
nobody is looking at has no realtime requirement. A person with nine forum tabs
open — which is a normal number of forum tabs — generates traffic for the one
they are reading.

**Failure backs off and then gives up.** Three consecutive errors doubles the
interval; after ten minutes of failure it stops and leaves a quiet note that
live updates have paused. A site having a bad minute must not be held down by
every browser that was connected to it when it started.

## Cost

The endpoint has one job and must be cheap enough to be boring.

- **No layout, no widgets, no sidebar.** It renders one partial per new post
  and nothing else.
- **Every query is a covered index lookup on `id > cursor`.** No counts, no
  joins that are not needed for the partial, no `ORDER BY created_at` where an
  id will do.
- **Nothing is written on a poll** except the typing key, and only when the
  client says it is typing.
- **A poll is capped.** At most 20 new posts come back at once; past that the
  client is told to reload, because somebody who left a tab open over a weekend
  should not be served a thousand rendered posts in one response.

## Permissions

The poll endpoint answers with exactly what the page would have shown, checked
the same way — a channel is subscribable only if the member can read the thing
it names, re-checked on every poll rather than trusted from when the page
loaded. Permissions change while tabs are open. A moderator who moves a thread
into a private forum has to be able to expect that the people who were reading
it stop receiving it, and "they had it open already" is not an exception.

Private conversations are the sharp case: `pm.87` is subscribable only by a
current participant, and somebody removed from a conversation stops receiving
it on their next poll, within seconds, without being logged out of anything.

## Switching it off

One setting, on by default, and off means off — no polling at all, not a slower
poll. An operator on a host that charges by the request is entitled to a forum
that makes one request per page view, and that is a legitimate way to run a
community rather than a degraded one.

## Deliberately not doing

**Read receipts and "seen by".** Different feature, different consent
conversation, and one people have opinions about. Not smuggled in behind a
typing indicator.

**Live reaction counts and live view counts.** Numbers ticking in the corner of
the eye while you read is a cost with no reader benefit. The data is already
there if that turns out to be wrong.

**Live editing and presence cursors.** A forum is not a document editor.

**Notifications by push.** The bell already updates on the poll it is already
making. Web Push is a separate piece of work with a service worker, a
subscription store and a permission prompt, and it does not belong inside this
one.
