5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Figmaのリアルタイム同期の仕組みを、Node.js + WebSocketの最小構成で実装してみた

5
Last updated at Posted at 2026-11-06

1. はじめに

ソーイ株式会社の西浦です。

前回の記事「Figmaの同時編集、なぜこんなになめらか?CRDTに着想を得た同期設計を公式ブログから読み解く」は、Figmaで他の人が動かしているオブジェクトが、自分の操作のようになめらかに追従してくることへの違和感から始まりました。Figma公式ブログを読んで、その裏側にある同期・競合解決の仕組みを、次の4つにまとめました。

  • サーバー到着順を利用した、LWW (Last-Writer-Wins) によるプロパティ同期
  • 楽観的更新と、チラつきを防ぐ一手間(flickering防止)
  • reparent(親子関係の変更)と、循環参照が一時的に発生して解消される流れ
  • Fractional Indexingによる兄弟順序の管理

なかでも「サーバーに届いた順を使うので、タイムスタンプが不要」という点は、読んでいるあいだ半信半疑でした。説明としては筋が通っているのに、そんなに単純でいいのかと引っかかっていたからです。調べて分かった気になっているだけかもしれないので、Node.js + WebSocketで最小構成を作って、自分の手で動かして確かめることにしました。

この記事の対象読者

  • 前回の記事を読んで、実際に動くところまで見てみたい方
  • リアルタイム協調編集の仕組みを、小さな実装で試してみたいエンジニア
  • LWWや楽観的更新といった言葉は知っているものの、動かしたことはない方

前提とリポジトリ

実装したのは、Figma公式ブログにある2019年時点の設計を、理解しやすいように単純化したものです。実際のFigmaの内部実装とは異なります。

コードとデモはこちらです。認証・永続化・複数ドキュメント対応は扱っていません。

ブラウザで触れるWeb版デモもあります。インストールは不要です。

無料枠のホスティングなので、しばらくアクセスがないとスリープします。初回は起動に数十秒かかることがあります。

2. 先に結果から:flickering防止をOFFにしてみた

一番分かりやすかった結果を、先に見てもらいます。4つの仕組みのうち「楽観的更新とflickering防止」を、トグルでOFFにしたときの違いです。同じようにノードをドラッグしているだけですが、これだけ変わります。

OFFのときは、マウスより遅れた位置に引き戻されて、カクつきます。

acc.gif

ONのときは、マウスにそのまま追従して、なめらかに動きます。

abc.gif

仕組みは5章で説明します。まずは土台になっている構成から見ていきます。

3. 全体の構成:サーバーは1つだけ

4つの仕組みに共通する前提です。

  • 1つのドキュメントに対して、サーバープロセスは1つ
  • クライアントの操作はすべてこのサーバーを通る
  • サーバーは届いた順に処理して、全クライアントへブロードキャストする

合意アルゴリズムもベクタークロックも使いません。サーバーが1つしかないので、サーバーに届いた順がそのまま正解になり、タイムスタンプを比べる必要がなくなります。

4. LWWによるプロパティ同期:届いた順に上書きする

サーバー側の実装はこれだけです。

server/document.js
applyPropertyUpdate(nodeId, key, value) {
  const node = this.nodes.get(nodeId);
  if (!node) return null;
  node.properties[key] = value;
  return node;
}

届いた順に上書きしているだけです。

2つのクライアントが同じプロパティをほぼ同時に書き換えても、同じ値に収束するか試しました。

[client1] 楽観的に適用: A.color = "#00ff00"
[client2] 楽観的に適用: A.color = "#ff00ff"
[client1] 確定 (再描画なし): A.color = "#00ff00"
[client2] 無視 (未確定の自分の更新あり): A.color = "#00ff00"
[client2] 確定 (再描画なし): A.color = "#ff00ff"
[client1] 受信して反映: A.color = "#ff00ff" (from 1af61d3b)

client1 の A.color = #ff00ff
client2 の A.color = #ff00ff

サーバーに後から届いた更新(ここではclient2)が、最終的に両方のクライアントに反映されています。

client2は、自分の更新が確定する前にclient1の更新を受け取っていますが、これは無視しています。理由は5章で説明します。

ブラウザUIでは、2人が同時に同じ場所を操作しないと収束の様子を見られないので、ログで確認しています。npm run scenario:lww で再現できます。

5. 楽観的更新とflickering防止:確定通知では再描画しない

クライアントは、操作した瞬間にローカルへ反映します。同時に、その操作の opId を「確定待ち」として覚えておきます。

client/DemoClient.js
updateProperty(nodeId, key, value) {
  const node = this.nodes.get(nodeId);
  const opId = this.nextOpId();
  node.properties[key] = value; // 即時反映 (楽観的更新)
  this.pendingOps.set(opId, { type: 'update-property', nodeId, key });
  this.ws.send(JSON.stringify({ type: 'update-property', nodeId, key, value, opId }));
}

サーバーから届いた確定通知が自分の操作のものなら、値はすでに画面に出ているので再描画しません。これがflickering防止です。

[client1] 楽観的に適用: B.x = 250
[client1] 確定 (再描画なし): B.x = 250

確定のたびに再描画すると、値が一瞬巻き戻ってから正しい値に戻るので、画面がチラつきます。

ログだけでは違いが分かりにくかったので、ブラウザUIにON/OFFのトグルを付けました。OFFにすると、自分の操作の確定通知が届くたびに、その時点でサーバーから届いた(少し古い)値で再描画します。確定通知が届くまでの間も、マウスは動き続けています。そのため、確定された値は「少し前の位置」になっていて、OFFだとその位置へ引き戻されます。2章のGIFは、このトグルを切り替えて撮ったものです。

client/public/app.js
if (wasPending && flickerPreventionEnabled) {
  // 確定済みの自分の操作 → 再描画しない (flickering防止)
} else if (wasPending) {
  // OFF: 自分の操作でも強制的に再描画する (あえてチラつきを再現)
  renderAll();
}

2人も2タブも要りません。1人でノードを素早くドラッグするだけで違いが分かります。

  • ON: マウスにそのまま追従して、なめらかに動く
  • OFF: ドラッグ中に、マウス位置より少し遅れた座標へノードが引き戻されて、カクつく

ブラウザからサーバーまでの往復時間を測ったところ、手元のローカルでは約5ms、Render上のデモでは約150msでした(どちらも中央値です)。2章のGIFは、Render上のデモで撮っています。ローカルでは遅延が小さいので、ONとOFFの差は、ここまで大きくは出ません。

自分の操作の確定通知で再描画しないだけでは、足りない場面があります。自分の更新が確定する前に、同じプロパティへの他の人の更新が届く場合です。サーバーからの通知は、サーバーが処理した順に届きます。確定待ちが残っているうちに届いた他の人の更新は、自分の更新より先に処理されたものです。これをそのまま画面に反映すると、あとから届く自分の確定通知では再描画しないので、画面が他の人の値のまま止まってしまいます。

そこで、同じプロパティに確定待ちの更新が残っているあいだは、他の人の更新を無視します。4章のログで、client2が「無視」しているのがこの処理です。Figmaの公式ブログにも、同じ考え方が書かれています。トグルをOFFにしているときは、この無視もしません。

返事を待たずに先に反映して、返事が来ても描き直さない。通信の遅れが画面に出ないのは、この「待たない」設計のおかげです。

6. reparentと循環参照:前回の図を動かして再現する

前回の記事で描いたsequenceDiagramを、そのまま動かして再現してみました。

サーバー側の判定です。

server/document.js
reparent(nodeId, newParentId) {
  if (newParentId === nodeId || this.isAncestor(nodeId, newParentId)) {
    return null; // 循環参照になるため拒否
  }
  // ...
}

動かした結果のログは、図と同じ流れになっています。

[client1] 楽観的に適用: A の親 -> B
[client2] 楽観的に適用: B の親 -> A
[server] reparent A -> B (受理)
[client1] 確定 (再描画なし): A の親 -> B
[client2] 受信して反映: A の親 -> B
[server] reparent B -> A (循環参照のため拒否)
[client2] 拒否 (循環参照) → 巻き戻し: B の親を root に戻しました

プロパティの競合は、サーバーで最後に処理された値を採用します。一方、親子関係の変更は、循環参照になる操作だけを拒否して、手元を巻き戻します。複雑な調停はせず、この割り切りだけで循環が解消されました。

7. Fractional Indexing:BigIntで桁を伸ばす

前回の記事では、実際のFigmaは64bit浮動小数点ではなく、任意精度の分数を文字列で持っていると書きました。今回はBigIntを使って、桁を伸ばしながら中間値を計算する形にしました。

中間値を求める中心は、次の midpointDigits です。2つのorderの間に挿入する値は、これを呼ぶ generateKeyBetween(a, b) で作ります。ソース全体は shared/fractionalIndex.js にあります。

shared/fractionalIndex.js
function midpointDigits(lowDigits, highDigits) {
  let len = Math.max(lowDigits.length, highDigits.length, 1);
  for (;;) {
    const low = lowDigits.padEnd(len, '0');
    const high = highDigits.padEnd(len, '0');
    const sum = BigInt(low) + BigInt(high);
    if (sum % 2n === 0n) {
      const mid = (sum / 2n).toString().padStart(len, '0');
      if (mid !== low && mid !== high) return trimTrailingZeros(mid);
    }
    len += 1; // 精度が足りなければ桁を1つ増やしてやり直す
  }
}

同じ2要素の間に何度挿入しても、間に新しい値を入れられました。

orderは小数点以下の桁だけを持つ値で、5 は0.5、745 は0.745を表します。小さい順に並べるので、最初の状態は A、B の順です。

初期状態: A(order=5), B(order=745)
移動後: B(order=25), A(order=5)
C挿入後: B(order=25), C(order=375), A(order=5)

浮動小数点で「2つの間の中間値」を取る方法だと、同じ隙間に挿入するたびに隙間が半分になります。倍精度(64bit)では、0.5と1の間に挿入し続けると52回で中間値が端の値と同じになり、それ以上は挿入できませんでした。文字列の桁を伸ばす方式なら、足りなくなったら桁を増やすだけなので、挿入の回数に上限がありません。

一方で、このデモには、同時挿入の対策は入れていません。2人が同じ2要素の間に同時に挿入すると、サーバーは同じorderを2回作ります(generateKeyBetween('25','5') を2回呼ぶと、どちらも 375 になります)。同じ値のときの並び順は決めていないので、実際に使うなら、ノードIDなどで同点を決める処理が必要です。

8. 実装してわかったこと

単純化した範囲では、4つとも調べた通りに動きました。そのうえで、読むだけでは気づかなかったことが2つあります。

「タイムスタンプ不要」には条件がある

一番大きかったのはこれです。前回は「サーバーへの到着順を使うのでタイムスタンプが不要」と書きましたが、実装してみると、これは「1つのドキュメントの更新順を、1つのサーバープロセスが決めている」という3章の前提に丸ごと支えられていると分かりました。

順序を決める場所が2つあったら、どうなるでしょうか。クライアント1はサーバーXに、クライアント2はサーバーYにつながっているとします。同じプロパティへの更新は、XとYでそれぞれ別の順番で「先に届いた」ことになり得ます。「届いた順」が1つに決まらないので、時刻や論理クロックのような、順序を決めるための別の仕組みが要ります。

タイムスタンプの管理が面倒だと言われるのはこのためで、Figmaはドキュメントごとに順序を決める場所を1つに絞ることで、その面倒ごとを避けているのだと理解できました。

今回の実装では、複雑さはサーバーではなくクライアントに寄っていた

循環参照の検出と聞いて身構えていましたが、サーバー側は isAncestor で祖先をたどるだけの数行でした。Fractional Indexingも、足りなければ桁を1つ伸ばすだけで済みました。サーバーが単純で済むのは、「届いた順」が正解だと決めているからです。

その分、手間がかかったのはクライアント側でした。自分の操作を「確定待ち」として覚えておく pendingOps と、サーバーに拒否されたときに巻き戻す処理です。5章のトグルでON/OFFを比べてみて、この地味な部分が画面のなめらかさを作っていることも分かりました。ログを見ていたときは気づかなかった差が、切り替えた途端に見えました。

実務に置き換えると

ソーイでは受託開発で、複数人が同時に触る画面を作る機会があります。今回の考え方は、Figma以外でも、そうした画面に使えると思います。

管理画面の一覧で「誰が今どの行を編集中か」を出したいときは、9章の「選択中ノードの表示」と同じ考え方が使えます。編集内容そのものとは別に、「誰がどこを見ているか」を一時的な情報として扱えば、既存のデータモデルに手を入れずに足せます。

ステータス変更やチェックボックスのように、よく押すボタンには、5章の楽観的更新と pendingOps の組み合わせが使えます。押した瞬間に見た目を変えて、確定通知が来ても再描画しなければ、サーバーの応答を待つ実装より体感がかなり速くなります。

仕組みを丸ごと持ち込むというより、考え方の部分だけを取り出して使うのが現実的だと思います。

9. おまけ:プレゼンス情報と、AIエージェントの参加

プレゼンス情報の同期

ドキュメントの永続的な状態(LWW・reparent・順序)とは別に、一時的なプレゼンス情報の同期も入れています。

  • マルチプレイヤーカーソル: 他の人のカーソル位置をリアルタイムに表示
  • 範囲選択(マーキー選択)の同期: 選択中の矩形を中継
  • 選択中ノードの表示: ノードをクリックすると、選択した人の色で枠線とラベルが付き、他のタブにも同期される

どれもドキュメントの状態ではなく、サーバーに保存せず、他のクライアントへ中継するだけです。

server/index.js
function broadcastExcept(message, exceptWs) {
  const payload = JSON.stringify(message);
  for (const ws of wss.clients) {
    if (ws.readyState === ws.OPEN && ws !== exceptWs) ws.send(payload);
  }
}

// select-node / deselect-node もカーソルや範囲選択と同じ扱い。
// Documentクラスには一切触れない。
case 'select-node': {
  broadcastExcept({ type: 'select-node', nodeId: msg.nodeId, fromClientId: clientId }, ws);
  break;
}

永続データと一時的なプレゼンス情報を分けて考えるのは、Figma以外の共同編集ツールでも使える考え方だと思います。

AIエージェントも、同じ仕組みで参加させてみた

学んだ仕組みを別の題材に使ってみたくて、同じリポジトリにKanban風のボード「Agent Workspace」を足しました。人間と同じ立場で、AIエージェントが1人のクライアントとして参加します。通常のデモ画面のツールバーから移動できます。

ドキュメントを書き換える処理は、人間と同じ Document のメソッド(reparent や move-node など)を使っています。AIの操作も、人間と同じ検証(循環参照の拒否など)を通ります。

カードを運んでいる途中も、ほかの人の画面に見えるようにしました。人間のドラッグも、AIの移動も、drag-start / drag-hover / drag-end という同じメッセージを流します。これもカーソルや選択中ノードと同じプレゼンスなので、サーバーは保存せず中継するだけです。カードの移動そのものは、ドロップした時点で、今まで通り reparent と move-node で確定します。

bbd.gif

AIへの指示は、「『デモを実装する』を完了にして」のような日本語で出せます。公開しているデモは、APIキーを使わず、決まったパターンで動くモックです。キーを設定すれば、Anthropic APIにもつなげられます。動かし方は、リポジトリのREADMEにまとめています。

10. まとめ

前回の記事は、Figmaで他の人が動かしているオブジェクトが、自分の操作のようになめらかに追従してくる、という違和感から始まりました。その裏側で何が起きているのかを、実装して確かめてみました。

動かしてみて分かったのは、なめらかに見えるのは「待たない」から、ぶつかっても破綻しないのは「割り切る」からだ、ということです。

  • 待たない: 操作はサーバーの返事を待たず、手元ですぐ画面に反映します。返事が届いても、自分の操作なら描き直しません。通信の遅れは、画面の外に隠れます
  • 割り切る: 操作がぶつかったら、サーバーが処理した順で決めます。プロパティは最後に処理された値が残り、親子関係の変更は、循環参照になる操作だけを拒否して手元を巻き戻します。並び順も、2つの間に値を差し込むだけで決めます

しかもこの割り切りは、「1ドキュメント=1サーバー」という前提のおかげで、数行のコードで済んでいました。調べているあいだ半信半疑だった「タイムスタンプ不要」は、本当でした。ただし、順序を決める場所を1つに絞っているという条件つきでした。

共同作業がなめらかに見える裏側には、通信の遅れを隠す工夫と、ぶつかったときの割り切りがあり、どちらも思っていたより小さな仕組みで支えられていました。前回比べたNotionやスプレッドシートとの体感の差にも、こうした設計の違いが関わっているのかもしれません。

参考リンク

お知らせ

技術ブログを週1〜2本更新中、ソーイをフォローして最新記事をチェック!

5
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?