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:

14 Likes

I’d suggest you to look at tanstack db. They’re kinda building what you’re describing for the javascript ecosystem.

2 Likes

TanStack DB is definitely on my radar - they’re doing some very interesting work there!

1 Like

I’d say neither the framework nor the application developer should handle offline events, because “offline” is hard to define. Websocket broke? No heartbeat received in last XX seconds? All have their caveats, and in the case of mobile internet, the server can be flooded with such events. Because of this, I’d advocate against of relying on websocket, but that’s probably not what you want to hear. You did say don’t hold back though.

1 Like

In general, I think the dream DX is just write queries and mutations and Hologram makes sure the data stays in sync. Ideally these would be no different than how you’d usually write queries and mutations in Elixir and you’d opt out of optimistic updates if needed. Hologram would also handle prefetching queries to help everything feel instant.

I think Zero, Instant, and Convex have the philosophy of “it’s just queries and mutations”. They all have their own query language to achieve it though, and for the latter two, I think they own the db as well. Meteor.js also had a similar philosophy and allowed you to write Mongo queries on server and client with some small differences. Zero improves upon this by letting you write the query once even though I don’t love their custom syntax. For Hologram, I think a similar DX to Zero but “it’s just standard Elixir” could be something to aspire to. I’m not sure that their SQLite cache that sits between Postgres and the client would be required to pull it off. The issues I see with the other attempts are either:

  1. They require hoop jumping. For example, TanstackDB paired with ElectricSQL requires defining things in certain ways on the server, then optionally filtering in a middle layer, to get the data onto the client. From there, you need to write queries again in TanstackDB’s query language. Not to mention a pretty convoluted mental model for the write path. I’m glad it exists but I don’t think it offers a DX to aspire to. I also wonder if their differential dataflow complexity can be avoided if using signals instead of assuming a re-render loop a la React. Then again if the data set is small enough it doesn’t matter and you’ll fit well within 16ms with any change regardless.
  2. They require opting into a completely different paradigm, e.g. Automerge, jazz.tools lean on CRDTs heavily which imo are not needed in a server reconciliation model and only become critical if dealing with collaborative document editing (or maybe not). In LiveStore’s case it uses event sourcing which sounds great in theory but seems overkill for most apps. There is something nice conceptually about event sourcing and deriving views but the complexity of making it all work seems not worth it. Maybe there’s a way to make it “just work” and not have the typical downsides come back to bite you.

Now to keep things a bit more manageable, I think Hologram should focus on supporting Last Write Wins to start and maybe as it’s only reconciliation technique. Otherwise, I think complexity explodes in what is already fairly complex.

For offline, queue the writes and replay when back online. The replays go through the same mutation business logic so if something has changed that would prevent their write, it’ll just get rolled back. There should also probably be a config to determine when a full sync is required, e.g. after X days offline with a default that won’t get people into too much trouble, maybe 1 day.

Like Zero, I think you could constrain the types of supported queries in a first release and potentially expand over time. For example, I think Zero still doesn’t support aggregates.

3 Likes

Local first would be awesome for LiveView Native, as native app users are used to instant responsiveness.

1 Like

As an aside, it would be amazing if one day you could pair Hologram and @garrison’s Hobbes if the stars align.

1 Like

Some iOS apps offer a sync feature that makes this local first concept more explicit, where the apps is first and foremost a local app, and sync is secondary. Some apps that come to mind are:

  • Day One (offers a “Reset Sync” in the app which indicates that the source of truth is the cloud version. It is also very explicit about the Sync Status. Each item also has an Entry Version History)
  • Strongbox (allows user to explicitly define what the Source Device (and then resolve any merge conflicts accordingly, which isn’t too bad, as each entry has a history versions exposed in the app). It also offers a Lazy Sync.)

And then there are web apps that offer an offline mode:

  • Google Docs, MS Office, Zoho, Proton, WPS

Git as probably also a good example of different merge conflict strategies, and 3-way-merge.

1 Like

You’re right that “offline” is hard to pin down. I think the sync engine still needs some form of connectivity awareness at the framework level though. If a sync attempt fails, the framework needs to know when to retry. Whether that’s periodic retries with backoff, reacting to the connection being re-established, or some combination - I’m not sure we can completely avoid the problem you’re describing. With a declarative data model this was always going to be the framework’s responsibility, not the developer’s - but you’re right that it’s a hard problem to solve well.

By the way, Hologram doesn’t use WebSockets - it communicates via standard HTTP requests with a keep-alive mechanism. But the challenge applies regardless of transport.

What approach would you lean toward?

1 Like

I agree with you; the client side needs to be aware of the connectivity situation. On the other hand, the server side should refrain from doing anything special when a client is perceived “offline”.

I was under the impression that Hologram use websocket, just not Phoenix Socket. Maybe I was hallucinating.

1 Like

Thanks @jam, really appreciate the detailed thoughts!

I agree with a lot here, especially the “make it invisible” direction - that’s exactly the bar I’m aiming for. Code should work the same way on both client and server, and developers shouldn’t need to think about what’s local vs. remote.

One place I’m thinking differently though is query-based sync. Syncing queries couples the sync layer to the UI - every time a component changes what it displays, you’re renegotiating what lives on the client. I’d rather sync underlying data based on declarative authorization rules. You define once what data a user/role gets locally (you need those rules for security anyway), and then components just query freely against whatever’s available. That’s where the real “just write queries” DX comes from I think - not because queries drive the sync, but because sync is already handled underneath.

On the query side - I definitely don’t want to invent a new language. But I’d like to explore some ideas from Gel (previously EdgeDB), their composability is way ahead of SQL. Probably more of a query API than a language, something that feels natural in Elixir.

As for event sourcing - I’m not ruling it out. Maybe there’s a lightweight version that collapses to simple atomic operations in most cases, giving you the “what happened” benefits without the usual complexity. Still weighing that against going straight to LWW.

And yeah, fully agree on starting constrained. Ship a small subset of the most needed operations first, validate the DX, expand from there.

Great discussion, keep the ideas coming!

1 Like

Worth noting that LiveView Native was discontinued - the core library is archived on GitHub. The project maintainer shared the reasons publicly.

Just to clarify - Hologram is a separate project to LiveView with a different architecture - it compiles Elixir to JavaScript and runs on the client, so it’s not related to LiveView Native. That said, mobile support is on the Hologram roadmap as well, and Local-First would definitely be important for that use case.

1 Like

From a user and dogfooding perspective, the ideal app I’d want to build would indeed be local-first in the sense of online-optional. A PWA that runs offline by default without limitations, but can connect to an official sync server or a self-hosted one for backups and sharing data between devices or users. Even better if in addition to the browser version, I was able to install a desktop or mobile version that writes to an SQLite database on the local file system. So in the end, it would be a three-way sync between browser storage, online server, and local file system. If the data is trapped in IndexedDB, I don’t really own the data as a user.

2 Likes

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!

5 Likes

I guess HTTP retries are more predictable durable - don’t get filtered by firewalls, the mobile networking issues @derek-zhou mentioned? Just curious about this design choice (I have a somewhat relevant project in this area coming up).

The big picture for me - what I would personally want/love out of hologram - is zero/low latency interactivity on the client for clicks/touches, graceful handling of temporary network disruptions (seconds/minutes not days), guaranteed order from a single node/client, near real-time updates on the client, and an option to choose a concurrent write resolution strategy for multiple nodes/clients.

Some strategies I might want:

  1. First One Wins (one gets an error)
  2. Throw them both away (both get errors)
  3. Configurable priority (that defaults to the two other strategies when messages are tied)
    1. Not exactly sure how this would work on the client but a use-case would be to prefer a in-store terminal over a shopping cart on the website.

I’d say 1 would be my first pick if I had to choose.

Just an implementation detail but with the http retry I suppose we also have to account for single node message ordering? Maybe a genserver buffer + sort based on a client-provided lamport or HLC - then merge multiple nodes…somehow? Interesting problems!

Also, hologram looks awesome! :purple_heart:

1 Like

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.

9 Likes

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.

4 Likes

Interesting examples, thanks! Sync status and version history are definitely on my radar.

That aligns well with where Hologram is heading - cross-platform from a single codebase (browser, desktop, mobile), with the local-first layer working the same across all of them. Multi-device sync should work out of the box. Good point about data ownership and not being trapped in IndexedDB - I’m currently leaning toward OPFS rather than IndexedDB for the storage layer, though I still need to do proper benchmarking before committing to that. I’ll take the data ownership aspect into account too.

3 Likes

Thanks for that incredibly thorough reply, the code examples really help ground the discussion!

I liked the local-only, local-first, server-only, three-way sync breakdown. I’m thinking along similar lines - and ideally this could be granular enough to configure per-model or even per-field if needed.

I’d push back a little on calling Ticket.open in both the action and the command. The whole goal of a local-first approach is to remove that kind of plumbing - the developer writes to the local store once, and the framework handles syncing to the server declaratively in the background. So the ideal DX would be closer to:

def action(:open_ticket, _params, component) do
  subject = component.state.new_subject
  {:ok, _ticket} = Ticket.open(subject)
  # Done. Framework syncs to server, handles conflicts, retries.

  put_state(component, :new_subject, nil)
end

No put_command, no server callback for the write itself. The framework knows Ticket is a synced model, so it writes locally and queues the sync automatically. The command queue you mentioned (retries, backoff, maybe replay on reconnection?, etc.) is a key piece - but it should be entirely opaque to the developer. They just write, and the framework takes care of the rest.

That still leaves inherently server-side effects - sending emails, calling external APIs, starting Oban jobs. Today those live in commands, but we could potentially push this further with some kind of declarative workflow mechanism tied to the model itself - server-side effects that trigger automatically when a synced write lands on the server. That way the page code stays focused purely on intent and state, no manual command wiring at all. But that’s a separate discussion.

Regarding the integration angle - I think the right approach is for Hologram to provide its own sync protocol and conflict resolution hooks as native primitives (think “Hologram sync protocol”, “Hologram conflict resolution hooks”). This would make Hologram’s local-first layer extendable, so any library or custom solution can plug into it.

Really appreciate the enthusiasm and depth here :slight_smile:

2 Likes