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 |
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.
(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})
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])))
version column checked during UPDATE). Add WHERE id = ? AND version = ? and return HTTP 409 on mismatch if you care about this.
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)]]})
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 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 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)
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")))
(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 (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)))))))))
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 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))
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.
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]}}))))
;; 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]))))
| 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) |
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)
test.check's gen/shuffle is useful for testing operation reordering. Bugs in vector-clock comparison surface immediately when applied to reordered sequences.
| 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 |