DEV Community

Abdullah Teslim
Abdullah Teslim

Posted on

How to Build Local-First Software with React: From IndexedDB to Offline Synchronization

How to Build Local-First Software with React: From IndexedDB to Offline Synchronization

Your React application should not fall apart just because the network doesn't.

Most web applications are built around a simple assumption: the browser requests data from a server, receives a response, and displays it.

That architecture works. Until it doesn't.

A user opens a dashboard and waits for the API. They open the same dashboard in another tab, triggering another round of requests. They lose connectivity while editing a record. They refresh the page and wait for data they already loaded a few minutes ago.

The problem isn't React. It isn't necessarily your database, either.

The problem is that many applications treat the browser as a temporary display layer instead of a place where application data can live and remain useful.

Local-first architecture challenges that assumption.

Instead of making every interaction depend on a successful network request, a local-first application keeps relevant data on the user's device, reads from local storage, and coordinates changes with the server when connectivity allows.

In this article, we'll explore how to approach local-first software with React, the browser technologies involved, the synchronization problems developers need to solve, and how to reduce the amount of infrastructure required to make it work.

1. What does local-first actually mean?

Local-first software prioritizes the local copy of application data rather than treating the remote server as the only place where useful data exists.

Consider a project management application.

A conventional implementation might fetch tasks every time the dashboard mounts:

\`jsx
import { useEffect, useState } from "react";

export default function Tasks() {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
fetch("/api/tasks")
.then((response) => response.json())
.then(setTasks)
.finally(() => setLoading(false));
}, []);

if (loading) return

Loading tasks...

;

return (

    {tasks.map((task) => (
  • {task.title}
  • ))}

);
}
`\

This is perfectly reasonable for a basic application. But the implementation depends on a successful network request before it can display the tasks.

If the user revisits the page, the application may fetch the same data again. If the connection fails, the application has no independent source from which to recover the previously loaded tasks.

A local-first design changes the data flow:

  1. Read previously stored data from the browser.
  2. Render that data as early as practical.
  3. Check whether the server has newer information.
  4. Update the local copy when fresh data arrives.
  5. Store user changes locally before attempting remote synchronization.
  6. Deliver pending changes to the server when possible.

The server still matters. It may remain the authoritative source for shared records, permissions, and business rules.

Local-first does not mean serverless, and it does not mean abandoning your existing backend.

It means designing the client so that ordinary reading and editing do not unnecessarily depend on the network being available at that exact moment.

2. Choose the right browser storage

React state is temporary. It exists while the relevant application runtime is active, but it does not automatically survive a page reload.

For persistent local data, web applications have several options.

localStorage is useful for small values such as preferences and simple flags. Its synchronous API and string-based storage make it a poor foundation for large, structured application datasets.

IndexedDB provides asynchronous storage for structured data and is generally a better foundation for browser-side application data.

Service workers can intercept supported network requests and help applications serve cached resources or provide offline navigation experiences. They do not automatically synchronize your application database.

These technologies solve different problems. A service worker does not replace IndexedDB, and IndexedDB does not automatically synchronize with your backend.

For this tutorial, IndexedDB is our starting point.

3. Create a persistent local data layer

You can use the browser's native IndexedDB API directly, but its event-based interface can be cumbersome. A wrapper such as the idb package makes it easier to work with.

Install it:

\bash
npm install idb
\
\

Create a database module:

\`js
// db.js
import { openDB } from "idb";

export const dbPromise = openDB("project-manager", 1, {
upgrade(db) {
db.createObjectStore("tasks", {
keyPath: "id",
});
},
});

export async function getTasks() {
const db = await dbPromise;
return db.getAll("tasks");
}

export async function saveTask(task) {
const db = await dbPromise;
await db.put("tasks", task);
}
`\

We have created a database called project-manager with an object store for tasks.

The put() operation inserts a record or replaces the existing record with the same key. The getAll() operation retrieves the records in that store.

This is a basic persistence layer. It does not yet handle server synchronization, record conflicts, storage failures, or multi-user isolation.

Those are separate engineering problems.

4. Connect IndexedDB to React

Now let's build a component that reads its initial data from local storage.

\`jsx
import { useEffect, useState } from "react";
import { getTasks, saveTask } from "./db";

export default function Tasks() {
const [tasks, setTasks] = useState([]);
const [ready, setReady] = useState(false);

useEffect(() => {
let cancelled = false;

getTasks()
  .then((storedTasks) => {
    if (!cancelled) {
      setTasks(storedTasks);
      setReady(true);
    }
  })
  .catch((error) => {
    console.error("Could not read local tasks", error);
  });

return () => {
  cancelled = true;
};
Enter fullscreen mode Exit fullscreen mode

}, []);

async function addTask(title) {
const task = {
id: crypto.randomUUID(),
title,
updatedAt: Date.now(),
};

await saveTask(task);
setTasks((current) => [...current, task]);
Enter fullscreen mode Exit fullscreen mode

}

if (!ready) return

Opening local data...

;

return (


addTask("New task")}>
Add task
  <ul>
    {tasks.map((task) => (
      <li key={task.id}>{task.title}</li>
    ))}
  </ul>
</section>

);
}
`\

This is a useful first step: data can survive a reload because it is stored outside React's in-memory state.

However, there are two important limitations.

First, this example waits for IndexedDB before rendering the task list. A production implementation could render the surrounding application immediately while managing local hydration separately.

Second, the example only reads and writes local data. It has no mechanism for retrieving server updates or delivering local changes to the backend.

Persistence is not synchronization.

That distinction is where local-first engineering becomes more interesting.

5. Make the local copy and server work together

A real application usually has two copies of relevant data:

  • The local copy, optimized for immediate access and continued work.
  • The remote copy, used to coordinate data between users and devices.

Once these copies exist, you need explicit rules for keeping them consistent.

A simple synchronization flow looks like this:

\text
Application starts
|
v
Read local data
|
v
Render available data
|
v
Request server updates
|
v
Validate and store changes
|
v
Update the application state
\
\

A straightforward implementation can fetch server data and replace the local copy.

\`js
import { dbPromise } from "./db";

export async function refreshTasks() {
const response = await fetch("/api/tasks");

if (!response.ok) {
throw new Error("Could not synchronize tasks");
}

const tasks = await response.json();
const db = await dbPromise;

const tx = db.transaction("tasks", "readwrite");

await tx.store.clear();

for (const task of tasks) {
await tx.store.put(task);
}

await tx.done;

return tasks;
}
`\

This example illustrates the basic mechanics, but it assumes the endpoint returns the complete dataset and that replacing the collection is safe.

That assumption will not hold for every application.

If the server returns only a partial result, clearing the local collection could delete valid records. If the user has unsynchronized local edits, replacing records could overwrite their work.

Production synchronization needs to account for the shape of the data, the scope of the response, pending local changes, and the server's version of each record.

For large datasets, fetching the entire collection repeatedly can also become expensive. Incremental synchronization, pagination, version markers, and server-provided changes are often more appropriate.

6. Support offline writes

Reading data offline is only part of the problem.

Suppose a user edits a task while disconnected. If your application updates React state but never persists the edit, the change can disappear on reload.

A more resilient approach is to persist the change and record the remote operation that still needs to happen.

A queue entry might look like this:

\js
{
id: "operation-123",
type: "UPDATE_TASK",
entityId: "task-456",
payload: {
title: "Prepare release"
},
createdAt: Date.now(),
status: "pending"
}
\
\

The application can show the new task immediately while marking the change as pending synchronization.

A simplified queue processor might look like this:

\js
async function sendTaskUpdate(task) {
const response = await fetch(
/api/tasks/${task.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: task.title,
}),
});

if (!response.ok) {
throw new Error("Task update was not accepted");
}

return response.json();
}
`\

The important detail is not the HTTP method. It is the separation between saving a change locally and confirming that the server accepted it.

A robust queue needs to handle:

  • Durable storage of pending operations.
  • Retrying temporary network and server failures.
  • Removing or acknowledging successfully processed operations.
  • Preventing duplicate effects when a request is retried.
  • Ordering dependent operations correctly.
  • Identifying permanent errors that require user intervention.
  • Recovering after a page reload or browser restart.

It also needs a deliberate policy for repeated edits.

If a user changes a task's title five times while offline, sending all five intermediate updates may be unnecessary. Depending on the data model and operation semantics, the queue may be able to coalesce them into one final update.

But this is not safe for every operation. A payment, an audit event, and a task-title update do not have the same delivery requirements.

An offline queue is a consistency mechanism, not just an array of failed requests.

7. Handle conflicts instead of assuming they won't happen

Consider two devices editing the same task.

Device A changes the title while offline. Device B changes the same title online. When Device A reconnects, which value should win?

A timestamp alone may not be sufficient. Device clocks can differ, and a later timestamp does not necessarily represent the correct business decision.

Possible strategies include:

Last-write-wins: Accept the update that wins according to a defined ordering rule. This can work for some simple fields but may silently discard meaningful changes.

Version-based conflict detection: Attach a version or revision to a record. If the server has moved to a newer version, reject or flag the stale update instead of blindly overwriting it.

Field-level merging: Merge changes to different fields when the application can prove that doing so is safe.

Explicit conflict resolution: Present both versions to the user or apply domain-specific rules.

Conflict resolution should be designed around the meaning of the data. There is no single merge strategy that is correct for every application.

For example, independently edited profile preferences may be easy to merge. Inventory, financial records, and collaborative documents may require much stricter rules.

8. Coordinate multiple browser tabs

Open the same application in three tabs.

Without coordination, all three may independently fetch the same data, establish separate real-time connections, and attempt to process the same pending operations.

This can produce unnecessary server traffic and additional synchronization races.

A multi-tab-aware architecture can coordinate work by:

  1. Sharing persisted data through IndexedDB.
  2. Communicating updates between tabs.
  3. Electing or locking a tab to perform certain network operations.
  4. Preventing multiple tabs from replaying the same queue entry simultaneously.
  5. Propagating logout and account changes across the browser.

Browser APIs such as BroadcastChannel and the Web Locks API can help with parts of this design. They do not automatically provide a complete coordination system, and the application still needs to define its locking, recovery, and synchronization rules.

A browser with several tabs is effectively several concurrent application runtimes sharing some resources.

Treating each tab as an isolated client ignores that reality.

9. Don't confuse offline access with offline security

Local storage changes your security responsibilities.

A browser cache may contain private information. A queued write may belong to a particular user or tenant. If one person logs out and another signs in on the same device, the application must not expose the previous person's data.

At a minimum, design for:

  • User- and tenant-scoped storage.
  • Explicit cleanup or isolation during logout and account switching.
  • Server-side authorization for every protected operation.
  • Validation of queued operations when they reach the server.
  • Appropriate treatment of sensitive data at rest.
  • Storage quota errors and browser eviction.
  • Clear user feedback when a write could not be durably saved.

IndexedDB is not automatically encrypted just because it is browser storage. A local copy is not a substitute for server-side authorization, and hiding data in the interface is not an access-control mechanism.

Also, a successful local write does not guarantee permanent storage. Browsers can restrict, evict, or clear site data. Applications must distinguish between a change saved durably to local storage, a change waiting for synchronization, and a change that exists only in memory.

For critical workflows, communicate those states honestly.

10. The real cost: maintaining the synchronization engine

At this point, we have covered local persistence, hydration, server refreshes, offline writes, conflict detection, and multi-tab coordination.

There are still other concerns: stale data policies, retry backoff, incremental synchronization, real-time events, queue deduplication, schema migrations, session teardown, and recovery after partial failures.

Each mechanism introduces interactions with the others.

For example:

  • What happens when a real-time update arrives while a queued offline edit is waiting to replay?
  • What happens when a retry succeeds on the server but the browser crashes before recording the acknowledgement?
  • What happens when a user logs out while offline?
  • What happens when two tabs attempt to replay the same operation?
  • What happens when the server changes a record that the browser has not loaded yet?

You can build and maintain these systems yourself. For some products, a custom implementation is the right choice.

But the engineering cost is larger than writing an IndexedDB wrapper and a useEffect.

The difficult part is making all these mechanisms work together predictably.

11. Using Flux to implement the infrastructure

This is the problem I built Flux to address.

Flux is a local-first software infrastructure tool for web applications. It sits between the application and its backend, managing browser-side data persistence, synchronization, offline mutations, and coordination across tabs.

Rather than asking every React application to implement each mechanism independently, Flux provides a shared runtime for these concerns.

Its documented capabilities include:

  • IndexedDB-backed local data with configurable time-to-live policies.
  • Hydration of registered application stores from local data.
  • Offline write queues and replay.
  • Mutation coalescing for supported update patterns.
  • Real-time synchronization through supported adapters.
  • Cross-tab coordination for data fetching and synchronization.
  • Conflict detection and resolution workflows.
  • User-scoped storage and session cleanup.

Flux is not a replacement for React, your database, your authentication provider, or your backend API. Those systems remain responsible for their existing roles.

The objective is to give your application a more resilient client-side data layer without requiring you to assemble and maintain every mechanism yourself.

Explore the architecture and supported integrations in the Flux documentation.

12. Getting started with Flux and React

Flux supports React applications, including projects built with Vite, Remix, and Create React App. It also provides a Next.js integration.

The general integration process is:

  1. Install the core package and the integration or adapter appropriate for your stack.
  2. Create a single Flux engine instance.
  3. Connect the engine to your existing backend or supported real-time provider.
  4. Register the application stores that need local persistence and synchronization.
  5. Configure data scope, caching policies, and any required offline-write behavior.
  6. Integrate hydration and synchronization into your application lifecycle.
  7. Test reconnects, duplicate requests, account switching, and conflicting updates.

The exact setup depends on your state manager, backend, and framework. Follow the relevant integration guide rather than assuming every application uses the same configuration.

Start here:

Flux for React and other supported frameworks

For the underlying architecture, read the five-layer resilience stack and the register() API reference.

You can also explore Flux at tsworldtechflux.dev.

Conclusion: Your browser can do more

React made it easier to build interactive interfaces. Modern browser storage and networking APIs make it possible to build applications that remain useful when the network is unreliable.

But local-first software requires more than caching a response.

It requires a deliberate approach to persistence, synchronization, offline mutations, conflict resolution, concurrent tabs, and user data isolation.

Start with a clear data model. Decide which operations can safely happen offline. Define how conflicts are resolved. Treat local storage as fallible, and test what happens when requests fail at the worst possible moment.

Then decide whether maintaining the entire synchronization layer is the best use of your engineering time.

The network will always be an external dependency. Your application does not have to make every interaction depend on it.

Build for the moment the connection fails, not just for the moment it works.

Top comments (0)