Scientyfic World

Local Storage in Expo: AsyncStorage vs SecureStore vs SQLite (SDK 58)

Most apps need to keep some data on the device: settings, a login token, cached content, or a full offline database. In an Expo app there are three standard tools...

Share:

Get an AI summary of this article

blog banner for using Local storage in Expo

Most apps need to keep some data on the device: settings, a login token, cached content, or a full offline database. In an Expo app there are three standard tools for this, each for a different job: AsyncStorage for small non-sensitive values, SecureStore for secrets, and SQLite for structured data. This guide shows when to use each, with current code for Expo SDK 58.

What I tested: I created a fresh Expo SDK 58 app (React Native 0.88), installed the three libraries with npx expo install, and type-checked all the code below. The AsyncStorage and SecureStore helpers pass three Jest tests with the libraries’ mocks. The SQLite functions pass three tests that run their real SQL against Node’s built-in SQLite through a small adapter. I did not run the app on a device for this post.

The 2024 version of this post used the old SQLite.openDatabase / transaction / executeSql API. That API was removed in expo-sqlite 15.0.0 (October 2024), so the SQLite section is rewritten for the current async API.

Which Storage Should You Use?

AsyncStorageSecureStoreSQLite
Package@react-native-async-storage/async-storageexpo-secure-storeexpo-sqlite
Data modelKey-value stringsKey-value stringsRelational tables, SQL queries
EncryptedNoYes (iOS Keychain, Android Keystore-backed)Not by default
Good forSettings, flags, small cachesAuth tokens, refresh tokens, small secretsNotes, messages, offline data, anything you query or sort
Avoid forSecrets, large or relational dataLarge values, data you must not loseA handful of simple settings
PlatformsAndroid, iOS, web and moreAndroid and iOSAndroid, iOS, web (with extra setup)

Rule of thumb: secrets go in SecureStore, everything else small goes in AsyncStorage, and anything you would want to query goes in SQLite. If you use Redux, Redux Toolkit state can be persisted to AsyncStorage with a library such as redux-persist.

Install the Packages

Use npx expo install rather than npm install, so Expo picks versions compatible with your SDK. On SDK 58 that installed AsyncStorage 2.2.0, expo-secure-store 58.0 and expo-sqlite 58.0, and added the SecureStore and SQLite config plugins to app.json:

npx expo install @react-native-async-storage/async-storage expo-secure-store expo-sqlite

All three are included in Expo Go, so you can try them without a development build.

AsyncStorage: Settings and Small Data

AsyncStorage is an unencrypted, asynchronous key-value store. Values are strings, so store objects as JSON and always handle missing or corrupted values. This helper keeps all user preferences under one key and merges updates with defaults:

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

export type Preferences = { theme: 'light' | 'dark' | 'system'; fontScale: number };

const KEY = 'preferences';
const DEFAULTS: Preferences = { theme: 'system', fontScale: 1 };

// AsyncStorage stores strings, so objects go in and out as JSON.
export async function loadPreferences(): Promise<Preferences> {
  const raw = await AsyncStorage.getItem(KEY);
  if (!raw) return DEFAULTS;
  try {
    return { ...DEFAULTS, ...JSON.parse(raw) };
  } catch {
    return DEFAULTS; // corrupted value: fall back rather than crash
  }
}

export async function savePreferences(update: Partial<Preferences>): Promise<Preferences> {
  const next = { ...(await loadPreferences()), ...update };
  await AsyncStorage.setItem(KEY, JSON.stringify(next));
  return next;
}
  • Group related values under one key instead of dozens of separate keys.
  • Never store passwords or tokens here; the data is not encrypted.
  • Keep it small. It is not designed for large datasets or queries.
  • Other useful methods include removeItem, multiGet/multiSet for batches, getAllKeys and clear. Avoid clear() in shared code, because it wipes keys other libraries may rely on.

expo-sqlite also ships a key-value store at expo-sqlite/kv-store, which its types describe as a drop-in replacement for AsyncStorage backed by SQLite, with synchronous methods as well. It is worth considering if you already use expo-sqlite.

SecureStore: Tokens and Secrets

SecureStore encrypts values with the platform’s secure storage: the Keychain on iOS and Keystore-backed encryption on Android.

import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'auth_token';

// Tokens and other secrets go in the iOS Keychain / Android Keystore-backed store.
export async function saveToken(token: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, token, {
    keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK,
  });
}

export function getToken(): Promise<string | null> {
  return SecureStore.getItemAsync(TOKEN_KEY);
}

export function clearToken() {
  return SecureStore.deleteItemAsync(TOKEN_KEY);
}

Things the documentation calls out:

  • Size. Expo does not enforce a limit, but some older iOS releases rejected values above about 2,048 bytes, so keep values small and catch errors.
  • Accessibility. On iOS, keychainAccessible controls when an item can be read. The default is WHEN_UNLOCKED; AFTER_FIRST_UNLOCK (used above) lets background tasks read the token after the first unlock. The ALWAYS options are deprecated.
  • Biometrics. requireAuthentication: true asks for Face ID, Touch ID or a fingerprint before reading. Adding a new fingerprint invalidates protected keys, and a cancelled prompt throws, so handle both cases.
  • Persistence. On Android the data is deleted on uninstall; on iOS it may survive reinstall, but that is not guaranteed. Never use SecureStore as the only copy of important data.
  • Sync versions. getItem and setItem also exist, but they block the JavaScript thread, so prefer the async methods.

SQLite: Structured and Offline Data

expo-sqlite gives you a full SQLite database. The current API is promise-based: openDatabaseAsync, execAsync for statements without results, runAsync for writes, getFirstAsync/getAllAsync for reads and withTransactionAsync for transactions. Always pass values as parameters (?), never by building SQL strings, to avoid SQL injection.

Schema, migrations and queries

This module creates the schema, tracks migrations with PRAGMA user_version and wraps the queries:

import type { SQLiteDatabase } from 'expo-sqlite';

export type Note = { id: number; title: string; body: string; updated_at: string };

const DATABASE_VERSION = 1;

// Run once at startup (SQLiteProvider's onInit does this for you).
// PRAGMA user_version tracks which migrations have already run.
export async function migrateDbIfNeeded(db: SQLiteDatabase) {
  const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
  let version = row?.user_version ?? 0;
  if (version >= DATABASE_VERSION) return;

  if (version === 0) {
    await db.execAsync(`
      PRAGMA journal_mode = WAL;
      CREATE TABLE IF NOT EXISTS notes (
        id INTEGER PRIMARY KEY NOT NULL,
        title TEXT NOT NULL,
        body TEXT NOT NULL DEFAULT '',
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      );
    `);
    version = 1;
  }
  await db.execAsync(`PRAGMA user_version = ${DATABASE_VERSION}`);
}

export async function addNote(db: SQLiteDatabase, title: string, body = ''): Promise<number> {
  const result = await db.runAsync('INSERT INTO notes (title, body) VALUES (?, ?)', title, body);
  return result.lastInsertRowId;
}

export function listNotes(db: SQLiteDatabase): Promise<Note[]> {
  return db.getAllAsync<Note>('SELECT * FROM notes ORDER BY updated_at DESC, id DESC');
}

export async function updateNote(db: SQLiteDatabase, id: number, title: string, body: string) {
  await db.runAsync(
    "UPDATE notes SET title = ?, body = ?, updated_at = datetime('now') WHERE id = ?",
    title,
    body,
    id,
  );
}

export async function deleteNote(db: SQLiteDatabase, id: number) {
  await db.runAsync('DELETE FROM notes WHERE id = ?', id);
}

// Several writes that must succeed or fail together.
export async function importNotes(db: SQLiteDatabase, notes: { title: string; body: string }[]) {
  await db.withTransactionAsync(async () => {
    for (const n of notes) {
      await db.runAsync('INSERT INTO notes (title, body) VALUES (?, ?)', n.title, n.body);
    }
  });
}

PRAGMA journal_mode = WAL lets reads and writes happen concurrently, which keeps the UI responsive. When you change the schema later, bump DATABASE_VERSION and add an if (version === 1) block with the change, so existing users’ databases are upgraded instead of recreated.

Using it in components

SQLiteProvider opens the database once, runs your migration in onInit before children render, and makes the database available through useSQLiteContext():

import { useEffect, useState } from 'react';
import { Button, FlatList, StyleSheet, Text, View } from 'react-native';
import { SQLiteProvider, useSQLiteContext } from 'expo-sqlite';
import { addNote, listNotes, migrateDbIfNeeded, type Note } from './src/db/notes';

export default function App() {
  return (
    <SQLiteProvider databaseName="app.db" onInit={migrateDbIfNeeded}>
      <Notes />
    </SQLiteProvider>
  );
}

function Notes() {
  const db = useSQLiteContext();
  const [notes, setNotes] = useState<Note[]>([]);

  useEffect(() => {
    listNotes(db).then(setNotes);
  }, [db]);

  return (
    <View style={styles.container}>
      <Button
        title="Add note"
        onPress={async () => {
          await addNote(db, `Note ${notes.length + 1}`);
          setNotes(await listNotes(db));
        }}
      />
      <FlatList
        data={notes}
        keyExtractor={n => String(n.id)}
        renderItem={({ item }) => <Text style={styles.item}>{item.title}</Text>}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, paddingTop: 64, paddingHorizontal: 16 },
  item: { paddingVertical: 8, fontSize: 16 },
});

Testing the SQL

expo-sqlite needs a native runtime, so it cannot run in a plain Jest test. To check the SQL itself, I ran the same functions against Node’s built-in node:sqlite module through a small adapter with the same method names. The three tests covered running the migration twice (the second run does nothing), insert, read, update and delete, and a failing import inside withTransactionAsync rolling back completely:

✔ migration creates the table once and sets user_version
✔ CRUD and transactional import
✔ a failing import rolls back completely
ℹ pass 3

Testing AsyncStorage and SecureStore

AsyncStorage ships an official Jest mock, and SecureStore is easy to mock with an in-memory map. These tests check defaults, merging, a corrupted value and the token round trip:

import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { loadPreferences, savePreferences } from '../src/storage/preferences';
import { clearToken, getToken, saveToken } from '../src/storage/secure';

jest.mock('@react-native-async-storage/async-storage', () =>
  require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);

jest.mock('expo-secure-store', () => {
  const store = new Map<string, string>();
  return {
    AFTER_FIRST_UNLOCK: 0,
    setItemAsync: jest.fn(async (k: string, v: string) => void store.set(k, v)),
    getItemAsync: jest.fn(async (k: string) => store.get(k) ?? null),
    deleteItemAsync: jest.fn(async (k: string) => void store.delete(k)),
  };
});

beforeEach(() => AsyncStorage.clear());

test('preferences fall back to defaults and merge updates', async () => {
  expect(await loadPreferences()).toEqual({ theme: 'system', fontScale: 1 });
  await savePreferences({ theme: 'dark' });
  expect(await loadPreferences()).toEqual({ theme: 'dark', fontScale: 1 });
});

test('corrupted preferences do not crash the app', async () => {
  await AsyncStorage.setItem('preferences', '{not json');
  expect(await loadPreferences()).toEqual({ theme: 'system', fontScale: 1 });
});

test('token round-trips through SecureStore', async () => {
  await saveToken('abc123');
  expect(await getToken()).toBe('abc123');
  expect(SecureStore.setItemAsync).toHaveBeenCalledWith('auth_token', 'abc123', { keychainAccessible: 0 });
  await clearToken();
  expect(await getToken()).toBeNull();
});

Best Practices

  • Wrap storage access in small modules like the ones above, so components never touch raw keys or SQL.
  • Version your data: a key name such as preferences plus defaults, and user_version for SQLite.
  • Treat local data as a cache that can disappear. Sync important data to a server; for offline-first apps backed by Firebase, see Firebase offline support in React Native.
  • Clear user data on sign-out: delete tokens from SecureStore and user rows from SQLite.
  • Do not store large files in any of these; use the file system and save only the path.

Conclusion

Expo gives you a clear toolkit for local data: AsyncStorage for small settings, SecureStore for secrets, and SQLite for structured data you need to query. Use the current async SQLite API with SQLiteProvider and versioned migrations, keep secrets out of AsyncStorage, and test the storage code like any other logic.

FAQs

Is AsyncStorage secure?

No. AsyncStorage is not encrypted. Store tokens, passwords and other secrets in SecureStore instead.

Why does SQLite.openDatabase not work anymore?

The legacy expo-sqlite API with openDatabase, transaction and executeSql was removed in expo-sqlite 15.0.0. Use openDatabaseAsync (or SQLiteProvider) with execAsync, runAsync, getAllAsync and withTransactionAsync.

How much data can SecureStore hold?

Keep each value small. Expo does not set a limit, but some older iOS versions rejected values larger than about 2,048 bytes. For larger encrypted data, store an encryption key in SecureStore and the data elsewhere.

Does local storage work in Expo Go?

Yes. AsyncStorage, SecureStore and expo-sqlite are all included in Expo Go. Some SecureStore biometric options need a development or release build.

Is data kept when the app is uninstalled?

Generally no. Uninstalling removes AsyncStorage and SQLite data. SecureStore data is removed on Android and may survive on iOS, but you should not rely on it.

Should I use SQLite or AsyncStorage for a list of items?

If you only save and load the whole list, AsyncStorage with JSON is fine for small lists. If you need to search, sort, filter or update individual items, or the list grows large, use SQLite.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts