Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Advanced Clientside Tech 低レイヤFE

Avatar for Stasshe Stasshe
September 25, 2026

Advanced Clientside Tech 低レイヤFE

KC3 hack 2026 大阪
Stasshe

Avatar for Stasshe

Stasshe

September 25, 2026

More Decks by Stasshe

Other Decks in Programming

Transcript

  1. 自己紹介 石田 尚幹 1回生 小中学生から自然科学の研究を行い、模型飛行機大会、発明工夫展や、探求発表会 などで複数回入賞。昼休みなどに不定期で学年全体を巻き込んだ大規模実験の企画 などを行った。 高校でJR四国と連携し一般販売ツアーを作成、その成果を県で発表 同時期、高校に導入された学食システムの脆弱性の可能性を指摘、システム作成会社 から認めていただいた

    生徒会員として、体育祭・スポーツ祭での大規模なDXを成功させ、現在も同高校で運 用が維持(Celeritas-Sports-Event-Web-Manager) 高校3年生の時Pyxis-CodeCanvasを制作し、U22Proconにて、 経済産業大臣賞テクノロジー部門・アクセンチュア賞を受賞 同時に、Celeritas複数作品同時通過を達成 現在は摂津市立第一中学校にて、Pyxisなどのサービスを提供し、正式な技術指導員 として参画 他サークルでもメンター・運営などを行う 2
  2. Advanced Frontend Tech Browser as a Runtime — フロントエンドをシステムプログラミングとして見る 主題:

    Main Thread をどう守るか 01 Concurrency / Scheduling Advanced Frontend Tech 02 Rendering Pipeline / Offmain-thread 03 04 Storage / Local-first Frontend Architecture 1
  3. 4 章構成: Concurrency → Rendering → Storage → Architecture 01

    02 03 04 Concurrency / Scheduling Rendering Pipeline / Off-main-thread Storage / Local-first Frontend Architecture CPU-bound な仕事を main から隔離するとき、message passing と shared memory のどちらを選ぶか どのステージが無効化 されるか。pixel を作る仕事 をどこで・いつ・誰がやるか ブラウザ上で永続的な application state を、複数 tab でどう一貫させるか 1 つのアプリを複数の runtime subsystem として 設計すると何が現れるか 25 min 20 min 20 min 17 min Advanced Frontend Tech 3
  4. ブラウザは「実行場所が複数ある runtime」 Renderer Process(site ごとに分離) Browser Process UI · navigation

    · 権限 Main Thread JS 実行 · DOM · Style · Layout · Paint · 入力イベント · GC GPU Process Compositor Thread scroll · transform / opacity animation · 合成 raster / composite の GPU 実行 · 表示 Raster Workers 描画命令 → tile の pixel Network Service HTTP · cache · cookie Worker Threads Worklet Threads Dedicated / Shared Worker AudioWorklet など ※ Chromium のモデル。Gecko / WebKit はスレッド・プロセス分割が異なる → 講義全体は、これらへの仕事の配り方の話 Advanced Frontend Tech 4
  5. Main Thread は 1 本しかない Main Thread Input JS(app) Style

    Layout Paint GC JS(3rd party) Input JS(app) time → 何を main に残し、何を追い出すか。これが 90 分の主題。 追い出す先 Worker / Compositor thread / Worklet / GPU process Advanced Frontend Tech 守る指標 frame budget(1 frame の締切)と input latency(入力から次の描画 まで) 払う代償 コピー、同期、backpressure、整合 性。並行化はタダではない 2
  6. 01 Concurrency / Scheduling 問い: CPU-bound な仕事を main から隔離するとき、message passing

    と shared memory のどちらを選ぶか Long Task · scheduler.yield · postTask · Worker の種類 · Structured Clone · Transferable · SharedArrayBuffer · Atomics · ring buffer · Worker pool · backpressure 5
  7. 01 Concurrency / Scheduling Long Task は入力と描画を人質に取る vsync 1 つの

    task: 180 ms(Long Task) Main h r Input click(20 ms)→ handler 開始(180 ms): 入力遅延 160 ms 0 ms 50 ms 100 ms 150 ms 200 ms h = handler, r = render。破線の vsync では frame を出せない(約 10 frame 落ち) • Long Task = main を 50 ms 以上占有する task • event loop は task を途中で奪わない(non-preemptive)。1 task の長さ = 応答遅延の下限 • 対策は 2 つだけ: 刻む(協調的スケジューリング)か、main から追い出す(Worker) Advanced Frontend Tech 6
  8. 01 Concurrency / Scheduling 刻む: 時間で区切って yield する Before 180

    ms 連続(その間 入力も描画も不可) After 30ms 30ms 30ms 30ms = yield した隙間。入力処理と描画がここに入れる • 変わるのは合計時間ではなく最悪応答時間 30ms 30ms async function processAll(items) { let last = performance.now(); for (const item of items) { process(item); if (performance.now() - last > 5) { await yieldToMain(); // let input run last = performance.now(); } } } • 区切りは件数ではなく経過時間で判断する • 粒度の目安: 数 ms(frame budget の一部) Advanced Frontend Tech 7
  9. 01 Concurrency / Scheduling setTimeout(0) と scheduler.yield() は「戻る位置」が違う setTimeout(fn, 0)

    前半 他の task A 他の task B 他の task C 続き 続き 他の task A 他の task B 他の task C await scheduler.yield() 前半 function yieldToMain() { if (globalThis.scheduler?.yield) { return scheduler.yield(); } return new Promise(r => setTimeout(r, 0)); } Advanced Frontend Tech • yield の続きは同優先度の他 task より先に走る • setTimeout は最後尾。入れ子 5 段目以降は最小 4 ms に clamp • 対応: Chromium 129+ / Firefox 142+。Safari は fallback 8
  10. 01 Concurrency / Scheduling scheduler.postTask: 優先度を宣言して積む priority 意味 例 user-blocking

    入力への直接の応答。最優先 クリック直後の視覚フィードバック user-visible(default) 見えるが、即時でなくてよい 検索結果・一覧の描画 background 見えない・急がない prefetch、index 構築、ログ送信 const ctl = new TaskController({ priority: 'background' }); scheduler.postTask(buildSearchIndex, { signal: ctl.signal }); // user starts typing -> the index is now urgent ctl.setPriority('user-visible'); // or ctl.abort() Advanced Frontend Tech 注意 優先度は main 内の実行順を決めるだけ。実 行中の task を奪う preemption ではない。長 い task は依然として分割が必要 9
  11. 01 Concurrency / Scheduling Worker の種類は「寿命」と「共有範囲」で選ぶ Dedicated Worker SharedWorker Service

    Worker 所有者 生成したページ 1 つ 同一 origin の複数 tab / iframe origin + scope(ページと独立) 寿命 生成元と同じ 最後の接続が切れるまで browser が event 駆動で起動・停止 典型用途 CPU-bound 計 算、OffscreenCanvas、WASM tab 間で 1 本の WebSocket / DB 接続を 共有 offline cache、push、network proxy 注意 起動コストと独立した JS heap Chrome Android は 148(2026-05)で 対応 いつ止められてもよい設計。state を持 たせない Advanced Frontend Tech 10
  12. 01 Concurrency / Scheduling postMessage はコピーである: Structured Clone のコスト Main

    heap serialize { nodes: [...100k], meta } 送信側で同期 コストは O(graph の大きさ) 走査・型判定・確保をすべて行う。10 万要素の配列なら 10 万要素分 Advanced Frontend Tech bytes deserialize 受信側 Worker heap 複製された object graph 送信側も払う 運べないもの postMessage の呼び出し中、serialize は main 上で同期実行。巨大な clone は main を止める function、DOM node は不可。class instance は prototype を失い plain object になる 11
  13. 01 Concurrency / Scheduling Transferable: コピーではなく所有権を移す Before Main Worker ArrayBuffer

    64 MB (なし) After: postMessage(buf, [buf]) Main Worker detached(byteLength 0) ArrayBuffer 64 MB コスト コピー O(n) → ほぼ O(1)。bytes は動かず所有権 だけが移る。代わりに送信側は二度と触れない (move semantics) 主な Transferable const buf = new ArrayBuffer(64 * 1024 * 1024); worker.postMessage(buf, [buf]); // transfer list buf.byteLength; // 0 (detached) Advanced Frontend Tech ArrayBuffer · MessagePort · ImageBitmap · OffscreenCanvas · ReadableStream / WritableStream · VideoFrame · AudioData 12
  14. 01 Concurrency / Scheduling SharedArrayBuffer: 同じ bytes を複数スレッドが見る 必要なレスポンスヘッダ Main

    Worker 1 Worker 2 Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp // => crossOriginIsolated === true 0 3 7 1 0 9 2 5 SharedArrayBuffer(同じ bytes を全員が見る) • なぜ制限されるか: 共有メモリ + カウンタで高精度 タイマーが作れる → Spectre 系 side channel • プロセス分離を保証できる時だけ解禁 message passing は「送る」、shared memory は「同じ場 所を見る」。速いが、同期の責任が自分に来る Advanced Frontend Tech • 代償: CORP/CORS 非対応の第三者リソースが読 めなくなる。credentialless で緩和 13
  15. 01 Concurrency / Scheduling 共有したら同期が要る: Atomics data race(lost update) Worker

    1 read 5 write 6 read 5 Worker 2 2 回 +1 したのに結果は 6(期待値 7) // not atomic: read, add, write counter[0]++; // atomic read-modify-write Atomics.add(counter, 0, 1); Advanced Frontend Tech write 6 API 用途 add / sub / and … 不可分な read-modify-write load / store 順序保証付きの読み書き(publish) compareExchange CAS。lock や lock-free 構造の基礎 wait / notify thread を眠らせる / 起こす。main では wait 不可 waitAsync Promise で待つ。main でも 可。Chromium 90+ / Safari 16.4+ / Firefox 145+ 14
  16. 01 Concurrency / Scheduling message passing vs shared memory message

    passing shared memory データの動き コピー(clone)または移動(transfer) 同じ bytes を共有 同期 暗黙(メッセージの到着順) 明示(Atomics) 扱えるデータ cloneable な object graph TypedArray 上の bytes のみ 安全性 data race が起きない data race があり得る デバッグ 容易(message を log する) 困難(非決定的) 前提条件 なし cross-origin isolation 向く用途 粗粒度の仕事の受け渡し 高頻度・大容量の状態共有 原則は message passing。計測して clone / 転送がボトルネックの箇所だけ shared memory へ Advanced Frontend Tech 15
  17. 01 Concurrency / Scheduling SPSC ring buffer: lock なしで流すキュー 0

    1 2 3 4 v v v head (consumer が進める) 5 6 7 tail (producer が進める) • producer は tail だけ、consumer は head だけを書く → lock 不 要 function push(v) { // producer const t = Atomics.load(ctrl, TAIL); const h = Atomics.load(ctrl, HEAD); if ((t + 1) % N === h) return false; // full data[t] = v; // 1. write slot // 2. publish (after the write) Atomics.store(ctrl, TAIL, (t + 1) % N); Atomics.notify(ctrl, TAIL); return true; } • empty: head == tail / full: (tail+1) % N == head • full で false を返す = backpressure の入口 Advanced Frontend Tech 16
  18. 01 Concurrency / Scheduling Worker Pool: command は message、データは共有メモリ Main

    Thread(UI・入力) commands(小さい) results / patches • pool size: hardwareConcurrency - 1 を 上限に実測で決める Dispatcher(queue・優先度・上限) • task は小さく idempotent に(失敗時に再 投入できる) Worker 1 Worker 2 Worker N • queue には上限を持たせる(→ 次: backpressure) • Worker は起動時に作って使い回す。都度 new しない Shared State(SharedArrayBuffer) 大きなデータはここに置き、message は offset / id だけ運ぶ Advanced Frontend Tech 17
  19. 01 Concurrency / Scheduling Backpressure: 生産が消費を上回ると queue は伸び続ける queue が単調増加

    → memory と遅延が伸び続ける Producer Consumer 120 msg/s 60 msg/s Bounded queue 上限を設け、満杯なら producer を待たせる / 拒否 する Drop Coalesce 古いもの(or 新しいもの)を 捨てる。ログ・計測値向き 最新値だけ残す。pointer 位 置、scroll、進捗表示 Credit / ACK window 受け手が「あと N 件受 けられる」を返す。TCP の flow control と同型 postMessage は相手が詰まっていても失敗しない。上限は自分で作るしかない Advanced Frontend Tech 18
  20. 01 Concurrency / Scheduling 「Worker に投げれば速い」が成立しない 5 つのケース 1. 仕事が小さい

    message 往復と clone の固定費が計算時間を上回る。1 ms 未満の仕事なら main で刻む方が 速い 2. 大きなデータが往復する serialize は送信側で同期実行。main で clone している限り main は救われない 3. DOM が必要 Worker は DOM に触れない。結果を DOM に反映する部分は main に残る 4. 結果を即座に待つ 並列化ではなく遅延の移動。main が await している間、UI 状態は止まったまま 5. 起動とメモリ Worker ごとに独立した JS heap と起動時間。大量生成・使い捨ては逆効果 Advanced Frontend Tech 19
  21. 01 Concurrency / Scheduling 第 1 章のまとめ: どこで実行するかの判断順 Yes DOM

    / layout 情報が必要か? Main に残す(DOM の読み書きを batch) No Yes Main に残す + scheduler.yield / postTask で刻む 1 回の仕事は数 ms 未満か? No Yes 大きなデータを高頻度で共有するか? Worker + Transferable / SharedArrayBuffer No Worker + message passing(既定はここ) Advanced Frontend Tech 20
  22. 02 Rendering Pipeline / Off-main-thread 問い: pixel を作る仕事を、どこで・いつ・誰が実行しているか。何を変 えると、どのステージが無効化されるか Pipeline

    · invalidation · contain / content-visibility · forced sync layout · layout thrashing · Paint vs Raster · Layer / Compositing · frame budget · input latency · OffscreenCanvas · Worklet 21
  23. 02 Rendering Pipeline / Off-main-thread pixel ができるまで: 7 つのステージ Main

    Thread Raster / Compositor threads GPU JavaScript Style Layout Paint Raster Composite Display DOM / CSSOM を 変更 要素ごとの computed style 幾何(x, y, w, h) 描画命令を記録 (display list) 命令 → pixel(tile 単位) layer を合成して frame に vsync で画面へ ※ 概念モデル。スレッド割当は Chromium 基準(Gecko: WebRender、WebKit も独自) 本章の焦点は名前ではなく 「何を変えると、どのステージから再実行されるか」 Advanced Frontend Tech 22
  24. 02 Rendering Pipeline / Off-main-thread 何を変えたかで、どこからやり直すかが決まる 幾何を変える width top font-size

    見た目だけ変える color background box-shadow 合成だけで済む transform opacity(layer 上) Style Layout Paint Raster Composite 実行 実行 実行 実行 実行 実行 skip 実行 実行 実行 条件付き* skip skip skip 実行 「transform が速い」の正体 = Layout と Paint を飛ばせること(layer 化が前提) * JS で値を書き換えた場合は Style が走る。compositor 上の CSS animation なら毎 frame の Style も不要 Advanced Frontend Tech 23
  25. 02 Rendering Pipeline / Off-main-thread Style invalidation と containment: 影響範囲を切る

    root div .card div .card { contain: layout paint; /* scoped */ } .feed-item { content-visibility: auto; /* skip offscreen */ contain-intrinsic-size: auto 320px; } contain の境界 • DOM 変更 → 影響しうる要素を判定 → その部分木だけ再計 算 変更の影響が境界の外へ漏れない → 再計算が部分木に閉 じる Advanced Frontend Tech • selector の形と DOM 構造が再計算範囲を左右する • contain は「外に影響しない」という約 束。ブラウザはそれを根拠に範囲を切る 24
  26. 02 Rendering Pipeline / Off-main-thread Forced synchronous layout: 読んだ瞬間に layout

    が走る el.style.width = '500px'; // write: layout dirty const w = el.offsetWidth; // read: flush now 通常 読み取り API は正しい値を返す義務がある → dirty ならその場で style / layout を実行する JS read あり JS Style Style Layout JS Layout Paint Style Layout Paint 赤 = JS の途中で強制された style / layout layout を強制しうる読み取り(一部) offsetTop/Left/Width/Height · clientWidth · scrollTop · getBoundingClientRect() · getComputedStyle() · innerText Advanced Frontend Tech 25
  27. 02 Rendering Pipeline / Off-main-thread Layout thrashing: 読み書きの交互が layout を

    N 回走らせる ✕ 交互: layout × N W R* W ◦ 分離: layout × 1 R* W R* for (const el of els) { el.style.width = calc(el) + "px"; total += el.offsetHeight; // flush } R R R W W W L const hs = els.map(el => el.offsetHeight); els.forEach((el, i) => { el.style.width = calc(el, hs[i]) + "px"; }); W = write, R = read, R* = forced layout を伴う read, L = frame 末尾の通常 layout 原則: 同じ frame 内で read を先に、write を後に。書いた結果は次の frame(rAF)で読む Advanced Frontend Tech 26
  28. 02 Rendering Pipeline / Off-main-thread Paint と Raster は別物 Paint

    Layout tree drawRect(0,0,320,48,#fff) drawText("Inbox",16,30) drawImage(avatar,280,8) ... display list Raster Composite frame tiles(pixel) Paint が重い(命令の記録・main) Raster が重い(pixel 化・raster thread / GPU) 要素数が多い、描画命令が複雑(大量の text、border、shadow の記録) 面積が大きい、blur / shadow / filter、高 DPR、大きな画 像の decode Advanced Frontend Tech 27
  29. 02 Rendering Pipeline / Off-main-thread Layer と Compositing: main が止まっても動くもの

    Layer: ページ本体 Layer: sticky header frame(layer の位置・alpha を変えて 合成) Compositor Layer: animation 中のカード(transform) Main JS の Long Task(main は busy) Compositor frame frame frame frame frame frame frame frame frame frame scroll と compositor 上の transform / opacity animation は、main を待たずに進む Advanced Frontend Tech 28
  30. 02 Rendering Pipeline / Off-main-thread Layer はタダではない bytes ≈ width

    × height × 4 × DPR² 8.3 MB 1920 × 1080, DPR 1 の 全画面 layer 1 枚 33 MB 同じ layer を DPR 2 で × 層数 will-change を 20 要素に 付ければ、その分だけ • will-change を全要素に付けない。直前に付け、終わったら外す • overlap による暗黙の layer 昇格(layer explosion)に注意 • DevTools の Layers パネル / Rendering → Layer borders で確認する ※ 実際は tile 単位で可視域中心に raster される。上の数値は桁の感覚として Advanced Frontend Tech 29
  31. 02 Rendering Pipeline / Off-main-thread Frame budget: 16.7 ms は前提ではない

    16.7 ms 8.3 ms 6.9 ms 60 Hz 120 Hz 144 Hz budget = 1000 ms / refresh rate 120 Hz の 1 frame In JS 3.3 ms Style 0.8 ms Layout 1.2 ms Paint 1 ms Raster+Comp. 1.5 ms 内訳は例示。JS に残るのは budget の一部でしかない • requestAnimationFrame は display refresh に同期する。120 Hz なら毎秒 120 回 • setInterval(fn, 16) で animation を書かない(refresh とずれて jank になる) Advanced Frontend Tech 30
  32. 02 Rendering Pipeline / Off-main-thread Throughput と latency は別の指標 frames

    Main 他の task click paint INP ≈ input delay + processing + presentation delay • throughput(fps)と latency(入力 → 次の描画)は別 • compositor が滑らかでも main は詰まり得る → fps だけでは見落とす • LoAF は frame 単位で原因の script まで分解できる (Chromium 123+) Advanced Frontend Tech handler new PerformanceObserver((list) => { for (const e of list.getEntries()) { console.log(e.duration, e.blockingDuration, e.scripts.map((s) => s.sourceURL)); } }).observe({ type: 'long-animation-frame', buffered: true }); 31
  33. 02 Rendering Pipeline / Off-main-thread OffscreenCanvas: 描画そのものを Worker へ Main

    Thread DOM · 入力 · UI transferControlToOffscreen() (1 回だけ) Worker simulation · layout 計算 Canvas 2D / WebGL / WebGPU で描画 requestAnimationFrame で frame 駆動 // main const off = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: off }, [off]); // worker onmessage = ({ data }) => { const ctx = data.canvas.getContext('2d'); const loop = () => { draw(ctx); requestAnimationFrame(loop); }; requestAnimationFrame(loop); }; main が詰まっても描画は続く(逆も) Advanced Frontend Tech 32
  34. 02 Rendering Pipeline / Off-main-thread 入力は main に届く: 最新値だけを渡す pointermove(120

    Hz 以上) latest-only slot Worker(rAF) 上書きするだけ 1 frame に 1 回だけ読む ✕ 全イベントを postMessage → queue が詰まり、描画が過去の入力を追いかける(遅延が累積) let pending = null; canvas.addEventListener('pointermove', (e) => { pending = { x: e.clientX, y: e.clientY }; // overwrite }); requestAnimationFrame(function tick() { if (pending) { worker.postMessage(pending); pending = null; } requestAnimationFrame(tick); }); Advanced Frontend Tech 途中の点が必要なら e.getCoalescedEvents() で frame 間 の点をまとめて 1 回で送る。第 1 章の Coalesce 戦略そのもの 33
  35. 02 Rendering Pipeline / Off-main-thread Worklet は Worker ではない: pipeline

    へのフック Worker Worklet アプリ browser の pipeline 実行タイミング 任意 pipeline の特定点(音声描画、paint など) global scope fetch、IndexedDB など大 半 極小(DOM も多くの非同期 API もない) 呼ぶのは instance 例 Advanced Frontend Tech 明示的に生成した数 計算、I/O browser が複数生成・破棄しうる → 状態を持てない AudioWorklet、CSS Paint API 2.67 ms 128 / 48000 s ≈ 2.67 ms AudioWorklet の 1 回の締切 • 締切を落とすと音切れ。allocation → GC pause も致命的 • lock・blocking・高頻度 message も禁止事項 • Paint / Animation / Layout Worklet は対応 がまちまち(思想として押さえる) 34
  36. 03 Storage / Local-first 問い: ブラウザ上で永続的な application state を、複数 tab

    でどう一貫させるか Storage の地図 · IndexedDB の transaction model · auto-commit · schema migration · multi-tab · Web Locks · BroadcastChannel · OPFS · durability · Local-first · outbox · 競合解決 35
  37. 03 Storage / Local-first ブラウザの永続化手段は 4 つの性格に分かれる localStorage IndexedDB OPFS

    Cache API API 同期 非同期 非同期(Worker では同期も) 非同期 単位 string の KV cloneable な object bytes / file Request → Response 検索 key のみ key range・index・cursor 自前(engine を載せる) URL の一致 transaction なし あり なし(engine 次第) なし 使いどころ 小さな設定値 アプリの state SQLite / WASM、大容量 offline の asset localStorage は main を同期ブロックする → アプリ state の置き場にしない Advanced Frontend Tech 36
  38. 03 Storage / Local-first IndexedDB は「index 付きの object store」 Database

    "app"(version 5) Object store "tasks" Index "byProject" 1 → {title, projectId: p1} p1 → [1, 4] 2 → {title, projectId: p2} p2 → [2] 4 → {title, projectId: p1} p3 → [3, 5] const tx = db.transaction('tasks', 'readonly'); const req = tx.objectStore('tasks') .index('byProject') .getAll(IDBKeyRange.only(projectId)); req.onsuccess = () => render(req.result); 値は Structured Clone SQL / JOIN / query planner はない。key・key range・index・cursor で自分で引く Advanced Frontend Tech 第 1 章と同じ clone アルゴリズムで保 存。Date・Map・Blob・ArrayBuffer も入 る。function や DOM は入らない 37
  39. 03 Storage / Local-first Transaction の scope がスケジューリングを決める T1 rw

    {tasks} 実行 T2 rw {tasks} 待機(T1 の終了まで) T3 ro {projects} 実行 T4 ro {projects} 実行(T3 と並行) 実行 time • readwrite: scope が重なる先行の transaction が終わるまで開始しない • readonly 同士は、scope が重なっても並行に走れる • scope(対象 store)を最小にするほど並行度が上がる Advanced Frontend Tech 38
  40. 03 Storage / Local-first await を挟むと transaction が死ぬ event loop

    に戻る active 未完了 request が 0 inactive commit 完了 committing finished request の callback 中は active に戻る ✕ 外部 I/O を挟む const tx = db.transaction('items', 'readwrite'); const data = await (await fetch(url)).json(); tx.objectStore('items').put(data); // -> TransactionInactiveError ◦ I/O を先に、transaction は後に const data = await (await fetch(url)).json(); const tx = db.transaction('items', 'readwrite'); tx.objectStore('items').put(data); await txDone(tx); // resolve on 'complete' auto-commit: pending request が尽きると勝手に commit する。外部 I/O を transaction の中で await しない Advanced Frontend Tech 39
  41. 03 Storage / Local-first Schema migration は multi-tab 問題になる Tab

    A(app v42, DB v4) IndexedDB Tab B(app v43) ① open('app', 5) ② versionchange イベント ③ db.close() ④ upgradeneeded → migration ③ が無ければ ④ は来ず、Tab B は blocked のまま db.onversionchange = () => { db.close(); showReloadBanner(); // new version available }; Advanced Frontend Tech デプロイ = 旧アプリ + 新アプリ + 永続 schema の共存問題 40
  42. 03 Storage / Local-first N タブ = N 個の sync

    loop: leader election で 1 つにする 素朴な実装 Leader election WebSocket × 1 Tab A Tab A(leader) Server Tab B Tab B(待機) Tab C Server Tab D Tab E Tab C(待機) Broadcast Channel Tab D(待機) WebSocket × 5・sync loop × 5 navigator.locks.request('sync-leader', async () => { startSyncEngine(); // only the leader await new Promise(() => {}); // hold until tab dies }); Advanced Frontend Tech leader が死んだら lock が解放され、待機中の次の tab が自動で leader になる 41
  43. 03 Storage / Local-first OPFS + SyncAccessHandle: Worker なら同期 I/O

    を許せる Main Thread(UI) message Dedicated Worker SQLite (WASM) VFS SyncAccess Handle const root = await navigator.storage.getDirectory(); const fh = await root.getFileHandle('app.db', { create: true }); const h = await fh.createSyncAccessHandle(); // ^ dedicated worker only h.write(bytes, { at: offset }); // sync h.flush(); 同期 read / write(UI は止まらない) • main で同期 I/O は許せない → Worker 限定だから同期 API を提供できる OPFS: app.sqlite3 Advanced Frontend Tech • file を排他ロック → 複数 tab からは同時に開けない(再び multi-tab 問題) 42
  44. 03 Storage / Local-first IndexedDB と OPFS は役割が違う IndexedDB OPFS

    単位 structured object(Structured Clone) bytes / file 検索 key range・index・cursor なし(上に載せる engine 次第) 整合性 transaction 内蔵 engine 次第(SQLite の journal / WAL) アクセス 非同期 非同期 + Worker では同期(SyncAccessHandle) 得意なもの アプリのドメインオブジェクト DB engine の backing store、大きな binary、random access SQLite(WASM)+ OPFS = SQL と JOIN をブラウザに持ち込む構成 Advanced Frontend Tech 43
  45. 03 Storage / Local-first ブラウザストレージは best-effort Eviction persist() estimate() storage

    pressure 時、browser が origin 単位でデータを消しうる 消さないよう要求する。許可は保証 されない usage / quota は privacy のため近似 値 const granted = await navigator.storage.persist(); const { usage, quota } = await navigator.storage.estimate(); if (usage / quota > 0.8) compactOrWarn(); Advanced Frontend Tech 設計への帰結 local を primary にしても唯一の copy にしない。server に複製がある前提で同期 する(→ Local-first) 44
  46. 03 Storage / Local-first Local-first: source of truth を反転させる Server-first(普通の

    SPA) Local-first UI UI 即時 HTTP(RTT を待つ) Server Local DB background Sync Engine protocol DB Server Local state = primary / Remote = synchronization target 得るもの: 即時の操作感とオフライン動作 払うもの: 同期・競合・障害時の整合性 Advanced Frontend Tech 45
  47. 03 Storage / Local-first 書き込みは「local transaction + outbox」 1 つの

    IndexedDB transaction Command moveTask(id, to) tasks を更新 UI に即時反映 outbox に op を追加 background outbox Sync Engine Server ACK outbox から削除 state と outbox を同じ transaction で書く → 片方だけ残る状態が起きない(atomic) ここから必要になるもの: retry · idempotency · ordering · 重複排除 · ACK Advanced Frontend Tech 46
  48. 03 Storage / Local-first Local-first ≠ CRDT: 競合解決はスペクトラム 単純・中央集権 表現力・分散

    Server authoritative Last-write-wins server が順序付けと検 証を行い、正を決める 新しい方が勝つ。失 われる更新を許容する OCC baseRevision が古 ければ reject → rebase / 再試行 CRDT OT 操作を相手の操作に合 わせて変換する 可換 にマージできるデータ 構造。server なしでも 収束 Figma の例 WebSocket + server authoritative。CRDT に着想を得つつ、server が順序付けと競合解決を担う独自方式。選ぶ基準 はデータ型と、競合の頻度と意味 Advanced Frontend Tech 47
  49. 04 Frontend Architecture 問い: 1 つのブラウザアプリを、複数の runtime subsystem として設計すると何が現れるか Runtime

    architecture · UI projection · commands / patches · incremental computation · sync protocol · snapshot + log · WASM boundary · failure recovery · version skew · observability 48
  50. 04 Frontend Architecture 1 つのアプリを、複数の runtime subsystem として設計する UI Runtime

    Main Thread · React / DOM / Input Application Engine Worker · domain state / indexes • 層の境界 = スレッド境界 = message / SharedArrayBuffer(第 1 章) Compute Engine Worker / WASM · parser / geometry / search • UI ↔ Engine: commands ↓ / patches ↑ • Engine ↔ Persistence: transaction(第 3 章) Local Persistence IndexedDB / OPFS • Sync ↔ Server: protocol(op log・ACK) Sync Engine Worker · WebSocket / HTTP • main には UI projection だけが残る(第 2 章の目 的) Server protocol の相手 Advanced Frontend Tech 49
  51. 04 Frontend Architecture UI は projection、canonical state は Engine が持つ

    command: moveNode(id, x, y) Domain Engine(Worker) UI(React) canonical state(10 万 node) 派生計算: geometry · index 永続化 · sync 用 op の生成 見えている部分だけ購読して描画 patch: 変わった分だけ ✕ React state = application state ◦ Engine = canonical、UI = projection 10 万 node・index・派生 geometry を全部 React state に 載せる → 更新のたびに main で差分計算 UI は見えている分だけ購読し、Engine から差分 patch を 受け取る Advanced Frontend Tech 50
  52. 04 Frontend Architecture Snapshot を往復させない ✕ snapshot を往復させる 毎 frame

    100 MB の document を postMessage → serialize が main で同期実行 → Worker 化した意味 が消える Commands Incremental patches 意図だけ送る(moveNode(id, x, y)) 変わった部分だけ送る cost per frame ∝ bytes cloned Transferable 大きな binary は所有権を移す 第 1 章の道具が、そのまま architecture の制約 になる SharedArrayBuffer 共有 view に置き、通知だけ送る Advanced Frontend Tech 51
  53. 04 Frontend Architecture Incremental computation: 変わった所だけ再計算する A1 A2 A3 A4

    • dependency graph を持つ B1 B2 C7 B4 • dirty propagation: 変更点から下流へ dirty を伝播 • dirty だけ再計算(memoization) • index も parser も incremental に C1 D19 D2 D3 • spreadsheet: セル → 依存セル • design tool: node → geometry → layout → render object 変更 再計算 触らない • editor: 編集範囲だけ再 parse 100 万セルあっても、再計算は依存先の 3 つだけ Advanced Frontend Tech 52
  54. 04 Frontend Architecture Sync Engine はプロトコル設計である type Operation = {

    opId: string; // unique -> idempotency clientId: string; seq: number; // per-client order baseRevision: number; // server state it assumed payload: Command; }; 起きること 扱い方 duplicate opId で重複排除 out-of-order seq で並べ直す / 欠番を待つ retry idempotent なら何度送っても安全 network partition outbox に滞留し、再接続で再送 stale client baseRevision 不一致 → rebase か reject server reject local を巻き戻して再適用(rollback) frontend に protocol design が入ってくる Advanced Frontend Tech 53
  55. 04 Frontend Architecture Snapshot + Log で復元コストを抑える replay だけ op

    1 → op 2 → op 3 → … → op 103,003(全部 replay する) snapshot + log 103,001 snapshot(rev 103,000) 103,002 103,003 … restore = load(snapshot) + replay(ops after snapshot) compaction client にも現れる 定期的に新しい snapshot を作り、それ以 前の log を捨てる local-first では client の DB も同じ構造 (初回同期 = snapshot、以降 = op) Advanced Frontend Tech Figma の例 document を memory に保持し checkpoint を作る。信頼性のため writeahead log を導入 54
  56. 04 Frontend Architecture WASM の速さは境界の設計で決まる 1,000,000 calls × 10 B

    1 call × 10 MB 境界の往復コストが支配的になる 境界を 1 回だけ越える • API の粒度: chatty な細かい呼び出しを避け、まとめて渡す • copy: linear memory への写しがコスト。TypedArray view で直 接書く • 文字列: encode / decode のコストがかかる • 共有: WASM threads + SharedArrayBuffer(第 1 章の前提が再 び) Advanced Frontend Tech Figma の例 UI は React / TypeScript、canvas engine は C++ → WebAssembly。本人たちいわく「Web stack より game engine に近い」。Code Layers では toolchain の大半を Web Worker 内で動かす 55
  57. 04 Frontend Architecture Tab はいつでも死ぬ: どこで落ちても回復できるか ① command ② Worker

    の state 更 新 ③ IndexedDB commit A ④ WebSocket 送信 B ⑤ ACK 受信 ⑥ outbox 削除 C crash 地点 再起動時の状態 回復 A(② と ③ の間) memory 上の変更だけ消える。ユーザーには反映済 みに見えていた local commit と確定表示の順序を設計する B(③ と ④ の間) local には残る。server は未受信 起動時に outbox から再送 C(④ と ⑤ の間) 送ったか不明(ACK なし) 再送し、server が opId で重複排除 (idempotency) Advanced Frontend Tech 56
  58. 04 Frontend Architecture Version skew: デプロイしても全員は更新されない Tab A(昨日から開いたまま) Tab B(今日開いた)

    Server UI v42 UI v43 protocol v15 Worker v42 Service Worker v43 v14 も受理 IDB schema v7 IDB schema v8 — • protocol は後方互換に変える(追加のみ) • 最低 version 未満は forced reload • schema は expand → migrate → contract • 旧 JS chunk を一定期間残す(古い tab の lazy load が 404 にならないように) • 接続時に version を交換(capability negotiation) Advanced Frontend Tech 57
  59. 04 Frontend Architecture Observability: 1 つの操作を thread を跨いで trace する

    pointerdown = 0 ms command 作成 +2 ms 計測の要点 Worker が受信 +1 ms trace ID を command に付 けて thread を跨いで繋ぐ +14 ms 計算完了 timeOrigin + now() で Worker と時刻を揃える +3 ms patch を受信 +8 ms render → presented Main input latency · LoAF · busy time Advanced Frontend Tech Worker / Storage Sync / Memory queue latency · message size · clone 時 間 · tx latency backlog · RTT · local vs server revision · heap 58
  60. 4 章は 1 本の因果でつながっている 02 Rendering 01 Concurrency main を空けたい

    Worker に出す main には UI projection だけが残る 04 Architecture 03 Storage ブラウザ内の分散システム Worker が state と DB を持つ Worker を使い始めた結果、FE に現れるもの message passing · backpressure · transaction · leader election · op log · failure recovery Advanced Frontend Tech 59
  61. Takeaways 1 Main Thread は希少資源。frame budget と input latency で予算管理する

    2 並行化はタダではない。コピー・同期・backpressure のコストまで設計に含める 3 大規模 FE は分散システム。local-first・protocol・障害・version skew を前提 に設計する Advanced Frontend Tech 60
  62. References • MDN: Web Workers / Transferable objects / SharedArrayBuffer

    / Atomics • MDN: Critical rendering path / Animation performance and frame rate • Chrome for Developers: Use scheduler.yield() to break up long tasks • Chrome for Developers: Long Animation Frames API • MDN: OffscreenCanvas / AudioWorklet / Long animation frame timing • web.dev: Optimize long tasks • MDN: IndexedDB API / Web Locks API / Broadcast Channel API • Figma Blog: Making multiplayer more reliable • MDN: FileSystemSyncAccessHandle / StorageManager.persist() / estimate() • Figma Blog: How Figma draws inspiration from the gaming world • W3C: Indexed Database API 3.0(transaction scheduling / lifecycle) • Figma Blog: Building Figma’s Code Layers Advanced Frontend Tech • Figma Blog: How Figma’s multiplayer technology works 61