Local-First vs. Server-Centric
in Clojure

The Core Distinction

In a server-centric architecture, the server is the authority. Clients query it, receive state, render it, and push mutations back. The server reconciles conflicts; clients hold no durable state between requests. This is the dominant model for web applications and is well-supported across the Clojure stack.

In a local-first architecture, the client owns a replica of data that it reads and writes without a network round-trip. The network is used for synchronisation, not for answering queries. Availability is preserved even when the server is unreachable. Conflicts are resolved by a merge function rather than by serialisation through a single writer.

Property Server-Centric Local-First
Authoritative state Server database Local replica + CRDT merge
Read latency RTT + server processing In-process (microseconds)
Write latency RTT required for ACK Optimistic, synced async
Offline capability None (degraded UI) Full read/write
Conflict model Serialisable transactions / last-write-wins CRDT merge / operational transform
Clojure tooling Ring, Pedestal, XTDB, next.jdbc, re-frame Electric, Datahike, Automerge-clj, replicant

Server-Centric Pattern in Clojure

Canonical Stack

Ring/Pedestal handles HTTP; next.jdbc or HoneySQL bridges to a relational store; re-frame owns client-side state as a derived view of server responses. The server is the single writer; the client discards state on navigation or refresh.

Server: Ring + next.jdbc

(ns app.server
  (:require [ring.adapter.jetty :refer [run-jetty]]
            [ring.middleware.json :refer [wrap-json-body wrap-json-response]]
            [next.jdbc :as jdbc]
            [next.jdbc.sql :as sql]))

(def ds
  (jdbc/get-datasource {:dbtype "postgresql"
                        :dbname "myapp"
                        :host   "localhost"}))

(defn get-document [id]
  (sql/get-by-id ds :documents id))

(defn update-document! [id body]
  (sql/update! ds :documents body {:id id}))

(defn app [req]
  (case [(:request-method req) (:uri req)]
    [:get  "/doc"] {:status 200 :body (get-document (:id (:params req)))}
    [:post "/doc"] (do (update-document! (:id (:params req)) (:body req))
                       {:status 204})
    {:status 404}))

(run-jetty (-> app wrap-json-body wrap-json-response)
           {:port 3000 :join? false})

Client: re-frame

re-frame models client state as a single app-db map. Events trigger effects; subscriptions derive views. Remote state is fetched on demand and stored in app-db; it is not authoritative — it is a cache.

(ns app.client.events
  (:require [re-frame.core :as rf]
            [ajax.core :refer [GET POST]]))

;; Effect handler: fires HTTP, dispatches on success
(rf/reg-event-fx ::fetch-doc
  (fn [{:keys [db]} [_ id]]
    {:http-xhrio
     {:method          :get
      :uri             (str "/doc?id=" id)
      :response-format (ajax.core/json-response-format {:keywords? true})
      :on-success      [::doc-loaded]
      :on-failure      [::fetch-failed]}}))

(rf/reg-event-db ::doc-loaded
  (fn [db [_ doc]]
    (assoc db :current-doc doc)))

;; Subscription — purely derived, no side effects
(rf/reg-sub ::doc-title
  (fn [db] (get-in db [:current-doc :title])))
Conflict Exposure
Two users editing the same document in parallel produce a last-write-wins outcome unless the server implements optimistic locking (e.g., a version column checked during UPDATE). Add WHERE id = ? AND version = ? and return HTTP 409 on mismatch if you care about this.

XTDB as a Server-Centric Store

XTDB gives you bitemporality and Datalog queries while remaining firmly server-centric. The node is the authority; clients submit transactions via HTTP or the Java client. Bitemporal history is free.

(ns app.xtdb
  (:require [xtdb.api :as xt]))

(def node
  (xt/start-node {}))

;; Submit a document
(xt/submit-tx node
  [[::xt/put {:xt/id    :doc/42
               :title   "Architecture Notes"
               :version 1}]]))

;; Query — server evaluates, client receives results
(xt/q (xt/db node)
  '{:find  [title]
    :where [[doc :title title]
             [doc :version v]
             [(> v 0)]]})

Local-First Pattern in Clojure

What "local-first" requires

A local-first system requires: (1) a local data store that can be read and written synchronously without a server, (2) a sync protocol that propagates changes between replicas, and (3) a conflict-resolution strategy that does not require a central sequencer. CRDTs satisfy (3) by design; operational transforms are an alternative with tighter semantics for text.

Datahike as a local replica

Datahike is an immutable Datalog database that runs in-process — in the JVM or, via DataScript, in ClojureScript. It supports a schema, transactions, and queries identical to Datomic's API, and can be backed by an in-memory store, a file store, or a JDBC store.

(ns app.local
  (:require [datahike.api :as d]))

(def cfg
  {:store  {:backend :file
             :path    "/tmp/localdb"}
   :schema-flexibility :read}))

;; Create on first run, connect thereafter
(when-not (d/database-exists? cfg) (d/create-database cfg))
(def conn (d/connect cfg))

;; Transact locally — no network
(d/transact conn
  [{:db/id    -1
    :doc/id   "42"
    :doc/body "initial content"
    :doc/ts   (System/currentTimeMillis)}])

;; Query — in-process, microsecond latency
(d/q '{:find  [?id ?body]
       :where [[?e :doc/id   ?id]
                [?e :doc/body ?body]}]
     @conn)

DataScript in ClojureScript

DataScript is the browser/ClojureScript equivalent of Datahike. It stores data in memory, schemas are optional, and the query API is identical. It is the standard local store for re-frame-based local-first frontends.

(ns app.ds
  (:require [datascript.core :as ds]))

(def schema
  {:doc/id   {:db/unique :db.unique/identity}
   :doc/tags {:db/cardinality :db.cardinality/many}})

(def conn (ds/create-conn schema))

(ds/transact! conn
  [{:doc/id   "abc"
    :doc/tags #{"clojure" "local-first"}
    :doc/ts   (.now js/Date)}])

(ds/q '{:find  [?id ?tag]
        :where [[?e :doc/id  ?id]
                 [?e :doc/tags ?tag]}]
      @conn)

Sync with a CRDT: last-write-wins map

A simple LWW-register map is sufficient for non-collaborative editing (one writer per key at a time). Each value is tagged with a Lamport timestamp or wall-clock; the replica with the higher timestamp wins on merge.

(ns app.crdt
  (:require [clojure.set :as set]))

;; State shape: {:key {:value v :ts epoch-ms :node "uid"}}

(defn put [store key value node-id]
  (assoc store key {:value value
                     :ts    (System/currentTimeMillis)
                     :node  node-id}))

(defn merge-stores [a b]
  (let [all-keys (set/union (set (keys a)) (set (keys b)))]
    (reduce
      (fn [acc k]
        (let [av (get a k) bv (get b k)]
          (assoc acc k
            (cond
              (nil? av) bv
              (nil? bv) av
              ;; tie-break on node-id for determinism
              (> (:ts av) (:ts bv)) av
              (< (:ts av) (:ts bv)) bv
              :else (if (neg? (compare (:node av) (:node bv))) bv av)))))
      {}
      all-keys))))

;; Merge is commutative, associative, idempotent
(def merged
  (merge-stores
    (put {} :title "Hello" "nodeA")
    (put {} :title "World" "nodeB")))
CRDT Correctness Properties
A valid CRDT merge must be commutative ((merge a b) = (merge b a)), associative, and idempotent ((merge a a) = a). Violating any one of these means replicas can diverge permanently. Test these three properties explicitly in your spec tests.

Electric Clojure: reactive sync as first-class

Electric (hyperfiddle/electric) collapses the client–server boundary into a single reactive expression. The compiler decides what runs on the server and what runs on the client; data flows reactively across the wire. This is neither purely local-first nor purely server-centric — state is managed by the Electric DAG, and partial evaluation ensures that only deltas cross the network.

(ns app.electric
  (:require [hyperfiddle.electric :as e]
            [hyperfiddle.electric-dom2 :as dom]))

;; This single function spans client and server.
;; `e/server` blocks run on JVM; `e/client` blocks run in the browser.
(e/defn DocumentEditor [doc-id]
  (e/client
    (let [!local (atom "")
          local  (e/watch !local)]
      ;; Server read — runs on JVM, value streamed to client
      (let [server-val (e/server (fetch-doc doc-id))]
        (when (not= local server-val)
          (reset! !local server-val)))
      (dom/textarea
        (dom/props {:value local})
        (dom/on "input"
          (fn [e]
            (let [v (-> e .-target .-value)]
              (reset! !local v)
              ;; Write back to server — no explicit HTTP call
              (e/server (save-doc! doc-id v)))))))))
When to use Electric
Electric is well-suited when the latency budget is acceptable (it requires a persistent WebSocket), the data model is relational or graph-shaped, and you want a single authoritative server. It is not a CRDT system — two concurrent edits will conflict unless the server serialises them.

Sync Protocol Design

Delta sync with core.async

For custom sync protocols, core.async channels model the bidirectional stream cleanly. Each side maintains a vector clock; on reconnect, it sends its clock to the peer, which replies with only the operations the sender has not yet seen.

(ns app.sync
  (:require [clojure.core.async :as a :refer [go ! chan]]))

;; Vector clock: {:nodeA 5 :nodeB 3}
(defn clock-dominates? [a b]
  ;; a ≥ b iff every entry in b is ≤ the same entry in a
  (every? (fn [[node t]] (<= t (get a node 0))) b))

(defn ops-since [log peer-clock]
  ;; Return ops the peer hasn't seen
  (filter (fn [{:keys [node seq]}]
            (> seq (get peer-clock node 0)))
          log))

(defn sync-loop [local-state local-log in-ch out-ch]
  (go
    (loop [state local-state log local-log]
      (when-let [msg (case (:type msg)
          ;; Peer announces its clock
          :hello
          (let [delta (ops-since log (:clock msg))]
            (>! out-ch {:type :delta :ops delta})
            (recur state log))
          ;; Peer sends operations we're missing
          :delta
          (let [new-state (reduce apply-op state (:ops msg))
                new-log   (into log (:ops msg))]
            (recur new-state new-log)))))))

Automerge via JVM interop

Automerge is a production-grade CRDT library supporting rich data types (maps, lists, text). The JVM target is available via the automerge-java library; from Clojure it is straightforward interop.

(ns app.automerge
  (:import [org.automerge Document ObjectId AmValue]))

(defn new-doc []
  (Document.))

(defn set-title! [^Document doc title]
  (let [tx (.startTransaction doc)]
    (.set tx ObjectId/ROOT "title" (AmValue/fromString title))
    (.commit tx nil nil))))

(defn get-title [^Document doc]
  (when-let [v (.get doc ObjectId/ROOT "title")]
    (.getString (.get v)))))

(defn merge-docs! [^Document local ^Document remote]
  ;; merge! is idempotent and commutative
  (.merge local remote))

;; Serialise for transport
(defn save-bytes [^Document doc]   (.save doc))
(defn load-bytes [^bytes bytes]   (Document/load bytes))
Automerge Text Type
For collaborative text editing, use AmValue/makeText + the Automerge.splice API rather than storing text as a plain string. Plain strings use last-write-wins semantics on the whole value; the Text CRDT type merges character-level insertions correctly.

State Management Patterns

re-frame + DataScript (local-first frontend)

The standard pattern: DataScript is the client's database; re-frame's app-db holds only transient UI state (modal open, cursor position). Queries run against the DataScript conn via subscriptions. Sync events push changesets to the server and pull remote changesets on reconnect.

(ns app.fx
  (:require [re-frame.core :as rf]
            [datascript.core :as ds]))

(def local-conn
  (ds/create-conn {:doc/id {:db/unique :db.unique/identity}}))

;; Subscription reads directly from DataScript — zero re-frame state
(rf/reg-sub ::all-docs
  (fn [_ _]
    (ds/q '{:find  [?id ?title]
            :where [[?e :doc/id    ?id]
                     [?e :doc/title ?title]}]
          @local-conn))))

;; Event: apply local write, enqueue for sync
(rf/reg-event-fx ::write-doc
  (fn [{:keys [db]} [_ doc]]
    (ds/transact! local-conn [doc])
    {:db           (update db :sync-queue conj doc)
     :dispatch-later [{:ms 500 :dispatch [::flush-sync]}]})))

;; Flush sync queue when online
(rf/reg-event-fx ::flush-sync
  (fn [{:keys [db]} _]
    (when (seq (:sync-queue db))
      {:http-xhrio
       {:method      :post
        :uri         "/sync"
        :params      (:sync-queue db)
        :on-success  [::sync-acked]
        :on-failure  [::sync-failed]}}))))

Offline detection

;; Register a coefficient for navigator.onLine
(rf/reg-fx :watch-online
  (fn [dispatch-key]
    (.addEventListener js/window "online"
      #(rf/dispatch [dispatch-key true]))
    (.addEventListener js/window "offline"
      #(rf/dispatch [dispatch-key false]))))

Decision Matrix

Requirement Server-Centric Local-First
Strict read-after-write consistency ✓ trivial (serialisable DB) ✗ requires sync round-trip
Regulatory / audit trail ✓ single canonical log (XTDB) Possible via append-only CRDT log
Offline writes ✗ impossible by definition ✓ core value proposition
Multi-user real-time collaboration Possible via WebSocket + OT ✓ natural (CRDT merges)
Cold-start read latency RTT (50–300 ms) ✓ in-process (<1 ms)
Data volume per client Unlimited (server-side storage) Bounded by device storage
Schema migration ✓ centralised, one migration Must support all schema versions across replicas
Clojure library maturity ✓ stable (next.jdbc, XTDB, re-frame) Mixed (Electric pre-1.0, Automerge-JVM beta)
Schema Migration in Local-First
Every client device is an independent replica that may be offline for days or weeks. A schema migration cannot be applied atomically across all replicas. Your sync protocol must handle receiving data in the old schema from a stale client long after the server has migrated. Either maintain a compatibility layer for N prior schema versions or force clients to re-sync from scratch on breaking changes.

Testing Sync Logic

Property-based tests for CRDT correctness

CRDTs have algebraic laws. Use test.check to generate arbitrary sequences of operations across multiple replicas and verify that merge is commutative, associative, and idempotent.

(ns app.crdt-test
  (:require [clojure.test.check :as tc]
            [clojure.test.check.generators :as gen]
            [clojure.test.check.properties :as prop]
            [app.crdt :refer [put merge-stores]]))

(def gen-op
  (gen/let [k (gen/elements [:title :body :tag])
             v (gen/string-alphanumeric)
             n (gen/elements ["nodeA" "nodeB"])]
    {:key k :val v :node n})))

(defn apply-ops [store ops]
  (reduce (fn [s {:keys [key val node]}]
            (put s key val node))
          store ops))

(def prop-commutative
  (prop/for-all [ops-a (gen/vector gen-op)
                 ops-b (gen/vector gen-op)]
    (let [a (apply-ops {} ops-a)
          b (apply-ops {} ops-b)]
      (= (merge-stores a b) (merge-stores b a))))))

(def prop-idempotent
  (prop/for-all [ops (gen/vector gen-op)]
    (let [s (apply-ops {} ops)]
      (= s (merge-stores s s))))))

(tc/quick-check 500 prop-commutative)
(tc/quick-check 500 prop-idempotent)
Simulate Partition + Reorder
In sync tests, model network partitions by accumulating operations in separate vectors and merging them in arbitrary orders. test.check's gen/shuffle is useful for testing operation reordering. Bugs in vector-clock comparison surface immediately when applied to reordered sequences.

Library Reference

Library Role Model Status
next.jdbc JDBC bridge for server DB Server-centric Stable
XTDB 2.x Bitemporal document store Server-centric Stable
re-frame Client state via event loop Both Stable
DataScript In-memory Datalog (CLJS/JVM) Local-first Stable
Datahike Persistent Datalog (JVM) Local-first Stable
Electric Clojure Reactive client–server DAG Hybrid Pre-1.0 (active)
Automerge (JVM) CRDT engine Local-first Beta
core.async Channel-based sync primitives Both Stable
test.check Property-based testing Both Stable