Skip to content

Side-effect orchestration: Rust vs Elm

Status: Adopted 2026-04-25.

Tauri + Elm gives you two natural homes for “after user does X, fire async Y, then do Z” logic:

  • Elm (TEA): dispatch a Cmd, wait for the reply, update the model. Built-in to the Elm Architecture; the language is designed for it.
  • Rust (tokio): spawn a background task, write the result to JSONL, let the frontend pick it up on next read.

This question recurs every time we add an AI feature, a deferred write, or any side-effect chain. We keep re-litigating it. This doc encodes the rule so we don’t.

If the side-effect’s result is written to the log (mutates domain state) or must survive app close → Rust.

If the side-effect’s result only affects what’s on screen this session (ephemeral UI: focus, scroll, optimistic spinner, modal state) → Elm.

The Elm runtime dies when the runtime dies. Closing the app, navigating away, the OS suspending the WebView on iOS — all of these terminate any in-flight TEA Cmd. There is no concept of “durable in-flight effect” in TEA.

Rust + tokio + the JSONL log do have a concept of durable state. A spawned tokio task can complete after the user has navigated away, written its result to disk, and on next EntriesList the frontend sees the update naturally. If we want to make the durability stronger, we can persist a “pending classification” record to JSONL itself, and a launch-time sweep finishes any unfinished work.

The litmus question: would a CLI or a second frontend want this orchestration? If yes, it’s domain. If no, it’s UI side-effect coordination.

Why TEA is not enough for domain orchestration

Section titled “Why TEA is not enough for domain orchestration”

Yes, TEA is a beautiful state machine. Yes, the flow reads more linearly in one update function. But:

  • TEA’s state is per-frontend-session. Closing the app loses it.
  • Multi-step writes that span an LLM call are vulnerable to user-initiated interruption (background, force-quit).
  • Cross-feature coordination (a queue of pending work) requires a queue somewhere; doing it in Elm reinvents what the JSONL log already gives you for free in Rust.

The ergonomic win of TEA is real but local; the durability loss is global.

Counter-cost: Rust orchestration is more code

Section titled “Counter-cost: Rust orchestration is more code”

Concrete costs in this codebase:

  • The Device and LlmClient traits are sync. To do async work we need tauri::async_runtime::spawn + spawn_blocking for the blocking ureq call. Tested workable; doesn’t require refactoring traits.
  • The api dispatch layer must declare a side-effect contract (a DispatchSideEffect enum return) so the Tauri host can spawn it. Adds ~30-50 lines per feature.
  • If we want push-back-to-Elm, tauri::ipc::Channel<T> is the sanctioned primitive (not an ad-hoc port). Most domain side-effects don’t need this — the next read picks up the change.

Net assessment: moderate one-time cost, compounding payoff per feature.

Side effectWhereWhy
Classify entry on capture, write supersede recordRustMutates domain state (Shape). Must survive app close.
Schedule iOS local notifications on sign-inRustWrites to OS notification queue (durable system state).
Daily anchor LLM continuation (carry-this)ElmEphemeral session UI. Nothing persisted. If app closes, it just regenerates next time.
pendingDeleteConfirm modal stateElmPure UI. No persistence.
pendingPracticeDeepLink flagElmCross-page UI coordination, no domain state.
Notification reschedule on entry mutationRustWrites to OS state. Dispatched from Elm but orchestrated in Rust.

When you decide “Rust orchestrates,” the pattern is:

  1. The api dispatch arm for the user-initiated command returns a DispatchSideEffect variant in addition to its normal Response.
  2. The Tauri host (src-tauri/src/lib.rs::core_dispatch) matches on the side effect and calls tauri::async_runtime::spawn(async move { spawn_blocking(|| { ... }).await }).
  3. The spawned task uses &dyn Device (cloned via Arc<TauriDevice>) to make the LLM call and write the supersede record.
  4. The Elm side just sees the new state on the next EntriesList refetch — no pendingX state, no parallel Cmd, no reply handler.

When you decide “Elm orchestrates,” the pattern is:

  1. Dispatch the user-initiated Command and the side-effect Command in parallel from the same update arm.
  2. Track in-flight state in a pendingX : Maybe ... model field.
  3. Handle the reply in a dedicated XReply arm; clear pendingX; trigger any follow-up dispatches from there.

Both patterns are valid. Pick by the rule above, not by which is more familiar.

  • If we ever want a second frontend (web, CLI) hitting the same Worker → revisit any Elm-orchestrated AI features (none today; carry-this is the only one and it’s session-ephemeral).
  • If we add features where Rust needs to call the LLM without a user tap (scheduled re-classification, server-pushed reminders) → confirms the rule; Rust must own those.
  • If we hit three or more concurrent Elm pendingX fields per page → likely a sign that some of them belong in Rust.
  • 2026-04-25 conversation that adopted this rule: classify-on-capture moved from Elm orchestration to Rust orchestration after consulting three architecture agents. First-principles agent recommended Rust on durability grounds; codebase-pragmatic agent recommended Elm on cost grounds; Tauri-patterns agent showed that Rust’s cost was lower than the codebase agent estimated (no async-trait refactor needed; Shape::Unsure doubles as the “needs classify” marker, no separate queue required).