bartblast
Designing Local-First features for Hologram - what's your dream DX?
Hi everyone!
I’m working on designing Local-First features for Hologram and I’d love to hear your thoughts before I dive into implementation.
What do we mean by “Local-First”?
The term gets used in different ways, so let me clarify what I mean here. Local-First is an architecture pattern where the UI isn’t blocked by the network. In practice this means:
-
Instant UI - reads come from a local store, so there’s no spinner waiting for a server round-trip
-
Optimistic updates - writes apply immediately to the local state and sync to the server in the background
-
Offline resilience - the app keeps working when connectivity drops
-
The server still matters - it handles auth, conflict resolution, shared state, and remains the source of truth
It’s not about forcing all data to live on the client - it’s about making the user experience fast and resilient regardless of network conditions. How much data you keep locally is a design decision, not a requirement.
What I’m looking for
I’m in the early design phase and I’m genuinely open to ideas. I want to hear about your ideal developer experience - not what you think is technically feasible, but what you’d want it to look like if there were no constraints.
Some things I’ve been thinking about:
1. Declarative sync
How would you ideally declare which data should be available locally and how it syncs?
2. Conflict resolution
When two users (or a user and the server) make conflicting changes, what should that look like from the developer’s perspective? Should it be automatic, configurable, or something else?
3. Offline experience
What should happen when the app goes offline and comes back? How much of this should the framework handle transparently vs. giving the developer control?
4. Inspiration from other tools
Have you used any local-first or sync engine solutions (in any ecosystem - Zero, Electric SQL, PowerSync, Automerge, LiveStore, etc.) that had a great DX? What made it great?
Don’t hold back - the bolder the idea, the better. Even if something seems unrealistic, it might spark a direction I haven’t considered.
Looking forward to the discussion! ![]()
Most Liked
zachdaniel
FWIW I do personally believe that using a declarative data backend like Ash is how this should actually be accomplished. Being able to rely on a data-layer agnostic storage layer that is introspectable and extensible is how I’ve personally always been planning on doing this.
With Ash you can ask a resource “can your data layer do X type of thing”, meaning that hologram can react to differently-capable data layers to make strategic choices, i.e if the data layer can transact, then you can batch operations. Your extension could add additional attributes to the resource even. Sky is the limit.
kingdomcoder
I think that Hologram as it stands today already does a world-class job separating client-side and server-side concerns. Extending that principle into managing offline data is how I would approach this problem.
Let me explain the DX I wish for with some code examples. (Caveat: I will include Ash-like snippets in my examples, but that’s primarily because I work in Ash every day. I am much more comfortable expressing myself in it)
Imagine a simple Hologram page. Right now, opening and closing a ticket works this way:
defmodule MyApp.TicketListPage do
use Hologram.Page
# ... route, layout, init, template omitted for brevity ...
def action(:open_ticket, _params, component) do
subject = component.state.new_subject
component
|> put_state(:new_subject, "")
|> put_command(:create_ticket, %{subject: subject})
end
def action(:close_ticket, params, component) do
component
|> put_command(:update_ticket, %{id: params.id, status: :closed})
end
def command(:create_ticket, params, server) do
case do_open_ticket(params.subject) do
{:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
{:error, _} -> put_action(server, :create_failed, %{})
end
end
def command(:update_ticket, params, server) do
case do_close_ticket(params.id) do
{:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
{:error, _} -> put_action(server, :update_failed, %{})
end
end
defp do_open_ticket(subject) do
# Does something... Starts an Oban job, calls an external API, sends an email, etc
MyApp.Support.Ticket.open(subject)
...
end
defp do_close_ticket(id) do
# Also does some very important server-side things
MyApp.Support.Ticket.close(id)
...
end
end
With a ticket resource that looks like this:
defmodule MyApp.Support.Ticket do
use Ash.Resource,
domain: MyApp.Support,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "tickets"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :subject, :string, allow_nil?: false, public?: true
attribute :status, :atom do
constraints one_of: [:open, :closed]
default :open
allow_nil? false
end
end
relationships do
belongs_to :representative, MyApp.Support.Representative
end
policies do
policy action_type(:read) do
authorize_if expr(representative_id == ^actor(:id))
end
policy action_type(:create) do
authorize_if always()
end
policy action(:close) do
authorize_if expr(representative_id == ^actor(:id))
end
end
actions do
defaults [:read]
create :open do
accept [:subject]
end
update :close do
accept []
argument :id, :uuid, allow_nil?: false
validate attribute_does_not_equal(:status, :closed) do
message "Ticket is already closed"
end
change set_attribute(:status, :closed)
end
end
code_interface do
define :open, args: [:subject]
define :close, args: [:id]
end
end
In my dream DX, upgrading what we have today to a local first app will only need these changes:
Configure an extension provided by Hologram:
defmodule MyApp.Support.Ticket do
use Ash.Resource,
domain: MyApp.Support,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [Hologram.Extension.IndexedDb] # add the local datastore you want to use
# ... everything else stays the same ...
indexed_db do
store "tickets"
scope :authorized # sync everything the actor is authorized to read
resolution_strategy :last_write_wins # can be the simple strategy for a start. future versions can even allow anonymous functions for custom strategies
end
end
On the page, actions now write to the local store first and then dispatch commands:
defmodule MyApp.TicketListPage do
use Hologram.Page
# ... route, layout, template omitted for brevity ...
def init(_params, component, _server) do
# Reads come from the local store -- no server round-trip, no spinner.
tickets = MyApp.Support.Ticket.read!()
put_state(component, %{tickets: tickets, new_subject: ""})
end
def action(:open_ticket, _params, component) do
subject = component.state.new_subject
# 1. Write to local store immediately (optimistic update)
{:ok, ticket} = MyApp.Support.Ticket.open(subject)
tickets = MyApp.Support.Ticket.read!()
component
|> put_state(%{tickets: tickets, new_subject: ""})
|> put_command(:create_ticket, %{subject: subject})
# 2. Command syncs to server in the background
end
def action(:close_ticket, params, component) do
# Write to local store immediately
{:ok, _ticket} = MyApp.Support.Ticket.close(params.id)
tickets = MyApp.Support.Ticket.read!()
component
|> put_state(:tickets, tickets)
|> put_command(:update_ticket, %{id: params.id})
end
# Commands stay exactly the same -- they still do the important
# server-side work (Oban jobs, external APIs, etc.)
def command(:create_ticket, params, server) do
case do_open_ticket(params.subject) do
{:ok, ticket} -> put_action(server, :ticket_created, %{ticket: ticket})
{:error, _} -> put_action(server, :create_failed, %{})
end
end
def command(:update_ticket, params, server) do
case do_close_ticket(params.id) do
{:ok, ticket} -> put_action(server, :ticket_updated, %{ticket: ticket})
{:error, _} -> put_action(server, :update_failed, %{})
end
end
defp do_open_ticket(subject) do
MyApp.Support.Ticket.open(subject)
...
end
defp do_close_ticket(id) do
MyApp.Support.Ticket.close(id)
...
end
end
This approach is beautiful to me because it’s declarative and explicit, and it preserves the mental model that the developer already works with (client-side concerns happen in actions and server-side concerns in commands, no magic). Calling MyApp.Support.Ticket.open in both the action and the command might initially seem redundant. But to me, it is explicit. It’s not redundant.
In addition, this approach suddenly unlocks several new possibilities! Local-first, local-only, local-never (server-only), @woylie‘s vision of 3-way-sync, or any other permutation of interest, all by configuring resource extensions and choosing where to call a write function:
- Local-only: Action writes to local store. No command dispatched. Data never leaves the client. Use case: drafts, preferences, UI state.
- Local-first: Action writes to local store AND dispatches a command. UI updates instantly, server syncs in the background. Use case: most app data. This is what the example above does.
- Server-only: Action dispatches a command without writing locally. Waits for server response. Use case: payments, sensitive operations.
- Three-way sync: Same resource configured with multiple extensions (e.g., IndexedDB in the browser, SQLite on the filesystem, Postgres on the server). Each syncs via the same command queue. Use case: @woylie’s vision of PWA + desktop + self-hosted server.
The wiring to achieve this is non-trivial. Perhaps Hologram has a command queue already for handling commands when disconnected, but that’s a key piece. When a command dispatch fails, it queues locally and retries with backoff. On reconnection, queued commands replay through the same server-side business logic (@jam’s proposal). Also simplifies the retry logic.
So, in summary, mapping back to @bartblast’s original questions:
1. Declarative sync
Configure an extension on the resource. Authorization rules determine what syncs. Actions and commands control write locality. Define the model once, declare where it lives, write normal Elixir.
2. Conflict resolution
Declared on the resource via the extension’s DSL (resolution_strategy :last_write_wins). Queued commands replay through business logic for natural conflict detection. Future versions can support per-field strategies and custom resolver functions.
3. Offline experience
Transparent. Actions work against the local store regardless of connectivity. Commands queue when unreachable and replay on reconnection.
4. Inspiration
The biggest inspiration is Ash’s data-layer agnosticism – same resource definition, multiple storage backends. @jam referenced Meteor’s “it just works” DX as the bar. I think the action/command model gets us there.
Really excited about this discussion!
bartblast
You didn’t hallucinate! Hologram did use WebSockets for commands and page fetching in earlier versions. I moved away from that in v0.5.0 - mainly because cookies and sessions can only be set via HTTP, and the workaround (a CRDT-based cookie store syncing across nodes) was way too complex. Plus other practical issues like corporate firewalls blocking WebSockets and so on.
The key realization was that Hologram doesn’t need WebSockets the way LiveView does. Since the code runs in the browser, the server only gets hit for page fetching and commands - not for every user interaction. HTTP persistent connections work great for that.
Popular in Discussions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance









