Skip to content

필사 모드: Local-First Software Design — An Alternative Architecture for the Age of Cloud Fatigue

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

Introduction — Cloud Fatigue as the Backdrop of the Era

If you had to summarize the mood of the tech community in the first half of 2026 in one phrase, it would be "big tech fatigue." On Hacker News, posts about leaving Gmail for self-hosted mail or small paid services regularly hit the front page, and the news that DuckDuckGo no-AI search traffic surged — driven by users who want search without AI summaries wedged in — made the rounds as well. On GeekNews, posts on the theme of "where is my data, really" consistently stay near the top.

It is the cumulative result of familiar experiences: when a subscription service shuts down, your documents vanish with it; when a server has an outage, you cannot work even though your own computer is fine; and one terms-of-service change later, your data becomes AI training material. The technical answer being summoned back in response to this fatigue is local-first software. Articulated in a 2019 essay by the Ink and Switch research lab, the concept is a design philosophy that tries to capture both the collaboration convenience of the cloud and the ownership of local software.

This post goes beyond introducing the manifesto. It organizes, from an architecture standpoint, the problems you actually hit when designing a local-first product — choosing a sync engine, conflict resolution, the boundary of the server role, and schema evolution.

The Seven Ideals of Local-First

The Ink and Switch essay laid out seven ideals that local-first software should satisfy.

1. Fast. No spinners: every operation should be served instantly from local data.

2. Multi-device. Your data should be accessible from every device you use.

3. The network is optional. Offline should be a supported default, not an abnormal state.

4. Seamless collaboration. Real-time co-editing at the level of Google Docs should be possible.

5. The Long Now. Your data and software should keep working even if the vendor disappears.

6. Security and privacy by default. End-to-end encryption should keep even the server operator from reading content.

7. You retain ultimate ownership and control. Legal and technical control of the data belongs to the user.

The key insight is this: ideals one through three can be imitated with caches and offline modes, but the moment you combine them with number four (collaboration), the difficulty explodes — because you must merge data modified concurrently on multiple devices. The essence of local-first architecture is, in the end, "bringing the merge problem of distributed systems down to the client."

The Architectural Shift — From Server-Centric to Local-First

Comparing the data flow of traditional SaaS and local-first makes the difference obvious.

[Traditional cloud SaaS]

client A server (source of truth) client B

UI ---- request ---> DB is the only state <--- request ---- UI

UI <--- response --- all reads and writes ---- response --> UI

* when the network drops, nothing works

* latency floor of the UI = round trip time (RTT)

[Local-first]

client A client B

UI -- instant read/write --> local DB local DB <-- instant read/write -- UI

| |

+-- sync engine -+

|

server = sync relay

(store, forward, back up)

* the UI only ever talks to the local DB (latency = disk access)

* the server is a post office that relays and archives changes

What this shift means, point by point:

First, the source of truth moves. The server DB is no longer the single truth; each client replica becomes a first-class citizen. The server role shrinks to a relay of change logs and a durable archive.

Second, the write path becomes asynchronous. A user write commits locally at once, and synchronization happens in the background. "Write success" therefore splits into two stages — locally durable, fully propagated — and the UI must express the distinction.

Third, conflicts become normal. In the server-centric model, conflicts were exceptions prevented by transactions and locks; in local-first they are an everyday certainty, and the merge strategy becomes the center of the design.

Inside a Sync Engine

The sync engine is the heart of a local-first stack. A typical composition looks like this.

+------------------- client --------------------+

| UI (React, Swift, ...) |

| | subscribe (queries) ^ reactive updates |

| v | |

| local store (SQLite, IndexedDB, OPFS) |

| | change capture (oplog) ^ apply remote ops|

| v | |

| sync client (retry, backoff, compaction) |

+-----------|----------------^------------------+

v (push) | (pull / live stream)

+-----------|----------------|------------------+

| sync server: stores change log, orders it, |

| fans out, filters by permission, |

| compacts into snapshots |

+-----------------------------------------------+

There are four major axes to decide at design time.

1. Sync granularity. Whole documents, rows, or individual operations. The finer the unit, the more precise the conflict resolution, but the higher the metadata cost.

2. Transport model. What combination of periodic pull, live WebSocket streams, and push-notification triggers.

3. Partial sync. If the client cannot hold the full dataset, how does it declaratively subscribe to a subset? This is where recent engines are converging on "query-driven sync."

4. Compaction and snapshots. The change log grows without bound, so when do you fold it into snapshots and discard old entries?

CRDTs — Just the Essentials, from a Design Perspective

The internal algorithms of CRDTs (Conflict-free Replicated Data Types) are covered in a separate post on this blog, so here we touch only on what a designer must know.

A CRDT is a data structure with the mathematical property that all replicas converge to the same state no matter the merge order. Implementations exist per type — counters, sets, lists, text — with Automerge and Yjs as the flagship libraries. Merging without a central coordinator is the appeal, but from a design standpoint you must know these limits.

- Convergence is not intent preservation. If two people move the same todo into different folders, the CRDT will converge to some result, but that result may match neither person's intent.

- Invariants are hard to guarantee. Global invariants such as "the balance can never go negative" cannot be upheld by local merging alone. Such domains need server arbitration.

- There is a metadata cost. Deletion residue (tombstones) and causality-tracking information accumulate, and text CRDTs can carry internal state far larger than the document itself. Recent implementations have improved a lot, but it is not free.

The practical conclusion is simple. Use CRDTs where intent conflicts are frequent and real-time matters — co-edited text, whiteboards — and use simpler strategies for structured business data. The hybrid design is the mainstream.

Three Conflict Resolution Strategies Compared

| Strategy | How it works | Strengths | Weaknesses | Best-fit data |

| --- | --- | --- | --- | --- |

| LWW | the write with the newest timestamp wins | simple to build, minimal metadata | one side of a concurrent edit is silently lost | settings, single-field updates |

| CRDT | automatic convergence via mathematical merge | lossless offline merging, no server needed | intent not guaranteed, state can bloat | co-edited documents, drawing |

| Server arbitration | server receives changes and reorders them by rules | can enforce invariants, easy to validate | no offline finality, server dependent | inventory, payments, permission changes |

Real products mix all three by data kind. Note bodies via CRDT, note titles and tags via LWW, sharing-permission changes via server arbitration — this kind of partition is the standard pattern.

The Sync Engine Ecosystem

As of 2026, the menu of options has grown rich. Here is a summary within the scope of what I have actually evaluated.

| Engine | Approach | Local store | Server side | One-line summary |

| --- | --- | --- | --- | --- |

| ElectricSQL | streams Postgres changes to clients | client choice | sync service in front of Postgres | focused on read-path sync, writes via your own API |

| Zero | query-driven sync | own client cache | own cache server plus Postgres | subscribe to a query and the result stays reactive |

| PowerSync | mirrors existing DBs such as Postgres into SQLite | SQLite | sync service | strong for gradual adoption while keeping your backend |

| Automerge | CRDT document library | own document format | relay servers stay simple | Ink and Switch lineage, suited to document models |

| Yjs | CRDT library | own types | relay servers stay simple | richest editor-integration ecosystem |

| Triplit | full-stack sync database | own store | own server | query subscriptions and permissions in one body |

Compressing the selection criteria into one line each: if you have an existing Postgres backend and want gradual adoption, look at PowerSync or ElectricSQL; if a collaborative editor is the core, Yjs or Automerge; if you are building a new product local-first from day one, integrated options like Zero or Triplit are the candidates.

The Server Does Not Disappear — Designing the Boundary

The name local-first invites a misunderstanding: the server does not go away. Its role changes. The following areas remain on the server structurally.

- Authentication. Identity verification inherently requires third-party attestation. Token issuance and refresh belong to the server.

- Authorization. "Who may see this document" must be enforced by the sync server at fan-out time. Client-side filtering is not security.

- Billing. Payments and subscription state are the canonical example of a global invariant; server arbitration is mandatory.

- Heavy computation. Full-text index building, AI inference, and large media transcoding are better done on servers or edges, with results pushed down through sync.

The practical principle of boundary design is "split writes into two classes." Writes that can be finalized locally (document edits, personal settings) go through the sync engine; writes that need server finality (sharing, payment, invitations) go through traditional API calls — but the resulting state is then propagated to all devices via sync. Separating these two paths from the start saves a great deal of confusion later.

The two-pronged write path, summarized as a picture:

user action

|

+-- locally finalizable? -- yes --> commit to local DB --> oplog --> sync engine

| (instant UI update) (background spread)

|

+-- needs server finality? -- yes --> API call --> server validates/commits

| |

on failure: offline state change fans out

queue (retry policy) to all devices via sync

Offline UX Patterns

Even if the architecture supports offline, users will distrust the app if the UX does not keep up. A few proven patterns:

1. Make optimistic UI the default, and show sync status quietly in one place. Instead of attaching a spinner to every document, a single "last synced at" indicator in the status bar is enough.

2. Do not dump conflicts on the user. Default to automatic merging, and offer a "view both versions" affordance only when truly needed. An app that shows merge dialogs often has a design problem.

3. Clearly distinguish server-finality actions while offline. For example, the share-invite button should visibly queue as "will send when connected."

4. Show progress for the first sync (initial download), and after that make the sync process as invisible as possible.

Choosing a Local Store — The Reality on the Web

Native apps settle on SQLite, but the web splits into options.

| Store | Nature | Strengths | Weaknesses |

| --- | --- | --- | --- |

| IndexedDB | built-in browser KV with indexes | works everywhere, zero extra dependency | awkward API, bulk write performance limits |

| SQLite Wasm plus OPFS | run SQLite in the browser | real SQL, code shared with native | Wasm bundle size, worker setup complexity |

| OPFS directly | origin-private file system | fast file IO | low level, you design your own format |

The 2026 trend clearly leans toward the SQLite Wasm and OPFS combination. The decisive factor is that native apps, servers, and the web can share the same SQL schema and queries. Sync engines are also trending toward supporting SQLite as a first-class target.

Schema Evolution — The Hidden Hard Problem of Local-First

In a server-centric app, a migration runs once at deploy time and you are done. In local-first, an old client can come back months later carrying offline changes. The design principles are:

- Stamp the schema version into the data. Include a schema version in every change-log entry so the receiver knows which transformation to apply.

- Prefer forward compatibility. Adding fields is safe; removing fields or changing meanings deserves caution. Use a deprecation period instead of deletion.

- Transform at read time. Rather than bulk-rewriting stored data, lazily upgrading old records to the new shape when read plays well with distributed environments.

- Make breaking changes as new collections. If you truly must break, create a new table, run a dual-write period, and migrate over — the classic distributed-systems method.

A Hands-On Mini Design — The Note App Scenario

Let us apply the principles so far to a small note app. Requirements: multi-device sync, offline editing, note sharing.

First the data model. Conflict strategies are split by data kind.

-- local SQLite schema (identical on every client)

CREATE TABLE notes (

id TEXT PRIMARY KEY, -- UUIDv7: sortable by creation time

title TEXT NOT NULL, -- strategy: LWW

body_crdt BLOB NOT NULL, -- strategy: CRDT (Yjs document binary)

folder_id TEXT, -- strategy: LWW

updated_at INTEGER NOT NULL, -- hybrid logical clock (HLC)

deleted INTEGER DEFAULT 0, -- soft delete (tombstone)

schema_v INTEGER DEFAULT 1

);

CREATE TABLE oplog (

seq INTEGER PRIMARY KEY AUTOINCREMENT,

note_id TEXT NOT NULL,

op_type TEXT NOT NULL, -- upsert_meta | crdt_update | delete

payload BLOB NOT NULL,

hlc TEXT NOT NULL, -- hybrid clock for conflict comparison

synced INTEGER DEFAULT 0

);

The skeleton of the sync client looks like this.

// sync-client.ts — a simplified sync loop

async function syncLoop(db: LocalDB, server: SyncTransport) {

while (true) {

// 1. push: send unsent local ops to the server

const pending = await db.query(

"SELECT * FROM oplog WHERE synced = 0 ORDER BY seq LIMIT 100"

);

if (pending.length > 0) {

await server.push(pending);

await db.markSynced(pending.map((p) => p.seq));

}

// 2. pull: receive remote ops after our server cursor

const remote = await server.pull(db.getCursor());

for (const op of remote) {

applyRemoteOp(db, op); // apply the merge rules below

}

await db.setCursor(remote.cursor);

await server.waitForChangeOrTimeout(30_000); // live notify or fallback

}

}

function applyRemoteOp(db: LocalDB, op: RemoteOp) {

switch (op.op_type) {

case "crdt_update":

// CRDT merge: order independent, always safe

db.mergeCrdt(op.note_id, op.payload);

break;

case "upsert_meta":

// LWW merge: apply only if the HLC is newer

db.applyIfNewer(op.note_id, op.payload, op.hlc);

break;

case "delete":

db.softDelete(op.note_id, op.hlc);

break;

}

}

Sharing is split off onto the server-finality path.

// share invitation: an example of a write that needs server arbitration

async function inviteCollaborator(noteId: string, email: string) {

// a traditional API call — the server updates the permission table

// and propagates the result to every participant device via sync

const res = await api.post("/shares", { noteId, email });

if (!res.ok) {

enqueueRetry({ kind: "invite", noteId, email }); // offline queue

}

}

Every principle discussed above is packed into this small design. Body via CRDT, metadata via LWW and HLC, permissions via server arbitration, deletion via tombstones, schema version embedded in records. A real product would add snapshot compaction and partial sync (folder-level subscriptions) on top.

Going Deeper on Partial Sync — The Query Subscription Pattern

You cannot make the mobile device of a user with one hundred thousand notes download everything. The answer recent sync engines are converging on is the declarative query subscription. The client declares "the subset I want to see" as a query, and the engine materializes that result set locally and keeps applying changes to it.

// a conceptual example of the query subscription pattern

// keep locally only "notes in my folders edited in the last 30 days"

const subscription = syncEngine.subscribe({

table: "notes",

where: {

folder_id: { in: myFolderIds },

updated_at: { gte: daysAgo(30) },

},

include: ["attachments_meta"], // attachments: metadata only

});

// the subscription is reactive — remote changes refresh the UI

subscription.onChange((rows) => renderNoteList(rows));

// unsubscribe when leaving the screen → manage local cache lifetime

subscription.unsubscribe();

Three caveats when designing this pattern.

First, movement across the subscription boundary. When a note moves to a folder outside the subscribed range it must disappear locally, which can look like deletion to the user. The UI must distinguish a "not downloaded" state.

Second, coupling permissions with subscriptions. The server must apply permission filters whenever it evaluates a subscription query. The moment you trust a client-sent query, you have opened a data exfiltration channel.

Third, large blobs such as attachments. Do not put blobs on the oplog; sync only their metadata and lazily download content by content address (hash). That is the established practice.

A Testing Strategy for Sync Code

Sync bugs are notoriously hard to reproduce. Fortunately, local-first architecture has one test-friendly property: merge logic is close to a pure function. I recommend stacking tests in three tiers.

// tier 1: property-based tests of the merge rules

// verify "any application order yields the same final state" with random op sequences

test("convergence: any order of ops yields identical state", () => {

const ops = genRandomOps(50); // generate 50 random operations

const stateA = applyAll(emptyDb(), shuffle(ops));

const stateB = applyAll(emptyDb(), shuffle(ops));

expect(canonical(stateA)).toEqual(canonical(stateB));

});

// tier 2: scenario tests — two fake clients and a fake server

test("offline edit on two devices merges without loss", async () => {

const server = new FakeSyncServer();

const alice = new FakeClient(server);

const bob = new FakeClient(server);

await alice.sync();

await bob.sync();

alice.goOffline();

bob.goOffline();

alice.editBody("note1", "sentence added by alice");

bob.editBody("note1", "different paragraph edited by bob");

alice.goOnline();

await alice.sync();

bob.goOnline();

await bob.sync();

await alice.sync(); // receive bob's changes

expect(alice.read("note1")).toEqual(bob.read("note1"));

expect(alice.read("note1")).toContain("sentence added by alice");

expect(alice.read("note1")).toContain("different paragraph edited by bob");

});

Tier 3 is chaos testing. Randomly inject network partitions, duplicate message delivery, reordering, and client kills, and verify convergence and zero data loss over long runs. Even if you use an off-the-shelf engine rather than building your own, you should still own tier 1 and tier 2 tests for your own merge rules — LWW field selection, deletion policy.

A Decision Framework — Does It Fit Our Product

Finally, a practical question list for judging whether to adopt local-first. Answer honestly.

1. Is the owner of the data clearly the individual user (or a small team)? If yes, fit is high. For a company-wide shared dashboard, it is low.

2. Do offline or slow-network scenarios actually exist? If you cannot picture concrete scenes — notes on the move, airplanes, field work — the cost-benefit is weak.

3. Are global invariants the core value? If "only one at a time," as in seat reservations, is the essence, server-centric is right.

4. Is real-time collaboration on the roadmap? If so, shaping a CRDT-friendly data model from the start is overwhelmingly cheaper than retrofitting later.

5. Can the team handle distributed-systems debugging? You need at least one person fluent in HLCs, causal ordering, and idempotency.

6. Does one user's data fit on the device? Beyond a few gigabytes, partial sync design becomes mandatory and the difficulty steps up.

If questions one, two, and four are "yes," it is worth serious consideration; if question three is "yes," consider a hybrid design that carves only that domain out for server arbitration.

The Business Model Perspective

Local-first is a business choice as much as a technical one. The lock-in effect of holding data hostage weakens, so you must charge for other value. Three models validated in the market:

1. Charging for the sync service. The software is free, but multi-device sync and collaboration relay carry a subscription. Keep a self-hosting option open for the sync server while differentiating on convenience.

2. The return of one-time purchases. Aligned with the longevity ideal, the "buy once, use forever" model is regaining appeal among subscription-fatigued users.

3. Charging for team and enterprise features. Open personal use for free and charge for features where the server is intrinsically required — permission management, audit logs, SSO. It is a clean structure that aligns the server-mandatory area with the billing area.

Paradoxically, because local-first lowers the exit barrier, you must retain users through product quality — and the community consensus is that this is the healthier incentive structure in the long run.

Limitations and Critical Perspectives

For balance, the counterarguments.

The complexity cost is real. Server-centric CRUD has decades of refined patterns and tools, whereas local-first adds the complexity of sync, merging, and schema evolution as a constant tax on every feature. Assess soberly whether a small team can carry it.

It does not fit every app. Domains where global invariants are the core — banking, inventory, reservations — are intrinsically server-arbitrated and gain little from local-first. Conversely, domains centered on user-owned data — documents, notes, design, personal knowledge management — are the sweet spot.

End-to-end encryption and collaboration features are in tension. If the server cannot read content, server-side search, spam filtering, and AI features get hard. Products that satisfy all seven ideals at once are still rare; most consciously give some up.

The ecosystem is still young. Sync engine APIs are changing fast, and long-term operational case studies have not accumulated. Picking an engine that will still exist in five years is itself an exercise in risk management.

Closing

Local-first is not a binary choice between cloud and local — it is an architectural redesign that moves the source of truth toward the user and repositions the server as a relay. With the era's mood of cloud fatigue creating demand, and SQLite Wasm plus maturing sync engines creating supply, the local-first of 2026 has come down from idealist manifesto to practical technology stack.

The best way to start is to experiment small. Mirror one read path of an existing product locally with PowerSync or ElectricSQL, or build a side-project note app following the mini design above. Once you experience a UI with no spinners, the designs you have been stacking on top of round-trip latency will start to look different.

References

- Local-First Software essay by Ink and Switch: https://www.inkandswitch.com/local-first/

- Local-First Web community: https://localfirstweb.dev/

- Automerge documentation: https://automerge.org/

- Yjs documentation: https://docs.yjs.dev/

- ElectricSQL official site: https://electric-sql.com/

- PowerSync documentation: https://docs.powersync.com/

- Zero (Rocicorp) official site: https://zero.rocicorp.dev/

- Triplit documentation: https://www.triplit.dev/docs

- SQLite Wasm documentation: https://sqlite.org/wasm/doc/trunk/index.md

- Hacker News discussion of local-first software: https://news.ycombinator.com/item?id=23985816

- GeekNews main page: https://news.hada.io/

- CRDT technical resources: https://crdt.tech/

현재 단락 (1/257)

If you had to summarize the mood of the tech community in the first half of 2026 in one phrase, it w...

작성 글자: 0원문 글자: 20,417작성 단락: 0/257