Offline-First GraphQL with Apollo Client Cache Persistence
AI generated
{ }
type
GraphQL · Apollo Client · Offline-First
Offline-First GraphQL with Apollo Client Cache Persistence
when the app has to keep working without a network

A network outage should never mean a dead app. With cache persistence, a mutation queue for pending changes, and thoughtful conflict resolution, a GraphQL application can be built so users keep reading, keep working, and keep making changes while the connection is waited on in the background.

19 min read Apollo Client · AsyncStorage · Mutation Queue React Native · Apollo Client 3

1. What offline-first actually means for GraphQL

Offline-first GraphQL does not mean an app offers every feature in airplane mode, it means the local cache is treated as the primary data source and the network as a background synchronization mechanism. The difference is fundamental: instead of waiting for a network response on every screen visit, the app first reads from the local Apollo cache and updates it once new data becomes available. To the user, it feels like the app never waits, regardless of whether a connection currently exists.

Apollo Client already brings an important foundation for this approach: a normalized in-memory cache that references objects by their ID and serves queries from it. For offline-first GraphQL, though, a pure in-memory cache isn't enough, because it's lost on app restart. Only the combination of cache persistence, saving the cache to the device, and a mutation queue for pending changes makes an app genuinely offline-capable.

Expectations matter too: not every feature can be sensibly offered offline. A live chat or a payment authorization inherently requires an active connection. Offline-first GraphQL targets mainly read access that can be served from cache, and write access that can be deferred without the user depending on an immediate server result. The following sections show how these building blocks are concretely implemented with Apollo Client.

2. Cache persistence: keeping the Apollo cache across restarts

The first building block of offline-first GraphQL is not keeping the Apollo cache in memory only, but regularly writing it to persistent storage. The apollo3-cache-persist package does exactly this: it serializes the InMemoryCache and stores it in AsyncStorage on React Native or localStorage on the web, with a configurable debounce so that not every single cache change triggers an expensive write immediately.

On app start, the saved cache is restored before the first render. That means a user opening the app while offline immediately sees the most recently seen data, instead of a loading state or an error message. For offline-first GraphQL, this step is the fundamental prerequisite, without a persistent cache there's simply no data left to show offline after a restart.


// Apollo Client with persisted cache, restored before first render
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ApolloClient, InMemoryCache } from '@apollo/client';
import { persistCache, AsyncStorageWrapper } from 'apollo3-cache-persist';

const cache = new InMemoryCache();

export async function createApolloClient() {
  // Restore the cache from disk before the client is used anywhere
  await persistCache({
    cache,
    storage: new AsyncStorageWrapper(AsyncStorage),
    debounce: 500,     // ms, batches rapid cache writes
    maxSize: 5 * 1024 * 1024,   // 5 MB persisted cache limit
  });

  return new ApolloClient({
    uri: 'https://api.mironsoft.de/graphql',
    cache,
  });
}

3. Detecting network status and informing Apollo Client

A persistent cache alone isn't enough, the app also needs to know whether a connection currently exists in order to steer requests accordingly. The @react-native-community/netinfo package delivers connection status updates in real time. Apollo Client, in combination with apollo-link-queue or custom links, offers the ability to pause outgoing operations while there's no connection, instead of letting them fail.

This mechanism is decisive for offline-first GraphQL: instead of an error message on every request made without a network, the operation is placed in a queue and automatically retried once the connection returns. This fundamentally changes the user experience, "error, please try again" becomes "syncing once back online".


// Pause outgoing operations while offline, resume automatically on reconnect
import NetInfo from '@react-native-community/netinfo';
import { RetryLink } from '@apollo/client/link/retry';
import QueueLink from 'apollo-link-queue';

const queueLink = new QueueLink();

NetInfo.addEventListener((state) => {
  // Close the queue while offline, open it again once reconnected
  queueLink.close();
  if (state.isConnected) {
    queueLink.open();
  }
});

const retryLink = new RetryLink({
  delay: { initial: 1000, max: 30000, jitter: true },
  attempts: { max: 5 },
});

// Link order matters: queue first, then retry, then the actual transport
export const offlineLink = queueLink.concat(retryLink);

4. Mutation queue: collecting changes while offline

Read access can be served fairly easily from the persistent cache, write access is the harder part of offline-first GraphQL. When a user places an order or edits a note while offline, that mutation needs to be stored until a connection exists again. A mutation queue stores such pending operations along with their variables persistently, usually in the same storage layer as the cache itself.

It's important that every mutation in the queue is designed to be idempotent, meaning it can safely run multiple times without producing duplicate effects. A client-generated, unique idempotency key per mutation prevents an order from being created twice if the reconnect process accidentally sends a mutation twice, for example due to a brief network interruption in the middle of synchronization.


// Simplified mutation queue with idempotency keys
import { v4 as uuid } from 'uuid';

interface QueuedMutation {
  id: string;              // idempotency key, generated once per mutation
  mutation: string;         // serialized GraphQL document
  variables: Record<string, unknown>;
  createdAt: number;
}

async function enqueueMutation(mutation: string, variables: Record<string, unknown>) {
  const queued: QueuedMutation = {
    id: uuid(),
    mutation,
    variables: { ...variables, idempotencyKey: uuid() },
    createdAt: Date.now(),
  };
  const existing = JSON.parse((await AsyncStorage.getItem('mutationQueue')) ?? '[]');
  await AsyncStorage.setItem('mutationQueue', JSON.stringify([...existing, queued]));
}

async function flushQueue(client: ApolloClient<object>) {
  const queue: QueuedMutation[] = JSON.parse(
    (await AsyncStorage.getItem('mutationQueue')) ?? '[]'
  );
  for (const item of queue) {
    // Server checks idempotencyKey and returns the existing result on retry
    await client.mutate({ mutation: gql(item.mutation), variables: item.variables });
  }
  await AsyncStorage.removeItem('mutationQueue');
}

5. Optimistic responses as the basis for offline UX

So a mutation shows up in the UI immediately while offline, instead of waiting for the actual server response, offline-first GraphQL combines the mutation queue with Apollo Client's optimistic response mechanism. The client applies the expected result locally to the cache right away, once the server is reachable and responds, the optimistic result is replaced with the real one. For the user, this makes the difference between online and offline almost disappear, a note appears in the list immediately, regardless of current connection status.

The catch: optimistic responses only work well when the expected server result is predictable, such as a new ID the client generates itself and offers to the server as a suggestion. For operations with server-computed values, such as a final price after discount logic, the optimistic display should be clearly marked as provisional, so users aren't surprised if the value shifts slightly after synchronization.

6. Conflict resolution on reconnect

As soon as multiple devices, or one device after a long offline period, synchronize again, conflicts can arise: server state has changed while local mutations are still pending. Offline-first GraphQL therefore needs a deliberate conflict strategy, rather than blindly relying on "last write wins", which in practice often leads to silent data loss. For additive operations, such as adding a comment, a conflict is rarely a problem. For operations that overwrite a value, such as an order status, an explicit version check makes sense.

A proven pattern is giving every mutable entity a version number or timestamp and sending it along with the mutation. The server compares the submitted version against the current state and rejects the mutation if data has changed in the meantime, instead of silently overwriting it. The client can then handle that case deliberately, for example with a merge dialog or a clear error message, instead of masking the conflict.

7. Storage limits and selective persistence

Mobile devices have limited storage, and an unboundedly growing Apollo cache isn't a good idea. For offline-first GraphQL, it pays off to limit persistence deliberately to data actually needed offline, rather than blindly persisting the entire cache. The persistCache setup allows a maxSize limit, and cache eviction policies can additionally remove rarely used entries in a targeted way before storage fills up.

A sensible pattern is distinguishing between critical data needed offline for core functionality and optional data like rarely visited detail pages. Critical data is aggressively pre-loaded and persisted, optional data stays in the volatile in-memory cache and gets evicted first when storage runs low. This prioritization prevents important offline functionality from being crowded out by unimportant detail data.

8. Testing offline behavior without airplane mode

Manually testing offline behavior by constantly toggling airplane mode is tedious and rarely covers all relevant cases. For offline-first GraphQL, a simulated network status in tests pays off, one that makes scenarios like "mutation starts online, connection drops mid-request, reconnect after ten seconds" reproducible on demand. Mock implementations of NetInfo and a configurable custom link that artificially delays or fails requests enable automated tests of these flows.

Tests for the mutation queue itself are especially important: does mutation order survive a flush, are idempotency keys correctly reused on retries, and is the queue actually emptied after a successful flush. These edge cases often slip through manual testing because they require very specific timing that's hard to reproduce reliably on a device.

9. Offline strategies compared

Depending on requirements, different combinations of the building blocks presented fit better. The following overview ranks the most important strategies by complexity and robustness.

Strategy Offline reads Offline writes Conflict safety
In-memory cache only No (after restart) No Not relevant
Cache persistence alone Yes No Not relevant
Persistence + mutation queue Yes Yes Safe only for additive changes
+ version checking Yes Yes High

For most applications, the combination of cache persistence and a mutation queue with idempotency keys already forms a solid foundation for offline-first GraphQL. Version checking pays off additionally whenever the same entity can be modified concurrently by multiple devices or users.

Mironsoft

GraphQL architecture and offline-capable apps

Need your app to work reliably without a network?

We set up cache persistence, mutation queues and conflict resolution with Apollo Client, and test offline behavior deliberately instead of leaving it to chance.

Cache strategy

Persistence setup with sensible storage limits and prioritization

Mutation queue

Idempotent, persistent queue for offline mutations

Conflict resolution

Version checking and merge strategies for concurrent changes

10. Summary

Offline-first GraphQL with Apollo Client rests on three pillars: cache persistence preserves already-seen data across restarts, a mutation queue collects pending writes idempotently, and optimistic responses let changes appear in the UI immediately, regardless of current connection status. Network status detection via NetInfo controls when requests get paused and when they're automatically retried.

Conflict resolution through version checking prevents concurrent changes from silently overwriting each other, and selective persistence with clear storage limits keeps the app functional even on devices with limited storage. Combining these building blocks, rather than treating them individually, produces an app that feels nearly identical to users regardless of whether a connection currently exists or not.

Offline-First GraphQL — The essentials at a glance

Cache persistence

apollo3-cache-persist saves the normalized cache to AsyncStorage, restored before the first render.

Mutation queue

Store pending mutations persistently with idempotency keys until the connection returns.

Optimistic UI

Instant local display of the expected result, replaced by the real server response after sync.

Conflict resolution

Version checking instead of "last write wins" prevents silent data loss on concurrent changes.

11. FAQ: Offline-First GraphQL with Apollo Client

1Caching vs. offline-first?
Caching speeds things up while connected, offline-first works fully without a connection using a persistent cache and queue.
2Which package for cache persistence?
apollo3-cache-persist, saves the InMemoryCache to AsyncStorage or localStorage.
3Not losing mutations offline?
Via a persistent mutation queue, stored until reconnect and then flushed automatically.
4Why idempotency keys?
Prevent duplicate effects when a mutation gets sent multiple times due to an interruption.
5Conflicts between local and server?
Version checking rejects outdated mutations instead of letting them through and silently overwriting data.
6Optimistic responses offline?
Yes, they show the expected result locally right away, regardless of connection status.
7Maximum cache size?
A few megabytes as a starting point, combined with selective persistence of critical data.
8Detecting connection status?
Via @react-native-community/netinfo, combined with a queue-link configuration in Apollo Client.
9Testable offline behavior?
Yes, via mock NetInfo and custom links that artificially delay or fail requests.
10Fits every feature?
No, features needing an inherently current server response shouldn't be offered offline.