bartblast

bartblast

Creator of Hologram

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! :slight_smile:

Most Liked

zachdaniel

zachdaniel

Creator of Ash

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

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

bartblast

Creator of Hologram

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.

Where Next?

Popular in Discussions Top

Jayshua
I recently came across the javascript library htmx. It reminded me a lot of liveview so I thought the community here might be interested....
New
jeramyRR
This is an interesting article to read. Elixir’s performance, like usual, is excellent. However, it seems like the high CPU usage is co...
New
nburkley
AWS re:Invent is on at the moment with some interesting announcements. One new feature in particular is the Lambda Runtime API for AWS La...
New
fireproofsocks
I’ve been working on an Elixir project that has required a lot of scripting. I usually reach for Elixir because I like it more (and in th...
New
sergio
There’s a new TIOBE index report that came out that shows Elixir is still not in the top 50 used languages. It also goes on to call Elix...
New
hazardfn
I suppose this question is effectively hackney vs. ibrowse but we are at a point in our project where we have to make a choice between th...
New
restack_oslo
Hello, Please pardon me for any faux paux. I am 46 and this is my first time on a forum of any kind. I wanted to to get answers from tho...
New
wmnnd
The Go vs Elixir thread got me thinking: Would it be too hard to implement a simple mechanism for creating Go-style static app binaries f...
New
pdgonzalez872
If this has been asked here before, please point me to where it was asked as I didn’t find it when I searched the forum. Maybe a mailing ...
New
sashaafm
Piggy backing a bit on @dvcrn topic BEAM optimization for functions with static return type?, I’ve been trying to understand in a deeper ...
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
Harrisonl
We have an ECS cluster with 4 services, where each task joins a single cluster, via discovery ECS discovery service. Currently when I de...
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New
marius95
Hello everyone, I try to use an Javascript Event Handler in my root.html.leex file. Therefore I created a function in the app.js file: ...
New
Darmani72
If I have a post route which an argument: post /my_post_route/:my_param1, MyController.my_post_handler How would get the post params ...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New
AstonJ
Please see the new poll here: Which code editor or IDE do you use? (Poll) (2022 Edition) It’s been a while since we first asked this, I...
208 31265 143
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New

We're in Beta

About us Mission Statement