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).

Lifecycle of one local edit State diagram of an edit moving from pending, through sent, to confirmed when its operation id appears on the stream, or to rebasing on a conflict response and back to sent. Lifecycle of one local edit PENDING applied locally SENT POST in flight CONFIRMED seen on stream REBASING 409, reapply POST op id on stream network error, retry 409 conflict resend rebased
The stream, not the POST response, is what confirms an edit. That way the author learns the edit's global position the same way everyone else does.

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.

The author's edit, confirmed through the stream Sequence diagram in which Ana applies an edit locally, posts it, Ben's edit is sequenced first, Ana's edit is sequenced second, and both clients receive both ops in the same order. The author's edit, confirmed through the stream Ana Server Ben apply locally (pending) POST op b base 206 POST op a base 206 op b seq 207 op b seq 207 op a seq 208 (pending cleared) op a seq 208
Ana saw her edit instantly and Ben's edit slid in underneath it. Both clients end with ops 207 and 208 in the same order.

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.

Divergent documents in a 1,000-run randomised editing test Bar chart of runs ending with at least one client diverged from the server, for three implementations: no operation ids, operation ids without rebase, and the full protocol. Divergent documents in a 1,000-run randomised editing test No op ids 412 Op ids, no rebase 57 Op ids + rebase 0 runs out of 1,000 where a client's final document differed from the server's
Operation ids remove duplicates and losses; rebasing removes the remaining divergence from concurrent edits to the same field.

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.