Serverless local-first settings sync over LAN with Automerge + mDNS

7 min readYaseen Khatib · MERN + AI Architect
Cover illustration: Serverless local-first settings sync over LAN with Automerge + mDNS

Why local-first LAN sync for settings?

Local-first keeps data on the user’s machines, works without the internet, and removes the need for cloud services. For settings, the data is small but impacts UX everywhere. Restricting sync to the LAN uses free transports and gives immediate convergence when devices are on the same network. CRDTs (Automerge) take the sting out of concurrent edits, and mDNS covers discovery without a bootstrap service.

How do you replicate app settings over LAN without a server?

Advertise a service with mDNS (DNS-SD) so peers on the LAN can find each other. Each peer listens on a small TCP port. When a peer shows up, connect and exchange Automerge sync messages, then persist the document locally. Because Automerge guarantees convergence, devices can drop off or power down at any time without risking consistency.

High level:

  • Each device holds the full CRDT document (settings).
  • Advertise/browse a service: _app-settings._tcp with a docId and deviceId.
  • Connect peers over TCP; run Automerge’s sync protocol until there’s no more to send.
  • Persist the doc snapshot locally and rehydrate on boot.

How does Automerge sync work over arbitrary transports?

Automerge emits compact binary sync messages you can send over any byte stream (TCP, WebRTC, BLE). Keep a SyncState per peer. On receive, pass the message to Automerge; it updates both the document and that peer’s SyncState. Keep generating messages for that peer until Automerge returns null.

Key API shape (Automerge 2.x):

  • init(), change(), save(), load()
  • initSyncState(), generateSyncMessage(doc, syncState), receiveSyncMessage(doc, syncState, msg)

Code example: Node + TypeScript mDNS + TCP sync service

The example below is a LAN-sync sidecar you can run alongside Electron/Tauri or embed in a Node service. It uses bonjour-service for mDNS/DNS-SD and net for TCP. The service is advertised with a shared docId, and peers initiate connections deterministically to prevent connection storms.

// src/lan-sync.ts
import fs from 'node:fs'
import net from 'node:net'
import crypto from 'node:crypto'
import Bonjour from 'bonjour-service'
import * as Automerge from '@automerge/automerge'

// ---- Types ----
type Settings = {
  version: number
  prefs: {
    theme: 'light' | 'dark'
    accent: string
    shortcuts: Record<string, string>
  }
}

// ---- Config ----
const SERVICE_TYPE = 'app-settings' // becomes _app-settings._tcp local
const DOC_FILE = 'settings.amrg'
const DOC_ID = process.env.DOC_ID ?? 'settings-v1' // all devices for this user share this
const DEVICE_ID = loadOrCreateId('.device-id')
const PORT = 0; // ephemeral

// Optional pre-shared secret for LAN encryption/auth
const PRESHARED = process.env.LAN_SECRET // undefined -> plaintext
const KEY = PRESHARED ? crypto.createHash('sha256').update(PRESHARED).digest() : undefined

// ---- Automerge state ----
let doc: Automerge.Doc<Settings> = loadDoc(DOC_FILE) ?? Automerge.from<Settings>({
  version: 1,
  prefs: { theme: 'light', accent: '#4f46e5', shortcuts: {} },
})

// Per-socket SyncState
const peerStates = new Map<net.Socket, Automerge.SyncState>()

// ---- TCP server ----
const server = net.createServer((socket) => {
  setupSocket(socket)
})
server.listen(PORT, () => {
  const address = server.address()
  if (address && typeof address !== 'string') {
    advertise(address.port)
    browse(address.port)
    console.log(`LAN sync listening on ${address.port}`)
  }
})

function setupSocket(socket: net.Socket) {
  socket.setNoDelay(true)
  peerStates.set(socket, Automerge.initSyncState())

  // Immediately drive sync for this peer
  drainSync(socket)

  let buf = Buffer.alloc(0)
  socket.on('data', (chunk) => {
    buf = Buffer.concat([buf, chunk])
    while (buf.length >= 4) {
      const len = buf.readUInt32BE(0)
      if (buf.length < 4 + len) break
      const frame = buf.subarray(4, 4 + len)
      buf = buf.subarray(4 + len)
      const payload = KEY ? openBox(frame, KEY) : new Uint8Array(frame)
      if (!payload) return socket.destroy() // auth fail

      const state = peerStates.get(socket)!
      const { doc: newDoc, syncState: newState } = Automerge.receiveSyncMessage(doc, state, payload)
      doc = newDoc
      peerStates.set(socket, newState)
      persist()

      // Respond with any messages Automerge wants to send now
      drainSync(socket)
    }
  })

  socket.on('close', () => peerStates.delete(socket))
  socket.on('error', (e) => console.warn('peer error', e.message))
}

function drainSync(socket: net.Socket) {
  let state = peerStates.get(socket)!
  while (true) {
    const msg = Automerge.generateSyncMessage(doc, state)
    if (!msg) break
    state = Automerge.initSyncStateFrom(state) // keep type, though generate returns updated via receive
    peerStates.set(socket, state)

    const payload = KEY ? sealBox(msg, KEY) : Buffer.from(msg)
    const frame = Buffer.alloc(4 + payload.length)
    frame.writeUInt32BE(payload.length, 0)
    Buffer.from(payload).copy(frame, 4)
    socket.write(frame)
  }
}

// Broadcast local change entrypoint you can call from your app
export function applyLocalChange(mutator: (d: Settings) => void) {
  const next = Automerge.change(doc, mutator)
  if (next !== doc) {
    doc = next
    persist()
    // Nudge all peers
    for (const s of peerStates.keys()) drainSync(s)
  }
}

// ---- mDNS advertise/browse ----
let bonjour: Bonjour | null = null
function advertise(port: number) {
  bonjour = new Bonjour()
  bonjour.publish({
    name: `settings-${DEVICE_ID}`,
    type: SERVICE_TYPE,
    port,
    txt: { deviceId: DEVICE_ID, docId: DOC_ID, v: '1' },
  })
}

function browse(port: number) {
  const browser = bonjour!.find({ type: SERVICE_TYPE })
  browser.on('up', (service) => {
    const txt = service.txt as any
    if (!txt || txt.docId !== DOC_ID || txt.deviceId === DEVICE_ID) return

    // Deterministic initiator: larger deviceId dials
    if (DEVICE_ID < txt.deviceId) {
      const host = (service.addresses.find(a => a.includes('.')) || service.host || service.fqdn)
      const peerPort = service.port
      const socket = net.connect(peerPort, host, () => setupSocket(socket))
    }
  })
}

// ---- Persistence ----
function persist() {
  const bin = Automerge.save(doc)
  fs.writeFileSync(DOC_FILE, Buffer.from(bin))
}

function loadDoc(path: string) {
  if (!fs.existsSync(path)) return undefined
  const buf = fs.readFileSync(path)
  return Automerge.load<Settings>(new Uint8Array(buf))
}

function loadOrCreateId(path: string) {
  if (fs.existsSync(path)) return fs.readFileSync(path, 'utf8').trim()
  const id = crypto.randomUUID()
  fs.writeFileSync(path, id)
  return id
}

// ---- Minimal secretbox using tweetnacl ----
import nacl from 'tweetnacl'
function sealBox(msg: Uint8Array, key: Buffer) {
  const nonce = crypto.randomBytes(24)
  const box = nacl.secretbox(new Uint8Array(msg), nonce, new Uint8Array(key))
  return Buffer.concat([nonce, Buffer.from(box)])
}
function openBox(frame: Buffer, key: Buffer): Uint8Array | null {
  const nonce = frame.subarray(0, 24)
  const box = frame.subarray(24)
  const m = nacl.secretbox.open(new Uint8Array(box), new Uint8Array(nonce), new Uint8Array(key))
  return m ?? null
}

Notes:

  • TCP framing is a simple length prefix.
  • Deterministic dialing prevents duplicate connections.
  • Keep a stable DOC_ID per user or silo; one document can host multiple logical namespaces (prefs, feature flags, etc.).
  • For production, add backoff, timeouts, and SIGINT cleanup for Bonjour.

How do you secure peer-to-peer LAN sync without a server?

Use a pre-shared secret to derive a symmetric key and encrypt Automerge messages with XChaCha20-Poly1305 (secretbox). Gate peers using TXT records (docId/version) and drop any frame that fails decryption. Rotate secrets per user or org. If you need stronger guarantees, derive keys via QR-paired ECDH and pin device IDs.

Minimal hardening steps:

  • Require LAN_SECRET on both peers; forbid plaintext in production builds.
  • Include docId and a protocol version v in the TXT record; ignore mismatches.
  • If necessary, reject connections across subnets; mDNS is link-local by default.

React/Electron: using the LAN-sync sidecar from UI

Most apps will forward local mutations to the sidecar and let Automerge take care of replication. A thin hook is enough:

// src/useSettings.ts (renderer)
import { useEffect, useState, useCallback } from 'react'
import type { Settings } from './types'
import { ipcRenderer } from 'electron'

export function useSettings() {
  const [settings, setSettings] = useState<Settings | null>(null)

  useEffect(() => {
    ipcRenderer.invoke('settings:get').then(setSettings)
    const sub = (_: any, next: Settings) => setSettings(next)
    ipcRenderer.on('settings:updated', sub)
    return () => ipcRenderer.off('settings:updated', sub)
  }, [])

  const update = useCallback((patch: Partial<Settings['prefs']>) => {
    ipcRenderer.invoke('settings:update', patch)
  }, [])

  return { settings, update }
}

Main process wires that to the Automerge sidecar’s applyLocalChange:

// src/main-ipc.ts
import { ipcMain } from 'electron'
import { applyLocalChange, getDoc } from './lan-sync-adapter' // thin wrapper exporting doc snapshot

ipcMain.handle('settings:get', () => getDoc())
ipcMain.handle('settings:update', (_evt, patch) => {
  applyLocalChange(d => {
    Object.assign(d.prefs, patch)
  })
})

Operational caveats and testing

  • mDNS is link-local; cross-VLAN paths need relays or unicast DNS-SD. Some consumer Wi‑Fi gear filters multicast.
  • Windows firewalls often default-block inbound; ship a clear prompt.
  • Sleep/wake churn: re-browse on network changes and re-advertise on IP changes.
  • Use a monotonic clock for logs only; Automerge does not depend on clocks.

Smoke test:

  • Start the sidecar on two machines on the same SSID.
  • Flip theme on A; observe near-instant update on B.
  • Kill A; update on B; restart A; they should converge immediately on reconnect.

What about conflicts, migrations, and schema?

Automerge treats conflicts as normal: concurrent writes to prefs.shortcuts merge as a map. For lists or counters, use the CRDT types provided (lists, counters). For migrations, include a version field; when you bump it, run a deterministic transformation inside a change. It’s just another change, so each peer applies it exactly once and they converge.

Example migration:

  • version 1 -> 2: split "theme" into {mode, accent}
  • Gate with if (doc.version === 1) inside a change block.

Tradeoffs

Concern mDNS + TCP + Automerge
Infra cost Zero (no server)
Discovery Automatic on LAN; brittle across VLANs
Security PSK acceptable on LAN; can evolve to ECDH pairing
Consistency Strong eventual via CRDTs
Complexity Low-medium; you own the transport

Production checklist

  • Backpressure and partial writes on TCP sockets
  • Rate-limit advertise/browse; debounce re-connections
  • Persist snapshots plus periodic incremental saves
  • Metrics: peer count, message sizes, convergence latency
  • Feature-flag to disable LAN sync for regulated environments

With these pieces in place, you get a solid, serverless, local-first settings sync that works when devices share a network and disappears when they don’t, without waking anyone up.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.