Collaborative Editing (OT vs CRDT) — Realtime

Collaborative Editing Google Docs, Figma, Notion — multiple users edit dokumen yang sama secara bersamaan. Dua pendekatan utama: OT dan CRDT. Problem…

Collaborative editing artinya beberapa orang mengubah teks yang sama dari tempat berbeda, dan semua layar akhirnya menampilkan teks yang sama tanpa ada ketikan yang hilang. Di Laundry Bu Rini, tiap order punya kolom catatan order. Bu Rini mengisinya di meja kasir, Mas Eko di tablet dekat mesin cuci. Bayangin mereka masing-masing memegang fotokopi catatan yang sama, menulis di fotokopinya sendiri, lalu saling mengirim apa yang mereka tulis. Masalahnya: "huruf ke-14" di fotokopi Bu Rini bisa jadi huruf ke-16 di fotokopi Mas Eko. CRDT menyelesaikannya dengan memberi nama tetap ke setiap huruf, jadi orang bilang "sesudah huruf 14@rini", bukan "di posisi 14".

🧪 Tebakan cepat: Catatan order #12 isinya kemeja dipisah. Di detik yang sama, Bu Rini menambahkan , jangan pakai pewangi di akhir, dan Mas Eko menambahkan 2 di depan. Server menyimpan teks utuh yang terakhir datang. Apa isi catatan setelahnya? Jawabannya ada di bagian "Last-write-wins: satu ketikan hilang".

Last-write-wins: satu ketikan hilang

Cara paling gampang: setiap kali selesai mengetik, kirim seluruh isi kolom, dan server menyimpan yang terakhir datang (last-write-wins, disingkat LWW).

// browser
input.addEventListener('change', () => socket.emit('catatan:simpan', { id: 12, teks: input.value }))
// server
socket.on('catatan:simpan', ({ id, teks }) => { catatan.set(id, teks); io.to('staf').emit('catatan', { id, teks }) })
Jam Siapa Kirim teks utuh Isi di server
09.41.02 Bu Rini kemeja dipisah, jangan pakai pewangi versi Bu Rini
09.41.03 Mas Eko 2 kemeja dipisah versi Mas Eko

Hasilnya 2 kemeja dipisah. Itu jawaban tebakannya: tulisan Bu Rini hilang tanpa pesan error, dan cucian Pak Darto bisa dicuci pakai pewangi. LWW cuma aman untuk nilai yang memang diganti utuh, misalnya nomor HP atau status order.

Kirim operasi, bukan teks utuh

Lebih hemat dan gak menimpa: kirim operasi (apa yang berubah). Bu Rini mengirim sisip(14, ", jangan pakai pewangi"), Mas Eko mengirim sisip(0, "2 "). Tapi kalau tiap layar menerapkan posisi itu apa adanya:

Layar Sebelum Menerapkan Hasil
Bu Rini kemeja dipisah, jangan pakai pewangi sisip(0, "2 ") 2 kemeja dipisah, jangan pakai pewangi
Mas Eko 2 kemeja dipisah sisip(14, ", jangan ...") 2 kemeja dipis, jangan pakai pewangiah

Di tablet Mas Eko, posisi 14 sudah bergeser 2 huruf karena 2 di depan. Dua layar berbeda, dan salah satunya rusak.

OT: server menggeser posisinya

Operational Transformation (OT) memperbaiki posisi sebelum menerapkan. Server pusat memutuskan urutan: sisip(0, "2 ") duluan. Operasi Bu Rini yang datang belakangan ditransformasi: "ada 2 huruf disisipkan sebelum posisi 14, jadi geser ke 16", menjadi sisip(16, ...). Google Docs memakai pendekatan ini. Kelemahannya: butuh server pusat yang mengurutkan semua operasi, dan fungsi transform untuk setiap pasangan operasi (sisip lawan hapus, hapus lawan hapus) terkenal sulit dibuat benar. OT tanpa server pusat ada di makalah, tapi dalam praktik hampir selalu ada server di tengahnya.

CRDT: tiap huruf punya nama

CRDT (Conflict-free Replicated Data Type) adalah struktur data yang setiap salinannya boleh diubah sendiri-sendiri, lalu digabung dalam urutan apa pun, dan hasil akhirnya pasti sama di semua salinan. Masalah yang ia selesaikan: konflik saat beberapa user mengedit data yang sama secara bersamaan. Untuk teks, idenya:

const kunci = (id) => `${id.c}@${id.s}`                         // "15@rini"
const lebihBesar = (a, b) => (a.c !== b.c ? a.c > b.c : a.s > b.s)

function terapkan(doc, op) {                // false artinya: belum bisa, tunggu
  if (op.type === 'hapus') {
    const el = doc.find((e) => kunci(e.id) === op.target)
    if (!el) return false                   // huruf yang dihapus belum datang
    el.dihapus = true                       // tombstone
    return true
  }
  if (doc.some((e) => kunci(e.id) === kunci(op.id))) return true // sudah pernah
  let i = 0
  if (op.after) {
    const a = doc.findIndex((e) => kunci(e.id) === op.after)
    if (a < 0) return false                 // jangkarnya belum datang
    i = a + 1
  }
  while (i < doc.length && lebihBesar(doc[i].id, op.id)) i++    // tie-break
  doc.splice(i, 0, { id: op.id, ch: op.ch, dihapus: false })
  return true
}

function terima(replika, op) {              // antrean sebab-akibat
  replika.antre.push(op)
  for (let maju = true; maju; ) {
    maju = false
    for (const o of [...replika.antre]) {
      if (terapkan(replika.doc, o)) { replika.antre.splice(replika.antre.indexOf(o), 1); maju = true }
    }
  }
}
const teks = (doc) => doc.filter((e) => !e.dihapus).map((e) => e.ch).join('')

Urutan datang boleh acak

Contoh di Lab: Bu Rini mengetik , jangan pakai pewangi (22 operasi sisip, 15@rini sampai 36@rini). Mas Eko mengetik 2 di depan (15@eko, 16@eko), lalu menghapus dipisah (7 operasi hapus). Kedua salinan bertukar 31 operasi dan sama-sama menjadi 2 kemeja , jangan pakai pewangi. Sisipan Bu Rini tetap mendarat sesudah 14@rini walaupun huruf itu sudah dihapus, karena tombstone-nya masih ada.

Dicoba: 31 operasi itu dikirim ke salinan ketiga (HP Pak Darto) dalam 2.000 urutan acak. Hasilnya 2.000 kali sama, antrean selalu kosong di akhir. Perhatikan juga: CRDT menjamin hasilnya sama, bukan kalimatnya bagus. Spasi sebelum koma tetap harus dirapikan manusia.

Tombstone dan harganya

Huruf yang dihapus tetap disimpan. Di CRDT buatan sendiri tadi, catatan 31 huruf disimpan sebagai 38 elemen (7 tombstone). Ketik lalu hapus satu huruf 1.000 kali, dan dokumen 14 huruf membawa 1.014 elemen. Library sungguhan menekan biaya ini: Yjs membuang isi huruf yang dihapus dan menggabungkan deretan tombstone. Diukur dengan Yjs 13.6.33 (clientID acak, seperti aslinya): kemeja dipisah = 28 byte, ditambah lalu dihapus 22 huruf = 44 byte (66 byte kalau gc: false), 1.000 kali ketik dan hapus = 46 byte.

Yjs dan Automerge: alat yang dipakai sungguhan

Jangan menulis CRDT sendiri untuk produksi. Versi yang dicek 11 Oktober 2026: yjs 13.6.33 (dengan y-websocket 3.1.0 untuk sinkron lewat WebSocket) dan @automerge/automerge 3.5.0.

import * as Y from 'yjs'

const rini = new Y.Doc(), eko = new Y.Doc()
rini.getText('catatan').insert(0, 'kemeja dipisah')
Y.applyUpdate(eko, Y.encodeStateAsUpdate(rini))       // eko menyalin isi awal

const sebelum = Y.encodeStateVector(rini)
rini.getText('catatan').insert(14, ', jangan pakai pewangi')
eko.getText('catatan').insert(0, '2 ')

Y.applyUpdate(eko, Y.encodeStateAsUpdate(rini, sebelum)) // 39 byte
Y.applyUpdate(rini, Y.encodeStateAsUpdate(eko))
console.log(rini.getText('catatan').toString()) // 2 kemeja dipisah, jangan pakai pewangi

⚠️ Jebakan yang sering ketemu

TL;DR: Last-write-wins menyimpan teks utuh terakhir dan diam-diam membuang ketikan orang lain. Operasi dengan posisi angka meleset begitu ada sisipan di depan. OT memperbaikinya dengan menggeser posisi, tapi butuh server pusat yang mengurutkan (Google Docs). CRDT memberi tiap huruf id tetap (15@rini), sisipan menyebut jangkar, tie-break "id lebih besar di kiri", dan hapus cuma menandai tombstone, jadi operasi boleh datang dalam urutan apa pun (2.000 urutan acak, 1 hasil) tanpa server, cocok untuk peer-to-peer dan offline. Tombstone memakan tempat; Yjs (13.6.33) dan Automerge (3.5.0) menekannya, jadi pakai mereka, bukan buatan sendiri.

Yang akan kamu pelajari