Offline Data Synchronization with SQLite and Redux Persist

Table of Contents
Big thanks to our contributors those make our blogs possible.

Our growing community of contributors bring their unique insights from around the world to power our blog. 

Introduction

Building modern mobile and web applications often means supporting offline functionality—letting users view, edit, and interact with data even when their connection is spotty or non-existent. Combining a client-side database like SQLite with a state-persistence library such as Redux Persist creates a robust solution for offline data synchronization. In this guide, you’ll learn why offline sync matters, how to architect your app’s data flow, and step through concrete code examples demonstrating how to integrate SQLite for durable storage and Redux Persist for state recovery. We’ll also cover conflict resolution strategies, real-world analogies, and expert tips to ensure seamless user experiences whether online or offline.

Why Offline Synchronization Matters

  1. Uninterrupted UX: Users remain productive—reading, writing, or updating—regardless of network state.
  2. Performance Boost: Local reads/writes to SQLite are faster than round-trips to a remote API.
  3. Data Integrity: Queued changes reduce risk of data loss during intermittent connectivity.
  4. Competitive Edge: Offline-capable apps stand out in app stores and drive engagement.

Analogy: Think of your app like a field notebook. Even when you’re deep in the wilderness (offline), you jot down notes. Later, once back at camp (online), your notebook syncs its contents to headquarters, merging seamlessly with the master log.

Core Components: SQLite and Redux Persist

SQLite: The Embedded Relational Database

  • Durable Storage: Persists data on device disk.
  • Structured Queries: ACID-compliant SQL engine ideal for complex joins.
  • Cross-Platform: Available on Android, iOS, and via WASM for web.

Redux Persist: State Rehydration Library

  • Automated Persistence: Saves Redux store snapshots to storage (AsyncStorage, localForage, etc.).
  • Rehydration: Restores store on app launch—no manual hydration code.
  • Transforms & Blacklisting: Customize which slices to persist or encrypt.

Expert Insight: While Redux Persist alone stores state, combining it with SQLite lets you offload large datasets from memory, query data efficiently, and maintain a normalized cache.

Step-by-Step Integration Guide

1. Project Setup and Dependencies

Install the core libraries:

bashCopyEditnpm install redux react-redux redux-persist
npm install react-native-sqlite-storage  # or expo-sqlite for Expo apps

Import and configure:

jsCopyEditimport SQLite from 'react-native-sqlite-storage';
import { createStore } from 'redux';
import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';

2. Initialize SQLite Database

Create or open your database when the app starts:

jsCopyEditconst db = SQLite.openDatabase(
  { name: 'app.db', location: 'default' },
  () => console.log('DB opened'),
  error => console.error('DB error', error)
);

Define tables:

jsCopyEditdb.transaction(tx => {
  tx.executeSql(
    `CREATE TABLE IF NOT EXISTS notes (
       id TEXT PRIMARY KEY NOT NULL,
       title TEXT,
       content TEXT,
       updated_at INTEGER
     );`
  );
});

3. Architecting Your Data Flow

  1. In-Memory State (Redux): Holds UI-centric flags (e.g., “isSyncing”).
  2. Persistent Cache (Redux Persist): Small slices like auth tokens or preferences.
  3. Local Database (SQLite): Large collections—notes, messages, products.
  4. Remote API: Central authoritative data store.

When users create or update an item:

textCopyEditUser interaction → Dispatch Redux action → Write to SQLite → Flag as “dirty” → Later sync to server

4. Persisting Redux State

Configure persistReducer:

jsCopyEditconst persistConfig = {
  key: 'root',
  storage: AsyncStorage,
  whitelist: ['auth', 'preferences']  // leave out large data
};

const rootReducer = combineReducers({ auth, preferences, ui });
const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = createStore(persistedReducer);
export const persistor = persistStore(store);

Wrap your app:

jsxCopyEditimport { PersistGate } from 'redux-persist/integration/react';

<PersistGate loading={null} persistor={persistor}>
  <Provider store={store}>
    <App />
  </Provider>
</PersistGate>

5. CRUD Operations with SQLite

Creating or Updating Records

jsCopyEditfunction saveNote({ id, title, content }) {
  const timestamp = Date.now();
  db.transaction(tx => {
    tx.executeSql(
      `REPLACE INTO notes (id, title, content, updated_at) VALUES (?, ?, ?, ?)`,

[id, title, content, timestamp]

); }); }

Reading Records

jsCopyEditfunction fetchNotes(callback) {
  db.transaction(tx => {
    tx.executeSql('SELECT * FROM notes ORDER BY updated_at DESC;', [], (_, { rows }) => {
      const items = rows.raw(); 
      callback(items);
    });
  });
}

6. Syncing with Remote API

Marking Dirty Records

Add a dirty flag to notes:

sqlCopyEditALTER TABLE notes ADD COLUMN dirty INTEGER DEFAULT 0;

Set dirty = 1 on local write:

jsCopyEdittx.executeSql(
  `REPLACE INTO notes (id, title, content, updated_at, dirty) VALUES (?, ?, ?, ?, 1)`,

[id, title, content, timestamp]

);

Background Sync Task

Use setInterval or background task APIs:

jsCopyEditasync function syncNotes() {
  const dirtyNotes = await new Promise(resolve => {
    db.transaction(tx => {
      tx.executeSql('SELECT * FROM notes WHERE dirty = 1;', [], (_, { rows }) => {
        resolve(rows.raw());
      });
    });
  });

  for (const note of dirtyNotes) {
    try {
      await api.updateNoteOnServer(note);
      // On success, clear dirty flag
      db.transaction(tx => {
        tx.executeSql('UPDATE notes SET dirty = 0 WHERE id = ?', [note.id]);
      });
    } catch (err) {
      console.error('Sync failed for', note.id, err);
    }
  }
}
setInterval(syncNotes, 5 * 60 * 1000);  // every 5 minutes

7. Conflict Resolution Strategies

  • Last-Write-Wins: Simplest—use updated_at timestamp to decide.
  • Manual Merge: Prompt users when conflicts arise (two versions differ).
  • Operational Transforms: Advanced—merge operations at the field level.

Expert Tip: Pair updated_at with a client-generated version counter to detect and handle race conditions more reliably.

Real-World Analogies and Best Practices

  • Analogy: Email Drafts
    • You compose an email offline, hit send later; your mail client queues and syncs drafts when connected.
  • Chunked Syncing:
    • Batch records into manageable chunks to avoid API timeouts.
  • Exponential Backoff:
    • On repeated sync failures, delay retries with backoff to prevent server overload.
  • Data Encryption:
    • Encrypt sensitive fields in SQLite using libraries like react-native-sqlcipher-storage.

Conclusion

Offline data synchronization melds the reliability of SQLite with the convenience of Redux Persist, crafting a resilient architecture that keeps users productive regardless of connectivity. By strategically separating UI state, persisted preferences, and large data sets, you create a balanced, performant app. Implement CRUD operations in SQLite, mark and batch-sync dirty records, and adopt a conflict resolution strategy suited to your domain. With these building blocks—plus best practices like backoff, chunking, and encryption—you’re ready to deliver seamless offline experiences that delight users and maintain data integrity.

Let's connect on TikTok

Join our newsletter to stay updated

Sydney Based Software Solutions Professional who is crafting exceptional systems and applications to solve a diverse range of problems for the past 10 years.

Share the Post

Related Posts