The Local Daemon Pattern
The Local Daemon Pattern
A specific architectural shape: a per-user (or per-project) daemon that auto-spawns on first CLI invocation, lives between commands to retain expensive state, and dies when no longer needed. The CLI feels stateless; behind the scenes, a daemon makes it stateful.
Mxr uses it. Lazydap uses it. Many modern dev tools use variants: git's gitstatusd, cargo's build cache, IDEs' language servers, Docker Desktop. The pattern has no universally-agreed name; "local daemon," "auto-spawning daemon," "user-level daemon" are all close.
The shape
First invocation:
$ mxr search "from:alice"
│
├─ probe socket: not found
├─ fork daemon: detached, drops PID file, binds socket
├─ wait for socket to appear (poll up to 2s)
├─ connect to socket
└─ send Search request, get response, exit
Second invocation (microseconds later):
$ mxr cat <message-id>
│
├─ probe socket: found, daemon is up
├─ connect to socket
└─ send Cat request, get response, exit
Daemon lifecycle:
├─ stays alive across CLI invocations
├─ holds expensive state (caches, open adapters, sync loops)
├─ optionally self-shutdowns after N minutes idle
└─ explicitly killable via `mxr shutdown` or signal
The CLI is short-lived. The daemon is long-lived. The user thinks they're running CLI commands; they're actually opening sockets to a background process.
Why this shape
Three problems it solves simultaneously:
1. Cold-start latency
A naive CLI re-loads everything per invocation: open SQLite, parse config, spin up async runtime, connect to providers, do work, exit. Easily 200ms per command before any actual work.
A daemon already has all that loaded. CLI invocations are 5–20ms total — connect, request, response. Subjectively instant.
2. Persistent connections / handles
Some operations require a long-lived connection (IMAP IDLE for push email, DAP adapter sessions, file watchers). A short-lived CLI can't hold these. A daemon can.
3. Multiple concurrent clients
Once the daemon exists, N clients can connect to the same instance. The TUI runs alongside the CLI, both consume the same state, both see the same updates in real time. Without a daemon, you'd need the TUI to be the canonical state-holder, which couples it to the protocol forever.
The auto-spawn dance
Done well, it's invisible. Done poorly, it leaks.
Done well:
async fn ensure_daemon_running() -> Result<()> {
let socket = socket_path()?;
if probe_daemon(&socket).await.is_ok() {
return Ok(()); // daemon already up
}
// Spawn daemon: re-exec ourselves with `daemon --foreground` flag,
// detached, with file descriptors closed.
fork_daemon().await?;
// Poll for the socket to appear, with backoff.
for attempt in 0..20 {
tokio::time::sleepfrom_millis(100 * (attempt + 1)).await;
if probe_daemon(&socket).await.is_ok() {
return Ok(());
}
}
Err("daemon failed to start within 2s")
}
The fork: spawn the same binary with a daemon --foreground (or implicit) subcommand, in a detached child process. On macOS / Linux the standard incantation involves setsid to break free of the parent's session.
PID Files track the daemon. Signal Handling manages its lifecycle.
Per-user vs per-project instances
Two scoping choices:
- Per-user: one daemon per logged-in user. State spans all the user's projects/work. Simpler. mxr's default — there's one mxr daemon per user.
- Per-project: one daemon per project root. State is project-scoped. Useful when state per project is large or sensitive (debugger sessions, language indexes). lazydap's default — one daemon per repo root, keyed by the project root path.
The instance key determines the socket path:
~/Library/Application Support/lazydap/instance-<hash>/lazydap.sock
~/.cache/mxr/mxr.sock
Where <hash> is derived from the project root (and optionally an env override like LAZYDAP_INSTANCE).
Idle shutdown (optional)
A daemon that runs forever wastes RAM if the user moved on hours ago. Pattern: shut down after N minutes of no client activity. Restart on the next invocation.
Trade-off: the next invocation pays cold-start cost again. Default in mxr/lazydap is "don't auto-shutdown" — RAM is cheap, latency isn't. Configurable for users who care.
Crash recovery
The daemon will crash. Network glitch hits an unhandled future, a panic propagates, the OOM killer arrives. Robust client behaviour:
- Probe socket. Fails? Maybe daemon died.
- Read PID file. PID is dead? File is stale, daemon definitely gone.
- Fork a new daemon.
- Resume work.
Persistent state must survive in durable storage (SQLite for mxr, TOML for lazydap). Live session state can vanish — the new daemon doesn't need to know about the old session, because the user's CLI invocation is fresh anyway.
Why not systemd / launchd?
Auto-spawning client-side bypasses init systems entirely. The user doesn't have to:
- Install a systemd unit file
- Run
systemctl --user enable mxr - Deal with service-management gotchas across distros
Trade-off: the daemon can't restart on system boot. For tools where the cost of cold-start on first-of-day is acceptable, this is fine. For tools that need to be running before the user logs in (sshd) it isn't.
mxr and lazydap fit "acceptable cold-start." Init-managed daemons are a different shape.
Comparison to other patterns
- CLI-only (no daemon): simpler. Slow per-invocation. Can't hold persistent connections.
- GUI app holding state: state lives in the GUI process. Closing the GUI loses state. CLI tools can't share it.
- Always-on daemon (init-managed): faster (no cold start ever). More setup overhead. Wastes resources when idle.
- Cloud service: zero local resource use. Network latency, privacy cost. Not local-first.
The local-daemon pattern is a sweet spot for local-first developer tools: per-invocation latency is good, cold-start is cheap, state is preserved across commands, no cloud, no admin setup.
Real-world examples
- mxr (Mxr) — email client.
- lazydap (Lazydap) — debugger.
- gitstatusd (Powerlevel10k) — caches git status across many shell prompts.
- bun, deno caching their JIT/install state.
- Docker Desktop — runs a VM in the background; docker CLI is short-lived clients to it.
- sccache for cargo — a compile cache daemon that serves multiple cargo invocations.
- Adobe Creative Cloud — yes, that thing in your menu bar.
What sets the pattern apart
The key invariants of "local daemon pattern" specifically:
- CLI is the primary interface. The daemon exists to make the CLI fast/stateful, not to be a server.
- Auto-spawn is invisible. Users don't manage the daemon's lifecycle.
- Multiple clients OK. TUI, agent skill, scripts — all peers, none privileged.
- Local IPC. Unix socket (or named pipe on Windows). Not TCP, not HTTP — those imply network and they're not the use case.
- Persistence is durable. Daemon state can be lost; what matters survives in SQLite/TOML/etc.
When all five hold, you have the pattern.
See also
- Daemons — what daemons are in general
- PID Files — process tracking
- Signal Handling — graceful shutdown
- Headless Core + Multiple Clients — the broader architectural pattern this implements
- Unix Domain Sockets — the IPC mechanism
- How Daemons Work — synthesis
- Mxr · Lazydap — the projects this note exists to describe