Local-first software: offline apps and sync with CRDT

1. Overview

Most apps today treat the server as the “source of truth” and clients just display it. Lose network, and the app becomes nearly useless. Local-first software flips this: data lives on the user’s device first, and the server just helps sync.

This post explains what local-first is, why it matters, and the role of CRDT (Conflict-free Replicated Data Type) in merging data without locks or a “single winner.” By the end, you’ll build a few basic CRDTs in Java 21, create a sync endpoint with Spring Boot 3, and know when not to choose this approach.

2. What is local-first and why it matters

A local-first app typically has these traits:

  • Instant read/write on device: every write goes to local storage (SQLite, IndexedDB, files…) without waiting for a network round-trip.
  • Works offline: lose network and you still edit data. When online again, it syncs.
  • Multi-device, multi-person collaboration: replicas are edited concurrently and automatically converge to the same state.
  • Users own the data: if the server goes down, data still exists on devices.

Several reasons why this approach is getting more attention:

  1. User expectations: people are used to instant-response apps, no spinner after every click.
  2. Powerful devices: phones and laptops can run local databases and merging logic.
  3. Mature ecosystem: CRDT libraries like Automerge, Yjs, and sync engines for SQLite have made it easier to build this than write from scratch.
  4. Privacy and data sovereignty: keeping data on users’ devices reduces reliance on a central server.

The biggest challenge is sync. Two devices both edit offline—when they meet again, who’s right?

3. How CRDT resolves conflicts

A CRDT is a data structure where every replica, after exchanging updates, always converges to the same state, regardless of order. No server arbitration, no locks needed.

Two main families:

  • State-based (CvRDT): replicas send full state to each other and call merge.
  • Operation-based (CmRDT): replicas send each operation. The channel must ensure delivery exactly once.

This post uses state-based because it’s easier to explain and demo. For convergence, merge must satisfy three properties:

Commutative   : merge(a, b) == merge(b, a)
Associative   : merge(merge(a, b), c) == merge(a, merge(b, c))
Idempotent    : merge(a, a) == a

With these three properties, late, out-of-order, or duplicate messages don’t corrupt the result.

4. Building basic CRDTs

The examples below use only the JDK. The Spring Boot section in part 5 needs extra dependencies.

4.1. G-Counter: increment-only counter

Say you want to count views on an Order across devices. Each replica only increments its own slot. On merge, take the maximum of each slot.

import java.util.HashMap;
import java.util.Map;

public final class GCounter {
    private final Map<String, Long> counts = new HashMap<>();

    public void increment(String replicaId) {
        counts.merge(replicaId, 1L, Long::sum);
    }

    public long value() {
        return counts.values().stream().mapToLong(Long::longValue).sum();
    }

    public void merge(GCounter other) {
        other.counts.forEach((id, n) -> counts.merge(id, n, Math::max));
    }
}

increment only touches its own slot, so replicas never overwrite each other. merge uses Math::max, which is commutative, associative, and idempotent. Quick test:

GCounter phone = new GCounter();
GCounter laptop = new GCounter();
phone.increment("phone");
phone.increment("phone");
laptop.increment("laptop");

phone.merge(laptop);
laptop.merge(phone);
phone.merge(laptop); // merging again doesn't corrupt the result
System.out.println(phone.value() + " " + laptop.value());
3 3

To support decrement, use PN-Counter: two G-Counters, one for increments and one for decrements; the value is their difference.

4.2. LWW-Register: “last write wins” value

For a single field like Customer.displayName, the simplest approach is to keep the value with the newest timestamp. On tie, break by replicaId so the result is deterministic.

public record LwwRegister<T>(T value, long timestamp, String replicaId) {

    public LwwRegister<T> merge(LwwRegister<T> other) {
        if (other.timestamp != timestamp) {
            return other.timestamp > timestamp ? other : this;
        }
        return other.replicaId.compareTo(replicaId) > 0 ? other : this;
    }
}
var a = new LwwRegister<>("Lan", 100, "phone");
var b = new LwwRegister<>("Lan Nguyen", 105, "laptop");
System.out.println(a.merge(b).value() + " | " + b.merge(a).value());
Lan Nguyen | Lan Nguyen

Note: Device clocks drift. A machine running fast by a few minutes will “win” every write. In practice, use Hybrid Logical Clock or Lamport clock instead of raw timestamps.

LWW is simple but silently loses one of two concurrent edits. For collaborative text, this is unacceptable. You’d need a string CRDT like RGA or algorithms used by Automerge and Yjs.

4.3. OR-Set: add/remove set

Say you have a shared shopping list. A naive implementation breaks when one device deletes “Milk” while another is adding it again. Observed-Remove Set solves this by tagging each add with a unique ID. remove only deletes tags this replica has seen.

import java.util.*;

public final class ORSet {
    private final Map<String, Set<String>> adds = new HashMap<>();
    private final Map<String, Set<String>> removes = new HashMap<>();

    public void add(String item) {
        adds.computeIfAbsent(item, k -> new HashSet<>()).add(UUID.randomUUID().toString());
    }

    public void remove(String item) {
        removes.computeIfAbsent(item, k -> new HashSet<>())
               .addAll(adds.getOrDefault(item, Set.of()));
    }

    public Set<String> elements() {
        Set<String> result = new TreeSet<>();
        adds.forEach((item, tags) -> {
            Set<String> alive = new HashSet<>(tags);
            alive.removeAll(removes.getOrDefault(item, Set.of()));
            if (!alive.isEmpty()) result.add(item);
        });
        return result;
    }

    public void merge(ORSet other) {
        union(adds, other.adds);
        union(removes, other.removes);
    }

    private static void union(Map<String, Set<String>> target, Map<String, Set<String>> source) {
        source.forEach((k, v) -> target.computeIfAbsent(k, x -> new HashSet<>()).addAll(v));
    }

    public ORSetState toState() {
        return new ORSetState(adds, removes);
    }

    public static ORSet fromState(ORSetState state) {
        ORSet set = new ORSet();
        union(set.adds, state.adds());
        union(set.removes, state.removes());
        return set;
    }
}
import java.util.Map;
import java.util.Set;

public record ORSetState(Map<String, Set<String>> adds, Map<String, Set<String>> removes) {}

How it works:

  • add generates a new UUID each time, even when re-adding the same item.
  • remove copies the current tags of the item to removes. Tags added by other replicas that this one hasn’t seen yet stay alive.
  • elements returns items with at least one tag not removed. Result: when add and remove happen concurrently, add wins (add-wins).
  • merge is just set union, satisfying all three properties from part 3.
  • ORSetState is a simple record for Jackson to serialize to JSON during sync.

5. Syncing over a server with Spring Boot

In local-first, the server doesn’t decide who’s right. It’s just an always-online replica that helps devices exchange state. Add the web dependency (version managed by spring-boot-starter-parent):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

The controller receives state from the client, merges it with the server copy, and returns the merged state:

import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@RestController
@RequestMapping("/lists/{listId}")
public class ShoppingListSyncController {

    private final Map<String, ORSet> store = new ConcurrentHashMap<>();

    @PostMapping("/sync")
    public synchronized ORSetState sync(@PathVariable String listId,
                                        @RequestBody ORSetState clientState) {
        ORSet serverCopy = store.computeIfAbsent(listId, id -> new ORSet());
        serverCopy.merge(ORSet.fromState(clientState));
        return serverCopy.toState();
    }
}

The client does the reverse: send state up, receive state back, merge into its local copy. Clients can retry freely since merge is idempotent. Try with curl:

curl -s -X POST localhost:8080/lists/family/sync \
  -H "Content-Type: application/json" \
  -d '{"adds":{"Milk":["t1"],"Eggs":["t2"]},"removes":{"Eggs":["t2"]}}'
{"adds":{"Milk":["t1"],"Eggs":["t2"]},"removes":{"Eggs":["t2"]}}

The client calls elements() on the returned state and gets [Milk].

Note: synchronized and ConcurrentHashMap are for demo only. In production, persist state to a database and send only deltas, not full state. You also need auth and access control per listId.

6. Testing convergence behavior

The key test: two replicas edit concurrently, merge in any order, get the same result.

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class ORSetTest {

    @Test
    void concurrentAddAndRemove_convergeWithAddWins() {
        ORSet phone = new ORSet();
        phone.add("Milk");
        ORSet laptop = ORSet.fromState(phone.toState()); // initial sync

        phone.remove("Milk");   // offline: phone deletes
        laptop.add("Milk");     // offline: laptop re-adds
        laptop.add("Bread");

        ORSet a = ORSet.fromState(phone.toState());
        a.merge(laptop);
        ORSet b = ORSet.fromState(laptop.toState());
        b.merge(phone);

        assertThat(a.elements()).isEqualTo(b.elements())
                                .containsExactly("Bread", "Milk");
    }

    @Test
    void merge_isIdempotent() {
        ORSet list = new ORSet();
        list.add("Eggs");
        list.merge(ORSet.fromState(list.toState()));
        assertThat(list.elements()).containsExactly("Eggs");
    }
}

The first test verifies commutativity and add-wins. “Milk” survives because the laptop added it with a tag the phone never saw. The second checks idempotence. You should add property-based tests too—generate random operation sequences and merge orders to catch subtle convergence bugs.

7. Common pitfalls and when not to use it

Common mistakes:

  • Using system clock for LWW without handling drift. Result: “new” data gets overwritten by “old” data.
  • Metadata bloat: OR-Set keeps tombstones forever. You need garbage collection, which typically requires knowing all replicas received the delete.
  • Assuming convergence means business correctness: CRDTs ensure replicas match, not that the result makes sense. Two users edit a title and LWW silently drops one.
  • No schema migration: data lives long on devices, so old and new app versions must read each other’s state.

When not to use local-first and CRDTs:

  • Invariants requiring global coordination: account balance can’t go negative, inventory can’t oversell, each seat booked once. These need central coordination (transactions, locks), CRDTs can’t preserve them.
  • Data must be tightly controlled: regulations forbid storing it on devices, or access must be revoked instantly.
  • Simple CRUD app, always online: complexity cost exceeds benefit. REST API with optimistic locking suffices.
  • Huge data or shared by too many people: syncing full state to each device isn’t feasible.

In practice, use hybrid: collaborative parts (notes, lists, drafts) go local-first. Transactional parts (payments via PaymentService) still route through the server with strict checks.

8. Conclusion

Local-first puts data on the user’s device first, enabling instant response and offline work. CRDTs are the technical foundation letting multiple replicas edit concurrently and auto-converge, thanks to merge being commutative, associative, and idempotent.

In this post, you built G-Counter, LWW-Register, and OR-Set in Java 21, created a sync endpoint with Spring Boot, and tested convergence. For real products, prefer tested CRDT libraries. Apply local-first only to data that truly benefits, and keep globally-coordinated business logic server-side.

local-firstcrdtoffline-firstdistributed-systemsspring-boot