Live collaborative editing by paragraph, on shared hosting
Several people can open the same board (a shared Lanna document) and write at once, and see each other's changes within a couple of seconds. It runs on ordinary shared hosting: no websockets, no server-sent events, no long-running process, only short PHP requests and a database.
This page explains the design and the trade-offs, including what it deliberately does not do yet.
Constraints
- Shared hosting kills long-lived connections and offers no process manager, so there is nowhere to run a websocket server.
- Visitors do not sign in. A board can be public, shared by a link, and edited by strangers.
- The text is mixed-script and edited by a custom editor with an IME-sensitive caret (see the Jaan Editor case study). Anything that rewrites the whole document under the user's caret is unacceptable.
The unit of collaboration is the paragraph
Instead of syncing characters, the system syncs paragraphs. Every paragraph in a document gets a stable id the first time it is seen, and the id survives edits, splits and merges of neighbours. A document is then an ordered list of (id, html) pairs plus a version number.
doc = { version: 41,
paras: [ {id: "p7", html: "..."}, {id: "p9", html: "..."} ] }
Paragraph granularity is a deliberate middle path. It is far simpler than character-level merging, and it matches how people actually write: usually one person is working in one paragraph at a time.
Presence and paragraph locks
While you edit, the client tells the server which paragraph holds your caret. The server keeps a small presence record per person (display name, colour, last seen, current paragraph) and grants a lock on that paragraph:
- Other people see a coloured bar and your name beside the paragraph, and it is read-only for them.
- The lock has a short expiry and is refreshed while you type. If you close the tab, lose signal or walk away, it simply times out. Nobody can leave a paragraph locked forever.
- Leaving the paragraph releases the lock at once.
Locks are advisory in the interface and enforced on the server. A client that ignores them still cannot overwrite a locked paragraph.
Polling that is polite
Without a push channel, the client asks. The rules keep the load low:
- Poll only while editing. A reader looking at a board does not poll fast. At most a slow check asks "has this changed?" and shows a "new edits, load" notice instead of changing the text under them.
- Adaptive back-off. The interval starts at about two seconds and grows when nothing happens, then snaps back as soon as there is activity.
- Tiny replies. If nothing changed since the version the client already has, the answer is a few bytes: "same".
- Send only what changed. The client sends only the paragraphs it touched, and the reply contains only the paragraphs that changed.
every ~2s (backing off when idle), while editing:
send { since: myVersion, dirty: [changed paragraphs], caret: pid }
reply { same: true } // most of the time
or { version: n, changed: [...], presence: [...], locks: [...] }
Applying someone else's change must not disturb you. Remote paragraphs are swapped in place, your caret and IME composition are never touched, and a paragraph you are typing in is never replaced.
There is also a cap on simultaneous live editors per board. Past it, new arrivals fall back to the simple save-and-compare flow described next.
Writes are applied against a version, and conflicts lose nothing
The server receives paragraph-level operations tagged with the version they were based on, and decides:
- If the paragraph is untouched since that version and not locked by someone else, the write is applied and the version number moves on.
- If it is stale (someone else changed it) or locked (someone else holds it), the write is rejected, and the rejected text is kept as a side version so it can be recovered later.
apply(op, baseVersion):
p = paragraph(op.id)
if p.lockedByOther or p.changedSince(baseVersion):
keepSideVersion(op.text, author) // the loser's text is never dropped
return reject(currentVersion)
p.html = op.html; version += 1
The client shows a calm message with the choice to see the latest text or to overwrite it deliberately. "Never silently lose what someone wrote" is the rule that all other choices serve.
Versions and history
Every save creates a version. To keep the history readable, saves by the same person within a short window are merged into one entry, so a long editing session is not a thousand rows. Two versions are special:
- The original (the first creation of the document) is always kept and can be restored.
- A side version from a rejected write is kept like any other, so conflicts are recoverable.
History shows who (as a name and colour), when, and a restore button. Restoring creates a new version rather than rewriting the past.
Anonymous identities
Most contributors are not signed in. So identity is light:
- A random visitor id is created in the browser. It is used to tell people apart and to attribute edits, never to find out who someone is.
- The visitor may choose a display name. If they do not, they get a friendly nickname from the dictionary: the northern Thai (คำเมือง) names of local fruit such as mango, longan and jackfruit, taken from the dictionary and shown with a colour and an emoji. It makes a crowd of anonymous editors readable ("a Lanna fruit is editing paragraph 3") and keeps the dictionary visible in the product. Signed-in members keep their full account name in history.
- Boards can be public, shared by link, or protected by an optional PIN for editing. Details of that protection are intentionally not described here.
Why not a CRDT?
Conflict-free replicated data types (CRDTs) and operational transforms give character-level merging, the Google Docs experience. They are the right answer for the long run, but not for this milestone:
- They need a persistent connection and state on the server, which this hosting does not provide.
- They do not fit well with a contenteditable that mixes atomic dictionary words and editable runs.
- Paragraph locks plus versions solve the common case (people editing different parts) with a fraction of the complexity, and fail safely.
A CRDT is a future milestone, likely alongside a host that can keep connections open. The paragraph id, the version number and the side-version model are designed so that a later move does not throw away existing documents.
What is not verified
Live editing has been tested with scripted multi-user runs (several simulated editors, locks, conflicts, expiry, fallback) and with a real browser, but not yet with large groups on poor mobile networks. That is on the list.
Back to Overview · Previous: Jaan Editor · Next: AI