Offline Storage in React Native: AsyncStorage vs SQLite

React Native, SQLite|SEPTEMBER 12, 2025|0 VIEWS
Compare AsyncStorage and SQLite for React Native apps to choose the best offline storage solution for your needs

Introduction

Offline storage is crucial for modern mobile applications. Users expect apps to work seamlessly regardless of network connectivity, maintaining data integrity and providing instant access to information. React Native offers several storage solutions, with AsyncStorage and SQLite being the most popular choices for different use cases.

Choosing the right storage solution can significantly impact your app's performance, user experience, and development complexity. While AsyncStorage excels in simplicity and is perfect for storing lightweight data, SQLite provides robust relational database capabilities for complex data structures and queries.

In this comprehensive guide, we'll dive deep into both AsyncStorage and SQLite, comparing their strengths, weaknesses, and ideal use cases to help you make an informed decision for your React Native application.

What You'll Learn

  • Understanding AsyncStorage capabilities and limitations
  • Exploring SQLite features and implementation in React Native
  • Performance comparison between both storage solutions
  • Best practices for each storage method
  • When to choose AsyncStorage vs SQLite
  • Implementation examples and code patterns

Understanding React Native Storage Options

Storage Requirements in Mobile Apps

Modern mobile applications need to handle various types of data storage:

  1. User Preferences: Settings, theme preferences, language choices
  2. Authentication Data: Tokens, user sessions, login credentials
  3. Application State: Current screen, form data, temporary values
  4. Cache Data: API responses, images, frequently accessed content
  5. Business Data: User-generated content, transactions, complex relationships

The choice between AsyncStorage and SQLite largely depends on the complexity and volume of data your application needs to handle.

AsyncStorage: Simple Key-Value Storage

What is AsyncStorage?

AsyncStorage is React Native's built-in, asynchronous key-value storage system. It's designed to be simple, persistent, and global to your app. Think of it as a large, persistent hash table that survives app restarts.

Key characteristics:

  • Asynchronous: Non-blocking operations using Promises
  • Persistent: Data survives app restarts and updates
  • Simple: Key-value pairs with string-based storage
  • Global: Accessible from anywhere in your app
  • Platform-agnostic: Works on both iOS and Android

AsyncStorage Implementation

Basic Setup

First, install AsyncStorage (it was moved out of React Native core):

npm install @react-native-async-storage/async-storage

For iOS, you'll need to run:

cd ios && pod install

Basic Operations

Here's how to perform basic CRUD operations with AsyncStorage:

import AsyncStorage from '@react-native-async-storage/async-storage';

// Store data
const storeData = async (key, value) => {
  try {
    const jsonValue = JSON.stringify(value);
    await AsyncStorage.setItem(key, jsonValue);
    console.log('Data stored successfully');
  } catch (error) {
    console.error('Error storing data:', error);
  }
};

// Retrieve data
const getData = async (key) => {
  try {
    const jsonValue = await AsyncStorage.getItem(key);
    return jsonValue != null ? JSON.parse(jsonValue) : null;
  } catch (error) {
    console.error('Error retrieving data:', error);
    return null;
  }
};

// Remove data
const removeData = async (key) => {
  try {
    await AsyncStorage.removeItem(key);
    console.log('Data removed successfully');
  } catch (error) {
    console.error('Error removing data:', error);
  }
};

// Clear all data
const clearAll = async () => {
  try {
    await AsyncStorage.clear();
    console.log('All data cleared');
  } catch (error) {
    console.error('Error clearing data:', error);
  }
};

Advanced AsyncStorage Patterns

Batch Operations

For better performance when dealing with multiple keys:

// Store multiple items
const storeMultipleItems = async (keyValuePairs) => {
  try {
    const pairs = keyValuePairs.map(([key, value]) => [
      key,
      JSON.stringify(value),
    ]);
    await AsyncStorage.multiSet(pairs);
    console.log('Multiple items stored');
  } catch (error) {
    console.error('Error storing multiple items:', error);
  }
};

// Get multiple items
const getMultipleItems = async (keys) => {
  try {
    const values = await AsyncStorage.multiGet(keys);
    return values.map(([key, value]) => [
      key,
      value ? JSON.parse(value) : null,
    ]);
  } catch (error) {
    console.error('Error getting multiple items:', error);
    return [];
  }
};

Storage Helper Class

Create a reusable storage utility:

class StorageManager {
  // User preferences
  static async setUserPreference(key, value) {
    const prefKey = `user_pref_${key}`;
    return this.setItem(prefKey, value);
  }

  static async getUserPreference(key, defaultValue = null) {
    const prefKey = `user_pref_${key}`;
    const value = await this.getItem(prefKey);
    return value !== null ? value : defaultValue;
  }

  // Cache management with expiration
  static async setCacheWithExpiry(key, value, expirationMinutes = 60) {
    const expirationTime = Date.now() + expirationMinutes * 60 * 1000;
    const cacheData = {
      value,
      expirationTime,
    };
    return this.setItem(`cache_${key}`, cacheData);
  }

  static async getCacheWithExpiry(key) {
    const cacheData = await this.getItem(`cache_${key}`);

    if (!cacheData) return null;

    if (Date.now() > cacheData.expirationTime) {
      await this.removeItem(`cache_${key}`);
      return null;
    }

    return cacheData.value;
  }

  // Generic methods
  static async setItem(key, value) {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (error) {
      console.error('Storage error:', error);
      return false;
    }
  }

  static async getItem(key) {
    try {
      const value = await AsyncStorage.getItem(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error('Storage error:', error);
      return null;
    }
  }

  static async removeItem(key) {
    try {
      await AsyncStorage.removeItem(key);
      return true;
    } catch (error) {
      console.error('Storage error:', error);
      return false;
    }
  }
}

AsyncStorage Pros and Cons

Advantages

Simple API: Easy to learn and implement
Built-in: Part of React Native ecosystem
Asynchronous: Non-blocking operations
Cross-platform: Works on both iOS and Android
Lightweight: Minimal setup required
Fast for Simple Data: Quick access to key-value pairs

Disadvantages

String-only Storage: All data must be serialized to strings
No Querying: Cannot perform complex data queries
No Relationships: No way to establish data relationships
Limited to 6MB: Storage size limitations on some platforms
No Transactions: No atomic operations for multiple changes
Performance: Can be slow with large amounts of data

SQLite: Relational Database Power

What is SQLite?

SQLite is a lightweight, serverless, relational database that runs directly in your application. It provides full SQL capabilities while maintaining a small footprint, making it ideal for mobile applications that need complex data relationships and queries.

Key characteristics:

  • Relational: Support for tables, relationships, and complex queries
  • ACID Compliant: Atomic, Consistent, Isolated, and Durable transactions
  • SQL Support: Full SQL syntax for data manipulation
  • Performance: Optimized for read-heavy operations
  • Unlimited Size: No practical storage limits
  • Cross-platform: Works on all mobile platforms

SQLite Implementation in React Native

  1. react-native-sqlite-storage: Mature, stable library
  2. react-native-sqlite-2: Modern alternative with Promise support
  3. Expo SQLite: For Expo managed workflow
  4. react-native-quick-sqlite: High-performance option

Setting Up SQLite with react-native-sqlite-storage

Install the library:

npm install react-native-sqlite-storage

For React Native 0.60+, run:

cd ios && pod install

Database Manager Implementation

Create a comprehensive database manager:

import SQLite from 'react-native-sqlite-storage';

// Enable promise API
SQLite.enablePromise(true);

class DatabaseManager {
  constructor() {
    this.db = null;
  }

  // Initialize database
  async initDatabase() {
    try {
      this.db = await SQLite.openDatabase({
        name: 'AppDatabase.db',
        location: 'default',
        createFromLocation: '~databases/AppDatabase.db',
      });

      console.log('Database opened successfully');
      await this.createTables();
      return this.db;
    } catch (error) {
      console.error('Database initialization error:', error);
      throw error;
    }
  }

  // Create tables
  async createTables() {
    const createUsersTable = `
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL,
        age INTEGER,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
      );
    `;

    const createPostsTable = `
      CREATE TABLE IF NOT EXISTS posts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER,
        title TEXT NOT NULL,
        content TEXT,
        is_published BOOLEAN DEFAULT 0,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id)
      );
    `;

    const createIndexes = `
      CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
      CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id);
    `;

    try {
      await this.db.executeSql(createUsersTable);
      await this.db.executeSql(createPostsTable);
      await this.db.executeSql(createIndexes);
      console.log('Tables created successfully');
    } catch (error) {
      console.error('Error creating tables:', error);
    }
  }

  // User operations
  async createUser(userData) {
    const { name, email, age } = userData;
    const query = `
      INSERT INTO users (name, email, age) 
      VALUES (?, ?, ?)
    `;

    try {
      const result = await this.db.executeSql(query, [name, email, age]);
      return {
        success: true,
        insertId: result[0].insertId,
      };
    } catch (error) {
      console.error('Error creating user:', error);
      return { success: false, error: error.message };
    }
  }

  async getUser(userId) {
    const query = 'SELECT * FROM users WHERE id = ?';

    try {
      const result = await this.db.executeSql(query, [userId]);
      if (result[0].rows.length > 0) {
        return result[0].rows.item(0);
      }
      return null;
    } catch (error) {
      console.error('Error getting user:', error);
      return null;
    }
  }

  async updateUser(userId, userData) {
    const fields = Object.keys(userData);
    const values = Object.values(userData);
    const setClause = fields.map((field) => `${field} = ?`).join(', ');

    const query = `
      UPDATE users 
      SET ${setClause}, updated_at = CURRENT_TIMESTAMP 
      WHERE id = ?
    `;

    try {
      await this.db.executeSql(query, [...values, userId]);
      return { success: true };
    } catch (error) {
      console.error('Error updating user:', error);
      return { success: false, error: error.message };
    }
  }

  async deleteUser(userId) {
    // Delete user and their posts (cascade delete)
    const deletePostsQuery = 'DELETE FROM posts WHERE user_id = ?';
    const deleteUserQuery = 'DELETE FROM users WHERE id = ?';

    try {
      await this.db.transaction(async (tx) => {
        await tx.executeSql(deletePostsQuery, [userId]);
        await tx.executeSql(deleteUserQuery, [userId]);
      });
      return { success: true };
    } catch (error) {
      console.error('Error deleting user:', error);
      return { success: false, error: error.message };
    }
  }

  // Advanced queries
  async getUsersWithPosts() {
    const query = `
      SELECT 
        u.id as user_id,
        u.name,
        u.email,
        p.id as post_id,
        p.title,
        p.is_published
      FROM users u
      LEFT JOIN posts p ON u.id = p.user_id
      ORDER BY u.name, p.created_at DESC
    `;

    try {
      const result = await this.db.executeSql(query);
      const rows = [];
      for (let i = 0; i < result[0].rows.length; i++) {
        rows.push(result[0].rows.item(i));
      }
      return rows;
    } catch (error) {
      console.error('Error getting users with posts:', error);
      return [];
    }
  }

  async searchUsers(searchTerm) {
    const query = `
      SELECT * FROM users 
      WHERE name LIKE ? OR email LIKE ?
      ORDER BY name
    `;

    try {
      const searchPattern = `%${searchTerm}%`;
      const result = await this.db.executeSql(query, [
        searchPattern,
        searchPattern,
      ]);
      const users = [];
      for (let i = 0; i < result[0].rows.length; i++) {
        users.push(result[0].rows.item(i));
      }
      return users;
    } catch (error) {
      console.error('Error searching users:', error);
      return [];
    }
  }

  // Database maintenance
  async getDatabaseInfo() {
    try {
      const userCountResult = await this.db.executeSql(
        'SELECT COUNT(*) as count FROM users'
      );
      const postCountResult = await this.db.executeSql(
        'SELECT COUNT(*) as count FROM posts'
      );

      return {
        userCount: userCountResult[0].rows.item(0).count,
        postCount: postCountResult[0].rows.item(0).count,
      };
    } catch (error) {
      console.error('Error getting database info:', error);
      return null;
    }
  }

  async closeDatabase() {
    if (this.db) {
      await this.db.close();
      console.log('Database closed');
    }
  }
}

// Export singleton instance
export default new DatabaseManager();

Usage in React Native Components

import React, { useState, useEffect } from 'react';
import { View, Text, Button, FlatList, TextInput, Alert } from 'react-native';
import DatabaseManager from './DatabaseManager';

const UserManager = () => {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [newUserName, setNewUserName] = useState('');
  const [newUserEmail, setNewUserEmail] = useState('');

  useEffect(() => {
    initializeDatabase();
  }, []);

  const initializeDatabase = async () => {
    try {
      await DatabaseManager.initDatabase();
      loadUsers();
    } catch (error) {
      console.error('Database initialization failed:', error);
      setLoading(false);
    }
  };

  const loadUsers = async () => {
    setLoading(true);
    const usersData = await DatabaseManager.getUsersWithPosts();
    setUsers(usersData);
    setLoading(false);
  };

  const addUser = async () => {
    if (!newUserName.trim() || !newUserEmail.trim()) {
      Alert.alert('Error', 'Please fill in all fields');
      return;
    }

    const result = await DatabaseManager.createUser({
      name: newUserName.trim(),
      email: newUserEmail.trim(),
      age: 25,
    });

    if (result.success) {
      setNewUserName('');
      setNewUserEmail('');
      loadUsers();
      Alert.alert('Success', 'User added successfully');
    } else {
      Alert.alert('Error', result.error);
    }
  };

  const deleteUser = async (userId) => {
    Alert.alert(
      'Confirm Delete',
      'Are you sure you want to delete this user?',
      [
        { text: 'Cancel', style: 'cancel' },
        {
          text: 'Delete',
          style: 'destructive',
          onPress: async () => {
            const result = await DatabaseManager.deleteUser(userId);
            if (result.success) {
              loadUsers();
              Alert.alert('Success', 'User deleted successfully');
            } else {
              Alert.alert('Error', result.error);
            }
          },
        },
      ]
    );
  };

  const renderUser = ({ item }) => (
    <View style={{ padding: 10, borderBottomWidth: 1, borderColor: '#ccc' }}>
      <Text style={{ fontSize: 16, fontWeight: 'bold' }}>{item.name}</Text>
      <Text>{item.email}</Text>
      <Button
        title="Delete"
        onPress={() => deleteUser(item.user_id)}
        color="red"
      />
    </View>
  );

  if (loading) {
    return (
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
        <Text>Loading...</Text>
      </View>
    );
  }

  return (
    <View style={{ flex: 1, padding: 20 }}>
      <Text style={{ fontSize: 24, marginBottom: 20 }}>User Management</Text>

      <TextInput
        placeholder="Name"
        value={newUserName}
        onChangeText={setNewUserName}
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          padding: 10,
          marginBottom: 10,
        }}
      />

      <TextInput
        placeholder="Email"
        value={newUserEmail}
        onChangeText={setNewUserEmail}
        keyboardType="email-address"
        style={{
          borderWidth: 1,
          borderColor: '#ccc',
          padding: 10,
          marginBottom: 10,
        }}
      />

      <Button title="Add User" onPress={addUser} />

      <FlatList
        data={users}
        renderItem={renderUser}
        keyExtractor={(item) => item.user_id?.toString()}
        style={{ marginTop: 20 }}
      />
    </View>
  );
};

export default UserManager;

SQLite Pros and Cons

Advantages

Relational Data: Support for complex relationships and joins
SQL Queries: Powerful query capabilities with sorting, filtering
ACID Transactions: Data integrity with atomic operations
Performance: Optimized for large datasets and complex queries
No Size Limits: Handle gigabytes of data efficiently
Data Types: Support for various data types (INTEGER, TEXT, BLOB, etc.)
Indexes: Optimize query performance with indexes

Disadvantages

Complexity: Steeper learning curve and setup
SQL Knowledge: Requires SQL expertise
Overhead: More resource-intensive for simple data
Migration Management: Schema changes need careful handling
Third-party Dependency: Requires additional libraries

Performance Comparison

Benchmark Results

Here's a performance comparison based on common operations:

Small Data (< 1MB)

  • AsyncStorage: ⚡ Faster for simple key-value operations
  • SQLite: 🐌 Slower due to database overhead

Medium Data (1MB - 10MB)

  • AsyncStorage: 🐌 Performance degrades with data size
  • SQLite: ⚡ Consistent performance with proper indexing

Large Data (> 10MB)

  • AsyncStorage: 🚫 Significant performance issues
  • SQLite: ⚡ Excellent performance with optimized queries

Complex Queries

  • AsyncStorage: 🚫 Not supported (requires loading all data)
  • SQLite: ⚡ Optimized SQL queries with indexes

Real-World Performance Test

// Performance testing utility
class PerformanceTest {
  static async compareStorage(dataSize) {
    const testData = this.generateTestData(dataSize);

    console.log(`Testing with ${dataSize} records`);

    // AsyncStorage test
    const asyncStart = Date.now();
    await AsyncStorage.setItem('test_data', JSON.stringify(testData));
    const storedData = await AsyncStorage.getItem('test_data');
    const parsedData = JSON.parse(storedData);
    const asyncEnd = Date.now();

    console.log(`AsyncStorage: ${asyncEnd - asyncStart}ms`);

    // SQLite test
    const sqliteStart = Date.now();
    await DatabaseManager.initDatabase();

    for (const record of testData) {
      await DatabaseManager.createUser(record);
    }

    const users = await DatabaseManager.searchUsers('test');
    const sqliteEnd = Date.now();

    console.log(`SQLite: ${sqliteEnd - sqliteStart}ms`);

    return {
      asyncStorage: asyncEnd - asyncStart,
      sqlite: sqliteEnd - sqliteStart,
    };
  }

  static generateTestData(size) {
    return Array.from({ length: size }, (_, i) => ({
      name: `Test User ${i}`,
      email: `test${i}@example.com`,
      age: 20 + (i % 50),
    }));
  }
}

When to Use Each Solution

Choose AsyncStorage When:

Simple Data: Storing user preferences, settings, tokens
Small Datasets: Less than 1MB of total data
Quick Prototyping: Need fast development with minimal setup
Key-Value Access: Data accessed by unique keys
No Relationships: Independent data without connections

Common AsyncStorage Use Cases:

  • User authentication tokens
  • App settings and preferences
  • Cached API responses
  • Form data persistence
  • User onboarding progress
  • Theme and language preferences

Choose SQLite When:

Complex Data: Relational data with connections
Large Datasets: Multiple megabytes of data
Complex Queries: Need filtering, sorting, joining data
Data Integrity: Require transactions and ACID properties
Reporting: Generate analytics and reports
Offline-First: Complete offline functionality

Common SQLite Use Cases:

  • E-commerce product catalogs
  • Chat applications with message history
  • Task management with categories and tags
  • Financial applications with transactions
  • Content management systems
  • Social media feeds with relationships

Best Practices and Optimization

AsyncStorage Best Practices

1. Data Structure Design

// ❌ Bad: Storing large arrays directly
const badPractice = {
  all_users: [
    /* thousands of user objects */
  ],
};

// ✅ Good: Break down data into smaller chunks
const goodPractice = {
  user_1: { name: 'John', email: 'john@example.com' },
  user_2: { name: 'Jane', email: 'jane@example.com' },
  user_index: ['user_1', 'user_2'], // Keep an index
};

2. Error Handling and Fallbacks

class AsyncStorageHelper {
  static async safeGet(key, defaultValue = null) {
    try {
      const value = await AsyncStorage.getItem(key);
      return value ? JSON.parse(value) : defaultValue;
    } catch (error) {
      console.error(`AsyncStorage get error for key ${key}:`, error);
      return defaultValue;
    }
  }

  static async safeSet(key, value) {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (error) {
      console.error(`AsyncStorage set error for key ${key}:`, error);

      // Handle storage quota exceeded
      if (error.message.includes('QuotaExceededError')) {
        await this.clearOldCache();
        // Retry once
        try {
          await AsyncStorage.setItem(key, JSON.stringify(value));
          return true;
        } catch (retryError) {
          console.error('AsyncStorage retry failed:', retryError);
          return false;
        }
      }

      return false;
    }
  }

  static async clearOldCache() {
    try {
      const keys = await AsyncStorage.getAllKeys();
      const cacheKeys = keys.filter((key) => key.startsWith('cache_'));

      // Remove old cache entries
      for (const key of cacheKeys) {
        const data = await this.safeGet(key);
        if (data && data.expirationTime < Date.now()) {
          await AsyncStorage.removeItem(key);
        }
      }
    } catch (error) {
      console.error('Cache cleanup error:', error);
    }
  }
}

3. Performance Optimization

class OptimizedAsyncStorage {
  constructor() {
    this.cache = new Map(); // In-memory cache
    this.debounceTimers = new Map(); // Debounce frequent writes
  }

  async get(key) {
    // Return from cache if available
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const value = await AsyncStorageHelper.safeGet(key);
    this.cache.set(key, value);
    return value;
  }

  async set(key, value, debounceMs = 0) {
    // Update cache immediately
    this.cache.set(key, value);

    if (debounceMs > 0) {
      // Debounce writes to storage
      if (this.debounceTimers.has(key)) {
        clearTimeout(this.debounceTimers.get(key));
      }

      const timer = setTimeout(async () => {
        await AsyncStorageHelper.safeSet(key, value);
        this.debounceTimers.delete(key);
      }, debounceMs);

      this.debounceTimers.set(key, timer);
    } else {
      await AsyncStorageHelper.safeSet(key, value);
    }
  }

  invalidateCache(key) {
    this.cache.delete(key);
  }

  clearCache() {
    this.cache.clear();
  }
}

SQLite Best Practices

1. Database Schema Design

// Well-designed schema with proper relationships and indexes
const createTables = async () => {
  const queries = [
    // Users table with constraints
    `CREATE TABLE IF NOT EXISTS users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      email TEXT UNIQUE NOT NULL,
      name TEXT NOT NULL,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      CONSTRAINT email_format CHECK (email LIKE '%@%.%')
    );`,

    // Posts table with foreign key
    `CREATE TABLE IF NOT EXISTS posts (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id INTEGER NOT NULL,
      title TEXT NOT NULL,
      content TEXT,
      status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
      FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );`,

    // Indexes for better performance
    `CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);`,
    `CREATE INDEX IF NOT EXISTS idx_posts_user_status ON posts (user_id, status);`,
    `CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at);`,
  ];

  for (const query of queries) {
    await db.executeSql(query);
  }
};

2. Query Optimization

class OptimizedQueries {
  // Use prepared statements and proper indexing
  static async getUserPosts(userId, limit = 10, offset = 0) {
    const query = `
      SELECT 
        p.id,
        p.title,
        p.content,
        p.created_at,
        u.name as author_name
      FROM posts p
      INNER JOIN users u ON p.user_id = u.id
      WHERE p.user_id = ?
      ORDER BY p.created_at DESC
      LIMIT ? OFFSET ?
    `;

    return await db.executeSql(query, [userId, limit, offset]);
  }

  // Batch operations for better performance
  static async bulkInsertPosts(posts) {
    await db.transaction(async (tx) => {
      const query = `
        INSERT INTO posts (user_id, title, content, status) 
        VALUES (?, ?, ?, ?)
      `;

      for (const post of posts) {
        await tx.executeSql(query, [
          post.user_id,
          post.title,
          post.content,
          post.status || 'draft',
        ]);
      }
    });
  }

  // Efficient search with full-text search
  static async searchPosts(searchTerm) {
    // Create FTS virtual table for better search performance
    const createFTSTable = `
      CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts 
      USING fts5(title, content, content=posts, content_rowid=id);
    `;

    await db.executeSql(createFTSTable);

    const query = `
      SELECT 
        p.id,
        p.title,
        p.content,
        p.created_at,
        snippet(posts_fts, 0, '<mark>', '</mark>', '...', 32) as title_snippet
      FROM posts_fts 
      INNER JOIN posts p ON posts_fts.rowid = p.id
      WHERE posts_fts MATCH ?
      ORDER BY rank
    `;

    return await db.executeSql(query, [searchTerm]);
  }
}

3. Migration Management

class DatabaseMigration {
  constructor() {
    this.migrations = [
      {
        version: 1,
        up: async (db) => {
          await db.executeSql(`
            CREATE TABLE users (
              id INTEGER PRIMARY KEY,
              name TEXT,
              email TEXT
            );
          `);
        },
      },
      {
        version: 2,
        up: async (db) => {
          await db.executeSql(`
            ALTER TABLE users ADD COLUMN created_at DATETIME;
          `);
          await db.executeSql(`
            UPDATE users SET created_at = CURRENT_TIMESTAMP;
          `);
        },
      },
      {
        version: 3,
        up: async (db) => {
          await db.executeSql(`
            CREATE INDEX idx_users_email ON users (email);
          `);
        },
      },
    ];
  }

  async getCurrentVersion(db) {
    try {
      const result = await db.executeSql('PRAGMA user_version;');
      return result[0].rows.item(0).user_version;
    } catch (error) {
      return 0;
    }
  }

  async setVersion(db, version) {
    await db.executeSql(`PRAGMA user_version = ${version};`);
  }

  async runMigrations(db) {
    const currentVersion = await this.getCurrentVersion(db);
    console.log(`Current database version: ${currentVersion}`);

    const pendingMigrations = this.migrations.filter(
      (migration) => migration.version > currentVersion
    );

    if (pendingMigrations.length === 0) {
      console.log('Database is up to date');
      return;
    }

    await db.transaction(async (tx) => {
      for (const migration of pendingMigrations) {
        console.log(`Running migration ${migration.version}`);
        await migration.up(tx);
        await this.setVersion(tx, migration.version);
      }
    });

    console.log(
      `Database migrated to version ${
        pendingMigrations[pendingMigrations.length - 1].version
      }`
    );
  }
}

Hybrid Approach: Using Both Together

Sometimes the best solution is using both AsyncStorage and SQLite together, leveraging each for their strengths:

class HybridStorageManager {
  constructor() {
    this.cache = new OptimizedAsyncStorage();
    this.db = DatabaseManager;
  }

  async initialize() {
    await this.db.initDatabase();
    await this.loadCriticalDataToCache();
  }

  // Use AsyncStorage for frequently accessed, small data
  async getUserSettings(userId) {
    const cacheKey = `user_settings_${userId}`;
    let settings = await this.cache.get(cacheKey);

    if (!settings) {
      // Fallback to database
      settings = await this.db.getUserSettings(userId);
      if (settings) {
        // Cache for future access
        await this.cache.set(cacheKey, settings, 1000); // Debounce 1s
      }
    }

    return settings || this.getDefaultSettings();
  }

  async updateUserSettings(userId, settings) {
    // Update cache immediately for responsive UI
    const cacheKey = `user_settings_${userId}`;
    await this.cache.set(cacheKey, settings);

    // Update database in background
    setTimeout(async () => {
      await this.db.updateUserSettings(userId, settings);
    }, 0);
  }

  // Use SQLite for complex queries and relationships
  async getUserFeed(userId, page = 0, pageSize = 20) {
    const cacheKey = `user_feed_${userId}_${page}`;
    let feed = await this.cache.get(cacheKey);

    if (!feed) {
      feed = await this.db.getUserFeed(userId, page, pageSize);
      // Cache for 5 minutes
      await this.cache.setWithExpiry(cacheKey, feed, 5);
    }

    return feed;
  }

  // Critical data cached at app startup
  async loadCriticalDataToCache() {
    const currentUser = await this.db.getCurrentUser();
    if (currentUser) {
      await this.cache.set('current_user', currentUser);

      const settings = await this.db.getUserSettings(currentUser.id);
      await this.cache.set(`user_settings_${currentUser.id}`, settings);
    }
  }

  // Sync changes when going online
  async syncPendingChanges() {
    const pendingChanges = (await this.cache.get('pending_changes')) || [];

    for (const change of pendingChanges) {
      try {
        await this.db.applyChange(change);
        console.log(`Synced change: ${change.id}`);
      } catch (error) {
        console.error(`Failed to sync change ${change.id}:`, error);
      }
    }

    // Clear synced changes
    await this.cache.set('pending_changes', []);
  }

  getDefaultSettings() {
    return {
      theme: 'light',
      notifications: true,
      language: 'en',
    };
  }
}

Decision Framework

Questions to Ask

  1. Data Complexity: Do you need relationships between different data types?
  2. Data Volume: How much data will you be storing?
  3. Query Requirements: Do you need to search, filter, or sort data?
  4. Performance Needs: What are your speed requirements for data operations?
  5. Offline Requirements: How robust does your offline functionality need to be?
  6. Team Expertise: What's your team's familiarity with SQL?
  7. Development Timeline: How quickly do you need to implement storage?

Decision Matrix

| Factor | AsyncStorage | SQLite | Winner | | ------------------------ | ------------ | ---------- | ------------ | | Simple Implementation | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | AsyncStorage | | Complex Queries | ❌ | ⭐⭐⭐⭐⭐ | SQLite | | Large Data Sets | ⭐⭐ | ⭐⭐⭐⭐⭐ | SQLite | | Data Relationships | ❌ | ⭐⭐⭐⭐⭐ | SQLite | | Performance (Small Data) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | AsyncStorage | | Performance (Large Data) | ⭐⭐ | ⭐⭐⭐⭐⭐ | SQLite | | Learning Curve | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | AsyncStorage | | Data Integrity | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | SQLite | | Cross-Platform | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | AsyncStorage |

Conclusion

Both AsyncStorage and SQLite have their place in React Native development. The choice ultimately depends on your specific requirements:

Choose AsyncStorage for simple, lightweight data storage needs where you primarily access data by keys. It's perfect for user preferences, tokens, cache data, and small configuration objects. The simplicity and quick implementation make it ideal for rapid prototyping and simple apps.

Choose SQLite when you need robust data management with relationships, complex queries, and large datasets. It's essential for apps that require offline-first capabilities, reporting features, or complex data operations.

Consider a hybrid approach for larger applications where you can leverage both solutions - using AsyncStorage for frequently accessed small data and SQLite for complex business data.

The key is to evaluate your current needs while considering future scalability requirements. Start simple with AsyncStorage if you're unsure, but be prepared to migrate to SQLite as your data complexity grows.

Remember that the best storage solution is the one that meets your specific requirements while maintaining good performance and developer experience. Both options are mature, well-supported, and production-ready for React Native applications.

Key Takeaways

  • AsyncStorage: Perfect for simple key-value storage, user preferences, and small datasets
  • SQLite: Ideal for complex relational data, large datasets, and robust querying needs
  • Performance: AsyncStorage wins for small data, SQLite excels with large datasets
  • Complexity: AsyncStorage is simpler to implement, SQLite requires more setup but offers more power
  • Hybrid Approach: Using both together can provide the best of both worlds
  • Future-Proofing: Consider your long-term data requirements when making the choice

The React Native ecosystem provides excellent tools for both approaches, so you can't go wrong with either choice as long as it aligns with your application's requirements.