About this demo app
An example of a real-time, collaborative multi-page web app built with Phoenix LiveView
designed for offline-first ready: it is packaged as a PWA.
While the app supports full offline interaction and local persistence using CRDTs (via Yjs
and y-indexeddb
), the core architecture is still grounded in a server-side source of truth. The server database ultimately reconciles all updates, ensuring consistency across clients.
This design enables:
✅ Full offline functionality and interactivity
✅ Real-time collaboration between multiple users
✅ Reconciliation with a central, trusted source of truth when back online
Architecture at a glance
Client-side CRDTs (
Yjs
) manage local state changes (e.g. counter updates), even when offlineServer-side database (
Postgres
andSQLite
) remains authoritative-
When the client reconnects, local CRDT updates are synced with the server:
- In one page, via
Postgres
andPhoenix.Sync
with logical replication - In another, via
SQLite
using aPhoenix.Channel
.
- In one page, via
Offline first solutions naturally offloads the reactive UI logic to JavaScript. We used
SolidJS
.We used
Vite
for bundling and bring in thevite-plugin-pwa
. It registers a Service Worker to cache app shell and assets for offline usage.
How it works
Optimistic Updates with Centralised Reconciliation
Although we leverage Yjs (a CRDT library) under the hood, this isn’t a fully peer-to-peer, decentralised CRDT system. Instead, in this demo we have:
- No direct client-to-client replication (not pure lazy/optimistic replication).
- No concurrent writes against the same replica—all operations are serialised through the server.
- Writes are serialised but actions are concurrent.
What we do have is asynchronous reconciliation with an operation-based CRDT approach:
- User actions (e.g. clicking “decrement” on the counter) are applied locally to a
Yjs
document stored inIndexedDB
. - The same operation (not the full value) is sent to the server via
Phoenix
(eitherPhoenix.Sync
orPhoenix.Channel
). - Phoenix broadcasts that op to all connected clients.
- Upon receipt, each client applies the op to its local Yjs document—order doesn’t matter, making it commutative.
- The server database (
Postgres
orSQLite
) remains the single source of truth and persists ops in sequence.
In CRDT terms: we use an operation-based CRDT (CRDT Counter) for each shared value Ops commute (order-independent) even though they pass through a central broker.
Rendering Strategy: SSR vs. Client-Side Hooks
To keep the UI interactive both online and offline, we mix LiveView
’s server-side rendering (SSR) with a client-side reactive framework:
-
Online (LiveView SSR or Hooks)
- The PhxSync page renders a LiveView via
streams
and the "click" event sends data to the client to update the localYjs
document. - The YjsCh page renders a JS-hook which initialises a
SolidJS
component. In the JS-hook, theSolidJS
communicates via a Channel to update the database and the localYjs
document.
- The PhxSync page renders a LiveView via
-
Offline (Manual Rendering)
- We detect the status switch via a server polling.
- The Service Worker serves the cached HTML from the cache & JS bundle.
- We render the correct JS component.
- The component reads from and writes to the local
Yjs
+IndexedDB
replica and remains fully interactive.
Service Worker & Asset Caching
vite-plugin-pwa
generates a Service Worker that pre-caches the app shell static assets (HTML, CSS, JS) on install.
We intercept navigation with phx:navigate
to store the HTML document with the Cache API
. When offline, we serve the cached app shell.
This ensures the entire app loads reliably even without network connectivity.
Login and token rotation
We made a "dummy" login to set a user_id. We then use an access token (saved in the session) and a refresh token (saved in a cookie) to authenticate and rotate the custom "userSocket".
Results
Deployed on Fly.io: https://liveview-pwa.fly.dev
Repo: https://github.com/ndrean/LiveView-PWA
About the pages
The Phoenix.Sync
+Postgres
+ Yjs
/IndexedDB
page:
The SQLite
+ Phoenix.Channel
+ Yjs
/IndexedDB
page:
The FlightMap page
We propose an interactive map with a form with two inputs where two users can edit collaboratively a form to display markers on the map and then draw a great circle between the two points. The state is local, ephemeral by design as we don't need persistence. We still need a state manager since it is collaborative. We also need an observer/listener on state changes: when a remote user changes an input, he broadcasts the input and this updates the local state, which renders the UI. It uses Valtio
, a proxy based lightweight state manager.
Tech overview
Component | Role |
---|---|
Vite | Build and bundling framework |
SQLite | Embedded persistent storage of latest Yjs document |
Postgres | Supports logical replication |
Phoenix LiveView | UI rendering, incuding hooks |
Phoenix.Sync | Relays Postgres streams into LiveView |
PubSub / Phoenix.Channel | Broadcast/notifies other clients of updates / conveys CRDTs binaries on a separate websocket (from te LiveSocket) |
Yjs / Y.Map | Holds the CRDT state client-side (shared) |
y-indexeddb | Persists state locally for offline mode |
Valtio | Holds local ephemeral state |
Hooks | Injects communication primitives and controls JavaScript code |
Service Worker / Cache API | Enable offline UI rendering and navigation by caching HTML pages and static assets |
SolidJS | renders reactive UI using signals, driven by Yjs observers |
Leaflet | Map rendering |
MapTiler | enable vector tiles |
WebAssembly container | high-performance calculations for map "great-circle" routes use Zig code compiled to WASM
|
Common pitfall of combining LiveView
with CSR components
The client-side rendered components are - when online - mounted via hooks under the tag phx-update="ignore"
.
These components have they own lifecycle. They can leak or stack duplicate components if you don't cleanup them properly.
The same applies to "subscriptions/observers" primitives from (any) the state manager. You must unsubscribe, otherwise you might get multiples calls and weird behaviours.
⭐️ LiveView hooks comes with a handy lifecyle and the destroyed
callback is essential.
SolidJS
makes this easy as it can return a cleanupSolid
callback (where you take a reference to the SolidJS component in the hook).
You also need to clean subscriptions (when using a store manager).
The same applies when you navigate offline; you have to run cleanup functions, both on the components and on the subsriptions/observers from the state manager.
Service worker lifecycle
When the client code is updated, we get notified:
Once we accept the update, the new code is active:
Build tool: Vite
Since we need to setup a Service Worker, we used Vite
and the plugin VitePWA
.
We let Vite
bundle all the code and can remove safely esbuild
and tailwindcss
. They are now imported by Vite
.
No esbuild
nor tailwind
in the mix.exs
, no mix phx.digest
as files will be fingerprinted by Vite
in "prod" mode.
You need to distinguish between fingerprinted and not static assets. For example, icons, robots.txt, sitemap.xml... are not fingerprinted. Since the "/priv/static" folder is pruned, we copy the non-fingerprinted files into this folder through a function defined in the vite.config.js module. These file are served by Phoenix
.
We let Vite
manage the others. In particular, the paths are proxied by http://localhost:5173
.
In production mode, we use a plugin for this and Vite
will build and hash the files.
Therefor, we use an Elixir module to proxy or discover the path.
DEV HMR mode
You will be running the vite
dev server on port 5173 and the Phoenix
webserver on port 4000.
You prepend the static assets with
http://localhost:5173
in DEV mode, or find the hashed name in PROD mode.
# config.exs
config :liveview_app, :env, config_env()
# dev.exs
config :liveview_pwa, LiveviewPwaWeb.Endpoint,
[...]
code_reloader: true,
debug_errors: true,
secret_key_base: "aaaaaaaa.........................aaaa",
live_reload: [
web_console_logger: true,
patterns: [
~r"lib/liveview_pwa_web/(controllers|live|components|router|channels)/.*\.(ex|heex)$",
~r"lib/liveview_pwa/.*\ex
]
],
watchers: [
pnpm: [
"vite",
"serve",
"--mode",
"development",
"--config",
"vite.config.js",
cd: Path.expand("../assets", __DIR__)
]
]
- The Root layout:
Pass the assign env
(or the name you want) in the first live component (or controller):
|> assign(:env, Application.fetch_env!(:liveview_pwa, :env))
Add the following to "root.html.heex":
<link
:if={@env === :prod}
rel="stylesheet"
href={Vite.path("css/app.css")}
/>
<script
:if={@env === :dev}
type="module"
src="http://localhost:5173/@vite/client"
>
</script>
<script
defer
type="module"
src={Vite.path("js/main.js")}
>
</script>
- DEV server configuration:
// vite.config.js
const devServer = {
cors: { origin: "http://localhost:4000" },
allowedHosts: ["localhost"],
strictPort: true,
origin: "http://localhost:5173", // Vite dev server origin
port: 5173, // Vite dev server port
host: "localhost", // Vite dev server host
};
export default defineConfig = ({command, mode}) => {
if (command == 'serve') {
process.stdin.on('close', () => process.exit(0));
copyStaticAssetsDev();
process.stdin.resume();
}
return {
server: mode === 'development' && devServer
[...]
}
})
You will benefit from using the resolve
config by using alias
.
For example:
import img from "@assets/images/img.web";
// or dynamic at runtime inside a function
const img = new URL("@assets/images/img.web`", import.meta.url).href;
PROD mode
For the PROD mode, the files are versioned by Vite
because we want to be able to update the app when the client code changes.
Therefor, we removed the step phx.digest
from the Dockerfile.
Vite
produces a dictionary between the original asset name and the fingerprinted one. You need to use an Elixir module helper and use it in "root.html.ex".
Source code of "vite.config.js"
We also need to build a Manifest file. PWABuilder is a good source.
We also need to copy the static files.
Highlight of "vite.config.js": the VitePWA plugin.
PWAConfig = {
// Don't inject <script> to register SW (handled manually)
// and there no client generated "index.html" by Phoenix
injectRegister: false, // no client generated "index.html" by Phoenix
// Let Workbox auto-generate the service worker from config
strategies: "generateSW",
// App manually prompts user to update SW when available
registerType: "prompt",
// SW lifecycle ---
// Claim control over all uncontrolled pages as soon as the SW is activated
clientsClaim: true,
// Let app decide when to update; user must confirm or app logic must apply update
skipWaiting: false,
workbox: {
// Disable to avoid interference with Phoenix LiveView WebSocket negotiation
navigationPreload: false
// ❗️ no fallback to "index.html" as it does not exist
navigateFallback: null
// ‼️ tell Workbox not to split te SW as the other is fingerprinted, thus unknown to Phoenix.
inlineWorkboxRuntime: true,
// preload all the built static assets
globPatterns: ["assets/**/*.*"],
}
}
Store managers
The app uses a context store createStore
powered by SolidJS
.
For the PhxSync and YjsCh pages, we used Yjs
client-side and Postgres and SQLite respectively.
For the FlightMap page, we use Valtio
as we didn't design the state of this page to survive a network disruption.
Thats impressive!