Pairing SSE with POST for Collaborative Edits Permalink to this section
Part of Collaborative Presence & Live Updates, under Real-Time Application Patterns.
“SSE is one-way, so it cannot do collaboration” is a common objection, and it misreads the problem. Collaboration needs a bidirectional application, not a bidirectional socket. Edits go up as ordinary POST requests; everyone’s confirmed edits come down one ordered Server-Sent Events stream. This guide builds that loop: optimistic local edits with client-generated operation ids, a server that assigns a single order, confirmation arriving through the stream, and rebasing when someone else’s edit lands first.
Symptom & Developer Intent Permalink to this section
Teams attempting this split without a clear protocol hit a predictable set of bugs:
- The user’s own edit appears twice: once locally, once again when it comes back on the stream.
- Two users type into the same field and each ends up seeing their own value, permanently.
- An edit disappears when the network drops at the moment it was sent.
- Edits from the same user arrive at other clients out of order.
- After a reconnect, the document is missing edits that were made during the gap.
The intent is that every client converges on the same document, the author sees their edit instantly, and nothing is duplicated or lost across retries and reconnects.
Root Cause Analysis Permalink to this section
Two orders are in play. Local order is the order in which this user made their edits. Global order is the order in which the server accepted edits from everyone. Clients must display global order, and they must display their own unconfirmed edits on top of it. Most bugs come from confusing the two: applying the stream’s copy of your own edit as if it were new (duplicate), or never reconciling local edits with the global order (divergence).
A lost edit comes from not making the POST idempotent. If the request times out, the client cannot know whether the server applied it. Retrying without an idempotency key risks applying it twice; not retrying risks losing it. A client-generated operation id solves both: the server ignores a second copy of an id it already applied.
Step-by-Step Resolution Permalink to this section
Step 1 — Give every local edit an operation id and a base sequence Permalink to this section
// The client owns opId (for idempotency) and records the last global seq it had seen.
function makeOp(change) {
return { opId: crypto.randomUUID(), baseSeq: doc.seq, ...change };
}
Step 2 — Apply locally, keep a pending queue, send in order Permalink to this section
const pending = []; // local ops not yet seen on the stream
function edit(change) {
const op = makeOp(change);
pending.push(op);
render(applyAll(doc.state, pending)); // author sees the edit immediately
sendNext();
}
let sending = false;
async function sendNext() {
if (sending || !pending.length) return;
const op = pending.find((p) => !p.sent);
if (!op) return;
sending = true;
try {
const res = await fetch(`/api/docs/${docId}/ops`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': op.opId },
body: JSON.stringify(op),
});
if (res.status === 409) await rebase(op, await res.json());
else op.sent = true;
} catch {
setTimeout(sendNext, 1000); // network error: retry the same opId later
} finally {
sending = false;
}
sendNext();
}
Sending one operation at a time preserves the user’s local order on the server without any extra sequencing.
Step 3 — Assign global order once, idempotently Permalink to this section
app.post('/api/docs/:id/ops', requireDocAccess, async (req, res) => {
const op = req.body;
const existing = await ops.byOpId(req.params.id, op.opId);
if (existing) return res.status(202).json({ seq: existing.seq }); // retry of an applied op
const conflict = await detectConflict(req.params.id, op); // field changed after baseSeq?
if (conflict) return res.status(409).json(conflict);
const seq = await ops.append(req.params.id, { ...op, user: req.user.id }); // single authority
await bus.publish(`doc:${req.params.id}`, JSON.stringify({ type: 'op', seq, ...op, user: req.user.id }));
res.status(202).json({ seq });
});
Step 4 — Confirm from the stream, not from the response Permalink to this section
es.addEventListener('op', (e) => {
const op = JSON.parse(e.data);
if (op.seq <= doc.seq) return; // already applied (replay overlap)
doc.state = applyOp(doc.state, op);
doc.seq = op.seq;
const i = pending.findIndex((p) => p.opId === op.opId);
if (i >= 0) pending.splice(i, 1); // our own edit: confirmed, not duplicated
render(applyAll(doc.state, pending)); // re-apply remaining local edits on top
});
The render is always “confirmed global state, then my pending edits on top”. An incoming remote edit therefore appears underneath the author’s unconfirmed typing, exactly as it will after confirmation.
Step 5 — Rebase on conflict Permalink to this section
When the server rejects an operation because the field changed after its baseSeq, the client already has, or will shortly receive, the winning operation on the stream. Rebasing means recomputing the pending operation against the new state, or asking the user:
async function rebase(op, conflict) {
await waitForSeq(conflict.current); // make sure the winning op is applied
const next = transformOrAsk(op, doc.state); // app-specific: merge, overwrite or prompt
if (!next) { pending.splice(pending.indexOf(op), 1); return; } // user chose theirs
Object.assign(op, next, { baseSeq: doc.seq, sent: false });
}
For text, use an operational-transformation or CRDT library to do the transform; for structured fields, “show both values and let the user choose” is often the best product experience.
Step 6 — Resume after reconnect from the stream cursor Permalink to this section
Operation events carry id: <seq>, so the browser reconnects with Last-Event-ID set to the last applied operation and the server replays everything after it. Pending local edits are unaffected: they are still in the queue and still retried with the same opId, so an edit sent just before the drop is either already applied (and will arrive in the replay, clearing it) or will be sent again safely.
Validation & Monitoring Permalink to this section
# Idempotency: send the same op twice, expect one sequence number.
op='{"opId":"8d1c…","baseSeq":206,"field":"title","value":"Q3 plan"}'
curl -s -b s.txt -X POST -H 'Content-Type: application/json' -d "$op" https://app.example.com/api/docs/42/ops
curl -s -b s.txt -X POST -H 'Content-Type: application/json' -d "$op" https://app.example.com/api/docs/42/ops
# {"seq":207} twice
# Conflict: a stale baseSeq on a changed field must return 409.
curl -s -o /dev/null -w '%{http_code}\n' -b s.txt -X POST -H 'Content-Type: application/json' \
-d '{"opId":"f00…","baseSeq":100,"field":"title","value":"Old"}' https://app.example.com/api/docs/42/ops
A convergence test is the strongest check: script two or three clients making random edits to the same document concurrently, with random network delays and disconnections, and assert that every client’s final state is identical to the server’s.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why not confirm edits from the POST response?
The response tells the author their sequence number, but other edits may be sequenced before it arrives on the stream. Confirming from the stream means the author applies their edit at its true global position, the same way every other client does.
Is HTTP overhead per edit a problem?
For typing-speed edits over HTTP/2, no: requests share one connection and headers are compressed. Batch keystrokes into one operation every 100 to 200 milliseconds to keep request rates modest.
What if the stream is slower than the POST?
The pending queue covers it. The author keeps seeing their edit locally until it arrives on the stream, however long that takes, so the interface never flickers.
What should happen to pending edits when the user closes the tab?
Flush them with fetch keepalive or sendBeacon on pagehide, using the same op ids. If they do not arrive, the edits are lost, so for long-form editing also persist the pending queue to IndexedDB and resend it on the next visit; the op ids make the resend safe.
Do I need operational transformation?
For concurrent text editing within the same paragraph, yes, or a CRDT. For structured documents where edits target separate fields, per-field conflict detection with a 409 and a rebase is enough.