Skip to main content
New Expensify is built with an offline-first architecture, allowing full functionality without an internet connection. All changes sync automatically when connectivity is restored.

Overview

Offline-first means:
  • Full Functionality: Create, edit, and view expenses offline
  • Optimistic Updates: UI updates immediately, syncs later
  • Request Queue: Network requests queued and retried automatically
  • Conflict Resolution: Automatic handling of conflicting changes
  • Cross-Platform: Consistent offline behavior on all platforms
Offline mode is not a “feature” you enable—it’s the foundation of how New Expensify works.

Architecture

Onyx: Offline-First Data Store

New Expensify uses Onyx, a custom state management library built for offline functionality:
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';

// Data is automatically persisted
Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false});

// Subscribe to changes
Onyx.connect({
  key: ONYXKEYS.NETWORK,
  callback: (network) => {
    console.log('Network status:', network);
  },
});

Data Persistence

Onyx automatically persists data using:
  • Mobile: SQLite database
  • Web: IndexedDB
  • Desktop: SQLite (Electron)
// Data automatically persists and survives app restarts
Onyx.set(ONYXKEYS.SESSION, {
  authToken: 'abc123',
  email: '[email protected]',
});

Network Detection

Monitoring Connection Status

src/libs/NetworkConnection.ts
import NetInfo from '@react-native-community/netinfo';
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';

let isOffline = false;

// Subscribe to network state changes
function subscribeToNetInfo(accountID: number | undefined): () => void {
    NetInfo.configure({
        reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/Ping?accountID=${accountID}`,
        reachabilityMethod: 'GET',
        reachabilityTest: (response) => {
            return response.json().then((json) => json.jsonCode === 200);
        },
        reachabilityRequestTimeout: CONST.NETWORK.MAX_PENDING_TIME_MS,
    });

    const unsubscribe = NetInfo.addEventListener((state) => {
        setOfflineStatus(state.isInternetReachable === false);
    });

    return unsubscribe;
}

Network State

import {isOffline} from '@libs/Network/NetworkStore';

if (isOffline()) {
  // Show offline indicator
  showOfflineMessage();
} else {
  // Proceed with online actions
}

Connection Changes Tracking

src/libs/NetworkConnection.ts
function trackConnectionChanges() {
    if (!connectionChanges?.startTime) {
        setConnectionChanges({startTime: new Date().getTime(), amount: 1});
        return;
    }

    const diffInHours = differenceInHours(new Date(), connectionChanges.startTime);
    const newAmount = (connectionChanges.amount ?? 0) + 1;

    if (diffInHours < 1) {
        setConnectionChanges({amount: newAmount});
        return;
    }

    Log.info(
        `[NetworkConnection] Connection has changed ${newAmount} time(s) for the last ${diffInHours} hour(s).`
    );

    setConnectionChanges({startTime: new Date().getTime(), amount: 0});
}

Offline Functionality

Creating Expenses Offline

import {createDistanceRequest} from '@libs/actions/IOU';
import {isOffline} from '@libs/Network/NetworkStore';

function createExpense(amount: number, merchant: string) {
  // Works the same online or offline
  const optimisticData = [
    {
      onyxMethod: Onyx.METHOD.MERGE,
      key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
      value: {
        amount,
        merchant,
        created: DateUtils.getDBTime(),
        // Marked as pending when offline
        pendingAction: isOffline() ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null,
      },
    },
  ];
  
  API.write('CreateTransaction', {amount, merchant}, {optimisticData});
}
The UI updates immediately with optimistic data, then syncs in the background.

Optimistic Updates

import API from '@libs/API';
import Onyx from 'react-native-onyx';

API.write(
  'UpdateTransaction',
  {transactionID, amount: newAmount},
  {
    optimisticData: [
      {
        onyxMethod: Onyx.METHOD.MERGE,
        key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
        value: {
          amount: newAmount,
          pendingFields: {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE},
        },
      },
    ],
    successData: [
      {
        onyxMethod: Onyx.METHOD.MERGE,
        key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
        value: {
          pendingFields: {amount: null},
        },
      },
    ],
    failureData: [
      {
        onyxMethod: Onyx.METHOD.MERGE,
        key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
        value: {
          amount: originalAmount,
          pendingFields: {amount: null},
          errors: {amount: 'Failed to update'},
        },
      },
    ],
  }
);

Request Queue

Network requests are automatically queued when offline:
src/libs/Network/NetworkStore.ts
let offline = false;

// Subscribe to network status
Onyx.connectWithoutView({
    key: ONYXKEYS.NETWORK,
    callback: (network) => {
        if (!network) return;

        // Client becomes online - trigger reconnection
        if (offline && !network.isOffline) {
            triggerReconnectCallback();
        }

        offline = !!network.shouldForceOffline || !!network.isOffline;
    },
});
Requests are processed when connection is restored:
import NetworkConnection from '@libs/NetworkConnection';

// Register callback for when network reconnects
NetworkConnection.onReconnect(() => {
  console.log('Network reconnected, processing queued requests');
  processQueuedRequests();
});

Time Synchronization

Server time skew compensation for accurate timestamps:
src/libs/NetworkConnection.ts
let networkTimeSkew = 0;

Onyx.connectWithoutView({
    key: ONYXKEYS.NETWORK,
    callback: (network) => {
        networkTimeSkew = network?.timeSkew ?? 0;
    },
});

// Get current time with server skew applied
function getDBTimeWithSkew(timestamp: string | number = ''): string {
    if (networkTimeSkew > 0) {
        const datetime = timestamp ? new Date(timestamp) : new Date();
        return DateUtils.getDBTime(datetime.valueOf() + networkTimeSkew);
    }
    return DateUtils.getDBTime(timestamp);
}
Usage:
import NetworkConnection from '@libs/NetworkConnection';

const currentTime = NetworkConnection.getDBTimeWithSkew();
const reportAction = {
  created: currentTime,
  message: 'Hello',
};

Pending States

Pending Actions

Indicate what’s being synced:
type PendingAction = 'add' | 'update' | 'delete';

interface Transaction {
  transactionID: string;
  amount: number;
  
  // Indicates pending operation
  pendingAction?: PendingAction;
  
  // Indicates which fields are being updated
  pendingFields?: {
    amount?: PendingAction;
    merchant?: PendingAction;
  };
}

UI Indicators

import OfflineWithFeedback from '@components/OfflineWithFeedback';

function TransactionItem({transaction}) {
  return (
    <OfflineWithFeedback
      pendingAction={transaction.pendingAction}
      errors={transaction.errors}
    >
      <View style={transaction.pendingAction && styles.pending}>
        <Text>{transaction.merchant}</Text>
        <Text>{transaction.amount}</Text>
      </View>
    </OfflineWithFeedback>
  );
}

Offline Feedback Component

<OfflineWithFeedback
  pendingAction={item.pendingAction}
  errors={item.errors}
  errorRowStyles={styles.errorRow}
  onClose={() => clearErrors(item.id)}
>
  <ItemContent item={item} />
</OfflineWithFeedback>

Conflict Resolution

Last Write Wins

By default, the last successful update wins:
API.write('UpdateTransaction', params, {
  optimisticData: [/* optimistic state */],
  successData: [/* clear pending states */],
  failureData: [/* revert to original */],
});

Error Handling

import ErrorUtils from '@libs/ErrorUtils';

if (transaction.errors) {
  Object.entries(transaction.errors).forEach(([field, error]) => {
    showError(ErrorUtils.translateError(error));
  });
}

Retry Failed Requests

import {retryRequest} from '@libs/actions/Network';

function handleRetry(transactionID: string) {
  // Clear errors
  Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
    errors: null,
  });
  
  // Retry the request
  retryRequest(transactionID);
}

Receipt Handling Offline

Receipts captured offline are queued for upload:
import {isOffline} from '@libs/Network/NetworkStore';

function captureReceipt(receipt: Receipt) {
  // Save receipt locally
  const localPath = saveReceiptToLocalStorage(receipt);
  
  // Create transaction with local receipt
  const transaction = {
    receipt: {
      source: localPath,
      state: isOffline() ? 'SCANREADY' : 'SCANNING',
    },
  };
  
  // Upload when online
  if (!isOffline()) {
    uploadReceipt(receipt);
  } else {
    queueReceiptUpload(receipt);
  }
}
See Receipt Scanning for details on mobile receipt capture.

Force Offline Mode (Testing)

Force offline for testing:
import {setShouldForceOffline} from '@libs/actions/Network';

// Enable force offline
setShouldForceOffline(true);

// Disable
setShouuldForceOffline(false);

Simulating Poor Connection

import {setShouldSimulatePoorConnection} from '@libs/actions/Network';

// Randomly toggle online/offline every 2-5 seconds
setShouldSimulatePoorConnection(true);

Offline Behavior by Feature

Expenses

Reports

Settings

Best Practices

For Users

1

Work Normally Offline

Continue using the app as usual—it will sync when online
2

Watch for Pending Indicators

Gray/dimmed items indicate pending sync
3

Check for Errors

Red error messages indicate sync failures
4

Retry if Needed

Tap retry on error messages if they don’t clear automatically

For Developers

1

Always Use Optimistic Updates

API.write('Action', params, {optimisticData, successData, failureData});
2

Handle Pending States in UI

<OfflineWithFeedback pendingAction={item.pendingAction}>
  {/* content */}
</OfflineWithFeedback>
3

Provide Fallback Data

const data = onlineData ?? cachedData ?? defaultData;
4

Test Offline Scenarios

setShouldForceOffline(true);
// Test your feature
setShouldForceOffline(false);

Troubleshooting

  1. Check internet connection
  2. Look for error messages on pending items
  3. Try force closing and reopening the app
  4. Check for app updates
// Clear pending action
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`, {
  pendingAction: null,
  errors: null,
});
  1. Pull to refresh on list screens
  2. Force sync:
    import {reconnect} from '@libs/actions/App';
    reconnect();
    
// Recheck network status
import NetworkConnection from '@libs/NetworkConnection';
NetworkConnection.recheckNetworkConnection();

Performance Considerations

Storage Limits

  • Mobile: ~10 MB typical, can grow larger
  • Web: IndexedDB limits vary by browser (~50MB-1GB+)
  • Desktop: No practical limit

Data Pruning

Old data is automatically pruned:
// Reports inactive for 30+ days are removed from cache
const STALE_DATA_THRESHOLD = 30 * 24 * 60 * 60 * 1000; // 30 days

Queue Management

// Failed requests retry with exponential backoff
const retryDelay = Math.min(1000 * Math.pow(2, attempt), 30000);

Next Steps

Receipt Scanning

Offline receipt capture

Push Notifications

Real-time sync notifications

iOS Platform

iOS offline behavior

Android Platform

Android offline behavior

Build docs developers (and LLMs) love