Open your app's dashboard in three browser tabs. Open the network panel in each. Now refresh them one after another.
You will probably see the same requests, three times, for data your browser fetched a few seconds ago. Close the laptop, open it on a train with bad signal, and the app either spins or shows an error. Edit something while the connection drops and you are hoping the request retries.
None of this is a bug. It is the default shape of a client server web app: the server is the only place data lives, and the browser asks for it every time. The browser is treated as a screen, not as a place that can hold anything.
"Local-first" is the idea that flips that default. The data lives on the device first, and the network is how it gets distributed. Most of the writing about it covers the research and the CRDT tooling. That is great for new collaborative apps, but a lot of us have a Postgres database, a REST API, and a production app we are not going to rewrite.
So this post is about the other case: getting local-first behavior while your backend stays exactly where it is.
I am the founder of the team building Flux, a library that does this, so I'll use it as the worked example. The ideas apply even if you build the pieces yourself.
Local-first, with a backend that is still in charge
There are two broad ways to get there.
The first is to move the source of truth to the client and replicate it, usually with CRDTs. Automerge and Yjs are the well known libraries. It is the right model when multiple people edit the same document concurrently and you want merges that never conflict.
The second keeps your backend as the system of record. The browser keeps a local copy, coordinates it across tabs, queues writes while offline, and reconciles with the server when it can. Conflicts still exist here, and your backend still decides what is true. This is the model Flux follows, and it fits dashboards, internal tools, booking systems, e-commerce and operational software, where the server has to stay authoritative.
If you are not sure which you need, ask this: could two people legitimately edit the same thing at once and both be right? If yes, look at CRDTs. If your data has one correct answer owned by the server, a backed local layer is simpler.
Problem 1: every tab asks for the same thing
Browsers do not share state between tabs by default. Ten tabs means ten bootstraps, ten sockets, ten sets of identical reads.
The fix is coordination. One tab is elected as the leader and makes the network request. The other tabs hydrate from local storage instead of calling your server. When a tab changes something, it signals the others, so the update shows up everywhere immediately with no refresh.
The practical result: ten tabs open, one request to your server, and every tab agrees about what the data is.
Problem 2: return visits start from zero
Most apps on a repeat visit do the same work as a first visit: render a shell, fire requests, show a spinner, render data.
If the data is already in IndexedDB, there is no reason to wait. The useful model has three states for any cached item:
Fresh: the data is recent enough. Render it and do not ask the server.
Stale: the data is past its time to live. Render it right away, and revalidate in the background.
Missing: nothing is cached. This one waits for the network, like any first visit.
The detail worth noticing is that a time to live describes freshness, not deletion. Expired data can still be shown immediately, and it only gets replaced when something newer arrives. Return visits can render from local data before the network responds. They will not always skip the loading state, because a cold cache still has to fetch, and anyone who promises otherwise is overselling.
Problem 3: writes that vanish offline
Reads are the easy half. The harder question is what happens when a user saves something and the connection is gone.
The pattern that holds up is a durable queue:
The write goes into local storage first.
The UI updates optimistically.
When the network returns, queued writes replay in order.
Duplicates are removed, and repeated edits to the same record can be coalesced into one request.
The payoff is in your own code. The handler for "save this note" does not need an offline branch. You write the save once, and the queue deals with the gap between the browser and your backend.
There are edge cases you should know about. Browser File objects do not survive a tab closing the way plain JSON does, so uploads need their own pattern. A write that is delayed is not a write that is guaranteed to succeed, because the server can still reject it. A good queue makes failures visible instead of hiding them.
Problem 4: reconnecting should not mean re downloading everything
A phone comes back online after twenty minutes. The naive recovery is to refetch everything. That works, and it is expensive for your server and slow for the user.
The better approach is a sync anchor: a marker for "the last point this client knew about", tracked per channel. On reconnect, the client presents its anchor and the server sends only what changed since. Deletions need extra care, because a delta log only tells you about rows that still exist. A reconcile step, where the client and server compare what they believe is present, catches the rows that were removed while the client was away.
Problem 5: the world changed while you were offline
Here is the case that separates a real system from a demo. A user edits a record offline. While they are offline, someone else changes the same record on the server. Then the first user reconnects and their queued edit replays.
If you do nothing, the stale edit overwrites newer data.
The approach in Flux is a two pass handshake for queued offline mutations. Before the write lands, the client and server check whether the record moved. If it did, the write is held as a conflict instead of being applied, and your application decides what to do: keep the server version, keep the local one, or show the user both. Conflicts are scoped to queued offline writes, not to every request, which keeps the mechanism small and predictable.
Offline is not the hard part. Knowing what to do when things changed underneath you is.
Problem 6: shared machines
Cached data in a browser belongs to whoever logged in last, unless something prevents that. On a family laptop, a kiosk, or a work machine passed between shifts, user B should never see user A's cached data.
The fix is structural, not a cleanup step you hope runs. Every cache entry, queued write, sync anchor and conflict lives under a per user namespace. Logging out purges that namespace and signals the other tabs to do the same. On the next boot, a sweep removes namespaces that no longer have a session.
To be precise about what this is: it isolates browser state. It is not authentication and it is not authorization. Your server still decides who is allowed to see what.
What the setup looks like
For a Next.js app, the service worker and the offline routing are generated at build time from a config wrapper, so you never write the worker yourself. The full walkthrough is in the Next.js guide.
ts
`
// next.config.ts
import type { NextConfig } from "next";
import { withFlux } from "@tsworldtech/flux-next/plugin";
const nextConfig: NextConfig = {};
export default withFlux({
precacheRoutes: ["/", "/offline"],
offlineShell: "/offline",
storagePrefix: "myapp",
dynamicRoutePrefixes: ["/products/", "/blog/"],
})(nextConfig);
`
dynamicRoutePrefixes is the part people find surprising. It lets a route like /products/[slug] open offline even for a product the user has never visited, because the app shell is served at the real URL and the data comes from local storage.
Realtime comes through adapters for Supabase, Socket.IO, Server Sent Events and plain WebSocket. Your backend implements a small contract: an event shape, a sync handshake, and a changelog table so reconnects can send only what was missed.
You can prove it works before you configure anything else. Register one store, load the app, switch the network off in devtools, and refresh. The app should come up from local data. Production concerns like licensing, realtime adapters and conflict handling can wait until that moment has happened.
When not to use this
Be honest with yourself about fit. A backed local layer is a poor choice when:
You need strict consistency. Inventory, ledgers and anything where two people must not claim the same resource. Eventual agreement is not enough, and the server has to stay in the loop for every write.
The page is mostly static content. Marketing sites and blogs gain little from client side persistence.
The data is purely server generated. Analytics feeds and search results are fetched, displayed and discarded. There is nothing worth remembering.
You are building a multiplayer editor. Concurrent document editing is what CRDTs are for.
It fits well when users return to the same data repeatedly, keep several tabs open, work on unstable connections, or queue changes while offline.
Where this sits among the other tools
This is a map, not a ranking.
TanStack Query and SWR manage server state and caching in memory. They are excellent, and they do not persist across sessions or coordinate tabs by default.
PowerSync and ElectricSQL sync a database subset into a local SQLite database. They are a strong choice if you want a local relational store and queries.
RxDB is a reactive local database with replication to a backend of your choice.
Automerge and Yjs are CRDT libraries for conflict free collaborative data.
Flux coordinates persistence, tabs, queued writes, realtime and reconnect around an existing API, without changing your data model or moving your database.
They overlap more than the labels suggest, and the right pick depends on what you are optimizing for.
Try it
If your app opens the same data on every visit, keeps several tabs open, or has users on shaky connections, run the experiment from the top of this post. Count the repeated requests. Then decide whether the browser should keep remembering things for you.
The docs and quickstart are at
. I would like to hear which of the six problems hurts most in your app, and which one I got wrong.
Top comments (0)