Building Mobile Apps That Work When the Internet Doesn’t

Published: November 2025
Author: Chris Beaver
Category: Mobile Development, React Native

Your users aren’t always online. They’re in basements. They’re in rural areas. They’re on spotty LTE connections that drop mid-request. If your mobile app only works with perfect connectivity, it’s going to frustrate people.

I learned this the hard way building field service applications where technicians needed to collect data in locations with terrible or no signal. Here’s what I’ve learned about building mobile apps that handle the real world gracefully.

The Offline Problem

Most developers test on fast WiFi in their office. Your users are trying to submit critical data while their signal bar is flickering between one bar and no bars.

The typical mobile app architecture falls apart here:

  1. User fills out a form
  2. Taps submit
  3. Loading spinner appears
  4. Request times out after 30 seconds
  5. Error message: “Network error. Please try again.”
  6. All their data is gone
  7. User is furious

We can do better.

Designing for Intermittent Connectivity

The key insight: assume the network is unreliable and design around that assumption.

Store Everything Locally First

When users enter data, save it to local storage immediately. The network request happens in the background. If it fails, the data is still safe.

With React Native and Expo, tools like AsyncStorage or SQLite provide persistent local storage that survives app restarts.

// Bad: Network-first approach
const submitForm = async (data) => {
  try {
    await api.post('/samples', data);
    showSuccess();
  } catch (error) {
    showError();
  }
};

// Good: Local-first approach
const submitForm = async (data) => {
  // Save locally immediately
  const localId = await saveToLocalStorage(data);

  // Sync to server in background
  try {
    const response = await api.post('/samples', data);
    await updateLocalRecord(localId, response.data);
  } catch (error) {
    // Data is safe, we'll retry later
    markForRetry(localId);
  }
};

Implement Automatic Retry Logic

Failed network requests shouldn’t require user intervention. Build retry logic with exponential backoff into your data layer.

TanStack Query (formerly React Query) is excellent for this. It handles retries, caching, and synchronization elegantly:

import { useMutation, useQueryClient } from '@tanstack/react-query';

const useSubmitSample = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data) => api.post('/samples', data),
    retry: 3, // Retry failed requests 3 times
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    onSuccess: (data) => {
      // Update local cache
      queryClient.invalidateQueries(['samples']);
    },
    onError: (error) => {
      // Log for later retry
      logFailedRequest(error);
    }
  });
};

Show Sync Status Clearly

Users need to know what’s happening. Is their data saved? Is it syncing? Did something fail?

We use a simple status system:

  • Saved locally – Data is safe but hasn’t synced yet
  • Syncing – Actively sending to server
  • Synced – Successfully saved to server
  • Sync failed – Will retry automatically

This transparency builds trust. Users know their data is safe even when sync fails.

The Sample ID Problem

Here’s a tricky scenario: user creates a record locally, your app assigns it a temporary ID, then later syncs to the server which assigns a real database ID.

Now you have data relationships pointing to the temporary ID that needs to be remapped to the server ID. Fun times.

Our solution: generate UUIDs on the client side. The server accepts and stores these UUIDs as the primary identifier. No ID remapping needed.

import uuid from 'react-native-uuid';

const createSample = (data) => {
  const sampleId = uuid.v4(); // Generate UUID on client

  const sample = {
    id: sampleId,
    ...data,
    createdAt: new Date().toISOString(),
    syncStatus: 'pending'
  };

  // Save locally with UUID
  await saveLocally(sample);

  // Server will use the same UUID
  await syncToServer(sample);
};

This approach simplifies everything. Your local database and server database use the same identifiers.

Handling Conflicts

What happens when the same data gets modified locally and on the server?

There’s no perfect solution, but there are reasonable approaches:

Last Write Wins
Simple but can lose data. Use when concurrent edits are rare.

Server Always Wins
Local changes get overwritten by server. Use when server is the source of truth.

Prompt User
Show both versions and let user choose. Best UX but more complex.

For most business apps, “last write wins” with proper timestamps works fine. Conflicts are rare when users are working with their own data.

Testing Offline Behavior

You need to actually test offline scenarios. Your simulator is lying to you with perfect WiFi.

Airplane Mode Testing

Put your physical device in airplane mode and try to use your app. Does it crash? Do forms lose data? Can you navigate?

Throttled Connection Testing

Even harder: test on a slow, unreliable connection. Chrome DevTools lets you throttle network speed. Use it.

Error Scenarios

What happens when the server returns an error? When authentication fails? When the request times out?

Mock these scenarios in your tests:

// Mock a network timeout
jest.mock('./api', () => ({
  post: jest.fn(() => 
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Timeout')), 100)
    )
  )
}));

it('should save data locally when network request fails', async () => {
  const { getByText } = render(<SampleForm />);

  // Fill and submit form
  // ...

  // Verify data was saved locally despite network failure
  const localData = await getLocalStorage('samples');
  expect(localData).toHaveLength(1);
});

Crash Reporting Is Essential

When things go wrong in production, you need to know about it. We use Sentry for crash reporting and error tracking.

Sentry tells us when an API call failed, what the error was, and gives us the full context including user actions leading up to the error. This is invaluable for debugging issues that only happen in the field.

Performance Matters More on Mobile

Mobile users are impatient. If your app feels slow, they’ll bounce.

Optimize Images
Large images kill mobile performance and data plans. Compress them. Use appropriate dimensions. Consider WebP format.

Lazy Load
Don’t load everything upfront. Load what’s visible, defer the rest.

Minimize Bundle Size
Smaller JavaScript bundles mean faster startup time. Use dynamic imports for code splitting.

Profile Everything
React Native’s performance monitor shows FPS and memory usage. If your animations aren’t hitting 60 FPS, you have work to do.

The Real World Is Messy

Your mobile app will be used:
– On old devices with limited memory
– In areas with poor connectivity
– By users who don’t understand technical error messages
– While walking, driving, or multitasking
– With dirty screens and fat fingers

Build for this reality. Test on real devices in real conditions. Watch actual users try to use your app.

The apps that win are the ones that work reliably in imperfect conditions.

Wrapping Up

Building mobile apps with offline capabilities requires a different mindset than traditional web development. You can’t assume the network is available. You can’t assume requests will succeed.

But when you design around these constraints, you build more resilient applications that users trust. And trust is everything.


We specialize in building React Native mobile applications with offline-first architecture and real-world reliability. If you need a mobile app that works everywhere, let’s talk.

Leave a Comment