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 );
}
}
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
Work Normally Offline
Continue using the app as usual—it will sync when online
Watch for Pending Indicators
Gray/dimmed items indicate pending sync
Check for Errors
Red error messages indicate sync failures
Retry if Needed
Tap retry on error messages if they don’t clear automatically
For Developers
Always Use Optimistic Updates
API . write ( 'Action' , params , { optimisticData , successData , failureData });
Handle Pending States in UI
< OfflineWithFeedback pendingAction = {item. pendingAction } >
{ /* content */ }
</ OfflineWithFeedback >
Provide Fallback Data
const data = onlineData ?? cachedData ?? defaultData ;
Test Offline Scenarios
setShouldForceOffline ( true );
// Test your feature
setShouldForceOffline ( false );
Troubleshooting
Check internet connection
Look for error messages on pending items
Try force closing and reopening the app
Check for app updates
// Clear pending action
Onyx . merge ( ` ${ ONYXKEYS . COLLECTION . TRANSACTION }${ id } ` , {
pendingAction: null ,
errors: null ,
});
Pull to refresh on list screens
Force sync:
import { reconnect } from '@libs/actions/App' ;
reconnect ();
Connection shows offline incorrectly
// Recheck network status
import NetworkConnection from '@libs/NetworkConnection' ;
NetworkConnection . recheckNetworkConnection ();
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