linear-local-first-architecture について
Linear の技術分析をもとに、ローカルファースト設計を解説するスキルです。応答性の高い生産性ツールを作るチーム向けに、ローカルデータや同期、読み込み、性能を扱います。
主な機能
- 01IndexedDB へのワークスペース保存
- 02同期前にローカルで変更を反映
- 03認証確認前にローカルデータを描画
- 04コードを細かなチャンクに分割
- 05細粒度のオブザーバブルを利用
- 06キーボード操作を優先
活用シーン
- 01オフライン対応の生産性アプリ設計
- 02ネットワークを待たない更新応答の実現
- 03初期読み込みの短縮とキャッシュ再利用
- 04キーボード中心の高速なワークフロー構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ boraoztunc/ skills --skill linear-local-first-architectureインストール後、Claude Code で/linear-local-first-architectureを入力して読み込まれたか確認します。
詳細
このスキルを使う場面
- 体感速度がユーザー体験を大きく左右する、生産性向上ツールを構築している
- ネットワーク遅延は妥当な範囲なのに、アプリが「遅く感じる」とユーザーから報告されている
- ユーザーのワークフローから読み込みスピナーやスケルトン画面をなくす必要がある
- オフライン機能を備えたローカルファーストのアプリケーションを設計している
- 即座に反応するように感じられる楽観的更新を実装したい
- パワーユーザー向けにキーボード操作を優先したインターフェースを設計している
基本原則
-
ボトルネックはネットワークです。可能な限り排除しましょう。 ネットワークリクエストのたびに数百ミリ秒かかります。最善の最適化は、ローカル状態から読み取ってリクエスト自体をなくすことです。
-
ブラウザーを各ユーザーのデータベースとして扱いましょう。 ワークスペース全体を IndexedDB に保存し、メモリー内の監視可能なグラフにハイドレートします。UI はサーバーではなく、ローカル状態から読み取ります。
-
変更はまずローカルに適用し、非同期で同期しましょう。 ローカルの監視可能な状態をすぐに更新して UI を同期的に再レンダリングし、その後、トランザクションをキューに追加してバックグラウンドでサーバーと同期します。
-
まずレンダリングし、その後に認証しましょう。 ローカルデータがあればすぐにレンダリングし、バックグラウンドでセッションを検証します。サーバーに拒否された場合にのみ、ログイン画面へリダイレクトします。
-
コードを細かく分割して、出荷するコードを減らしましょう。 積極的なコード分割、モダンブラウザーのみを対象とする設定、パッケージごとのベンダーチャンクにより、初期ペイロードを減らし、キャッシュの粒度を高めます。
-
合成されるプロパティのみアニメーションしましょう。 GPU で処理できるよう、アニメーションは
transformとopacityに限定します。width、height、marginなど、レイアウトを発生させるプロパティはアニメーションさせないでください。
テクニック
ローカルファーストのデータアーキテクチャを構築する
ワークスペースを IndexedDB に保存し、メモリー内の監視可能なストア(Linear の場合は MobX)にハイドレートします。UI はサーバーではなく、ローカルストアにクエリします。
// A traditional web app updating the server
async function updateIssue({ issue }) {
showSpinner();
const response = await fetch(`/api/issues/${issue.id}`, {
method: "PATCH",
body: JSON.stringify({ title: issue.title }),
});
const updated = await response.json();
setIssue(updated)
hideSpinner();
}
// vs Linear
issue.title = "Faster app launch";
issue.save();
1 行目では、メモリー内のデータストア(MobX の監視可能な状態)を更新します。2 行目では、同期エンジンがまとめてサーバーに送信するトランザクションをキューに追加します。ローカルの更新に基づいて UI が同期的に再レンダリングされるため、スピナーも待ち時間もありません。
標準ライブラリーで楽観的更新を実装する
カスタムの同期エンジンを構築しない場合は、楽観的更新に対応した Tanstack Query や SWR などのライブラリーを使います。
// optimistic mutation with SWR
mutate(
`/api/issues/${issue.id}`,
{ ...issue, title: "Faster app launch" },
false
);
// vs Linear
issue.title = "Faster app launch";
issue.save();
重要なのは、UI の応答性をネットワーク遅延に左右されないようにすることです。ユーザーが速度を感じるのは、サーバーの応答速度ではなく、インターフェースがどれだけ早く反応するかによります。
モダンブラウザー向けビルドでバンドルサイズを削減する
モダンブラウザーのみを対象にし、レガシーなポリフィルを削除して、積極的にコードを分割します。
// vite.config.ts (reconstruction; matches observed chunk graph)
export default defineConfig({
plugins: [react()],
build: {
target: "esnext", // no legacy syntax, no polyfills
cssMinify: "lightningcss",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
// One chunk per npm package > ~3 KB. Cache invalidation
// becomes per-library instead of per-app-revision.
manualChunks(id) {
if (id.includes("node_modules")) {
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg) return `vendor-${pkg}`;
}
},
},
},
},
});
npm パッケージをすべて個別のチャンクに分割します。従来の vendor.js では、依存関係に変更があると依存関係グラフ全体が無効になります。パッケージごとにチャンクを分割すれば、キャッシュの無効化を細かく制御できます。
重要なチャンクを並列でプリロードする
<head> で重要なチャンクをすべて宣言し、「フェッチ → 解析 → インポートのフェッチ」というウォーターフォールをなくします。
<script type=module crossorigin
src="https://static.linear.app/client/assets/html.2_JBQs3Q.js"></script>
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/vendor-mobx.Crhy2qQc.js">
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/SyncWebSocket.Djw6l_Op.js">
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/DatabaseManager.DKssGAN8.js">
各プリロードの crossorigin 属性をエントリースクリプトの crossorigin と一致させることで、ブラウザーがキャッシュ済みのフェッチを再利用します。コールドロードのタイムラインは、順次実行されるウォーターフォールから、単一の並列バッチに短縮されます。
残りのアセットをサービスワーカーで事前キャッシュする
最初のページ読み込み後、サービスワーカーを使ってバックグラウンドでルートレベルのチャンクを事前キャッシュします。
// The service worker has a precache manifest baked into its source,
// around 1,200 hashed assets covering route chunks, icons, and fonts.
// It pulls them down lazily after the first page load.
ログイン画面を表示してから数秒以内に、アプリ全体がキャッシュに保存されます。以降のナビゲーションではネットワークを完全にスキップし、サービスワーカーがキャッシュから直接応答します。IndexedDB と組み合わせることで、アプリをオフラインで使えるようになります。
重要な CSS と起動処理をインライン化する
読み込み状態を描画するのに必要な最小限の CSS をインライン化し、外部スタイルシートのフェッチを避けます。
<style>
:root {
--bg-color: #f5f5f5;
--bg-base-color: #fcfcfd;
--bg-border-color: #e0e0e0;
--sidebar-width: 244px;
}
html { background: var(--bg-color); height: 100%; }
body { font-family: "Inter Variable", Arial, Helvetica, sans-serif; }
#appBorders {
border: 1px solid var(--bg-border-color);
background: var(--bg-base-color);
margin: 8px 8px 8px var(--sidebar-width);
border-radius: 12px;
}
#logo { transform: translateZ(0); }
@keyframes logoBackgroundPulse {
0% { opacity: 0; transform: scale(0.8); }
70% { opacity: 1; }
100% { opacity: 0; transform: scale(1.0); }
}
</style>
<script>performance.mark("appStart");</script>
インライン JavaScript で、最後に使用したシェルのトークン(サイドバーの背景色、幅、ダークモード)を描画前に復元します。
<script>
// Electron context — lets CSS branch on native chrome.
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");
// No local store → no workspace data → render the auth layout.
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");
// Restore last-known shell tokens (sidebar bg, width, dark mode) before paint.
const c = JSON.parse(localStorage.getItem("splashScreenConfig") || "{}");
if (c.bgSidebarColor) document.documentElement.style.setProperty("--bg-sidebar-color", c.bgSidebarColor);
if (c.sidebarWidth) document.documentElement.style.setProperty("--sidebar-width", c.sidebarWidth + "px");
if (c.darkMode) document.documentElement.classList.add("dark");
// Compact sidebar to a sliver when the user opens links in the desktop app.
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");
</script>
最初の JavaScript バンドルが読み込まれる頃には、読み込み画面のテーマ、サイズ、位置がすでに正しく設定されています。
フォントの読み込みを最適化する
二重取得を避けるため、適切な CORS モードで可変フォントをプリロードします。
<link rel="preload"
href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1"
as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preconnect" href="https://static.linear.app" crossorigin>
@font-face {
font-family: "Inter Variable";
font-weight: 100 900;
font-display: swap;
src: url(https://static.linear.app/fonts/InterVariable.woff2?v=4.1)
format("woff2");
}
/* Italic and Berkeley Mono follow the same shape, single woff2 each. */
可変フォントは、1 つの woff2 で 100〜900 のウェイト軸全体をカバーします。font-display: swap を指定すると、フォールバックのフォントスタックをすぐに表示し、Inter の読み込み後に切り替えます。プリロードに crossorigin="anonymous" を指定すると、CSS からフォントが参照されたときにブラウザーがキャッシュ済みのフォントを再利用します。
認証済みと仮定し、バックグラウンドで検証する
セッションの検証を待って処理をブロックするのではなく、ローカルデータが存在するか確認します。
if (localStorage.getItem("ApplicationStore") === null) {
document.documentElement.classList.add("logged-out");
}
ApplicationStore が存在する場合、そのユーザーは以前にアプリを使ったことがあり、ワークスペースは IndexedDB にあります。すぐに表示します。実際のセッショントークンは Cookie にあります。セッションが期限切れの場合、次のリクエスト(WebSocket ハンドシェイク、同期差分、任意の HTTP 呼び出し)が 401 で失敗した時点でログイン画面にリダイレクトします。
このフローで確認するのは「表示できるものがあるか」であり、「有効なセッションがあるか」ではありません。
細粒度のオブザーバブルでピンポイントに再レンダリングする
すべてのモデルの各プロパティを個別のオブザーバブルにします。サーバーから差分が届いたら、対応するオブザーバブルに書き込みます。フレームワーク(MobX)は、どのコンポーネントがどのフィールドに依存しているかを正確に把握し、該当するコンポーネントだけを再レンダリングします。
1 つの課題の 1 つのフィールドを更新する変更では、そのフィールドを読み取るコンポーネントだけが再レンダリングされます。親のリストでも、サイドバーでもなく、1 つのセルだけです。50 件の課題の更新では、リストの再レンダリングではなく、50 個のセルの再レンダリングが行われます。
キーボードを優先した操作を設計する
よく使う操作すべてにショートカットを用意します。単一のキーでフォーカス中の課題を編集し、2 文字の組み合わせで移動し、修飾キーでグローバル操作を実行できるようにします。UI のあらゆる場所でショートカットを見えるようにします。
アクション、課題、プロジェクト、ラベル、ステータス変更、ナビゲーション、設定を検索できるグローバルコマンドパレット(⌘ k)を実装します。検索はローカルの MobX オブジェクトプールを対象に行うため、サーバーリクエストは不要で、結果はすぐに表示されます。
合成されるプロパティだけをアニメーションする
ブラウザーでのプロパティ変更には、次の 3 つの段階があります。
- 合成されるプロパティ(
transform、opacity):GPU で高速化され、メインスレッドから独立します - ペイントを発生させるプロパティ(
color、background-color、border-color、fill):レイアウトをスキップしますが、ピクセルを再描画します - レイアウトを発生させるプロパティ(
width、height、top、left、margin、padding):後続するすべての要素の再計算を強制します
レイアウトを発生させるプロパティはアニメーションしないでください。
/* What Linear does */
.row:hover {
background-color: var(--color-bg-hover);
transition: background-color 0.12s;
}
.icon-arrow {
transform: translateX(0);
transition: transform 0.15s;
}
/* What you'd write if you didn't know better */
.row:hover {
margin-left: 2px; /* triggers layout for every row beneath */
transition: all 0.2s; /* and now you're animating margin */
}
アニメーション時間を短く保つ
業界標準より短いトランジションをデフォルトにします。
/* variables form Linear's stylesheet */
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;
非対称なタイミングを使います。呼び出されたときは即座に表示し、閉じられたときは 150ms かけてフェードアウトします。これにより、ユーザーの意図にすぐ応えるインターフェースになります。
アンチパターン
❌ UI を更新する前にネットワークリクエストを待たないでください。 まずローカルで変更を適用し、バックグラウンドで同期して、サーバーが拒否した場合にのみロールバックします。
❌ width、height、margin、padding、top、left など、レイアウトを発生させるプロパティをアニメーションしないでください。 transform と opacity を使います。
❌ 初回レンダリングを認証のためにブロックしないでください。 ローカルデータがある場合はすぐに表示し、非同期でセッションを検証します。
❌ すべてのベンダーコードを 1 つのチャンクにまとめないでください。 npm パッケージごとに独自のチャンクに分割し、キャッシュの無効化がアプリのリビジョン単位ではなく、ライブラリ単位になるようにします。
❌ 頻繁に行われる操作に長いアニメーション時間(>250ms)を使わないでください。 トランジションを短くするとアプリがより速く感じられます。長い時間は、頻度が低く影響の大きい場面に限って使います。
❌ すでにローカルにあるデータを、ナビゲーションのたびに取得しないでください。 起動時に IndexedDB からデータを読み込み、重いテーブルは必要に応じて遅延読み込みします。サーバーは UI の信頼できる情報源ではなく、同期先として扱います。