Offline Support in Web Apps: Data Persistence

When building web applications, it’s easy to think of the client state as disposable. Refresh the page, refetch the data, and move on. That mental model works well — right up until you start caring about offline behaviour.
This is the third post in a short series on building offline-capable applications. Today we discuss a fundamental aspect of offline-ready software — data persistence.
Regardless of whether you use a foreground queue, background sync, or a hybrid approach, you need a place to store data locally. In my experience, getting data persistence right from the start saves a lot of friction later when you start adding the actual features.
Why persistence matters
From the user's perspective, offline support begins with being able to see something meaningful. Ideally, it should be exactly what they saw when they were still online, like a list of to-dos, a previously opened document, a screen they were just interacting with. If the app can't display any data when the network is unavailable, there's not much the user can do.
This post is entirely about one very specific thing: caching data fetched from the server so it’s still available offline. Without persisted server data, there is no foundation for offline support.
The key decision, and the one I find most important early on, is establishing what data is critical to cache. Not everything needs to be persisted, but some queries are essential: global context, core dynamic configuration, and the data that’s in the offline-capable features.
This decision becomes the basis for all offline functionality:
which screens should be rendered without a network,
which actions are going to be possible offline,
and how much complexity you’ll need later for syncing and recovery.
Once this boundary is clearly defined, the offline story becomes much easier.
Storage options in the browser
Browsers give us a few options for storing data for offline sessions, each with very different trade-offs.
The simplest ones are localStorage and sessionStorage. They’re synchronous, easy to use, and available everywhere. Unfortunately, that simplicity comes with hard limits: small storage quotas, blocking APIs, and no support for structured or large data. For anything beyond feature flags or tiny bits of state, this is really not suitable.
Cookies are even less attractive for this use case. They’re size-constrained, sent with every request, and optimised for server communication rather than client-side persistence.
That leaves IndexedDB. It’s asynchronous, built for larger datasets, and designed to work well with structured data. The API itself is not particularly friendly, which is why I recommend using a library for, as the interface.
In my experience, IndexedDB is the most practical choice for offline persistence because:
it doesn’t block the main thread,
it scales well as your cache grows,
and it works reliably across modern browsers.
There are edge cases where other storage options make sense, but for persisted data caches and offline-first features, IndexedDB is usually the right default.
React Query persistence
It's hard to imagine a single-page React application of a decent size without using React Query to manage client-side server state. It's definitely my go-to choice for this purpose. Keeping data persistence close to the library simplifies the entire mental model.
The approach I’ll describe here is:
based on
@tanstack/react-query-persist-client,backed by IndexedDB,
and intentionally selective about what gets persisted.
This approach is general enough to work across different apps, as long as they use React with React Query as the primary data-caching solution.
The example: todos with offline status updates
I’ll use a small todos app as a concrete example:
the app displays a list of todos,
each todo has a
completedstatus,toggling the status should work offline,
and the list should still render after a reload.
That gives us a clear persistence requirement: the todos query must survive reloads and offline sessions.
Step 1 — Choosing what to persist (selective persistence)
Persisting everything that goes through React Query is rarely what you want. It unnecessarily increases storage usage. Instead, I recommend selective persistence:
global queries required for the app to function,
and queries directly related to offline-capable features.
In a todos app, that usually means the todos list itself.
A common pattern is to filter queries by key prefixes:
const persistedQueryPrefixes = [
'me', // Global - user context
'todos', // Feature-specific - list of todos
];
export const shouldPersistQuery = (query: Query) => {
return persistedQueryPrefixes.includes(query.queryKey[0]);
};
In practice, I prefer using query key factories so this stays type-safe and refactor-friendly.
The trade-off here is you need to think about query ownership. The upside is a much smaller and more predictable cache.
Step 2 — Building a persister with IndexedDB
You can use idb-keyval for read and write operations and implement React Query’s Persister interface.
import { set, get, del } from 'idb-keyval'
const STORAGE_KEY = 'rq-cache';
export const persister: Persister = {
persistClient: async (client) => {
await set(STORAGE_KEY, client);
},
restoreClient: async () => {
return get(STORAGE_KEY);
},
removeClient: async () => {
await del(STORAGE_KEY);
},
};
Step 3 — Wiring persistence into providers
React Query handles most of the lifecycle for you via PersistQueryClientProvider.
This is where configuration starts to matter:
maxAge — how long persisted queries are kept (for example, 3 days),
buster — value to increment when you make breaking cache changes.
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24 * 3,
buster: 'v1',
dehydrateOptions: {
shouldDehydrateQuery: shouldPersistQuery,
},
}}
>
{children}
</PersistQueryClientProvider>
One important piece of configuration in your regular query client is staleTime — this is when data becomes stale but still usable. Queries marked as stale will be fetched from the API when the app is online. Keep in mind, they won't be fetched again after a refresh because the client now lives in persisted storage.
Finding the right configuration is a balancing act. In my experience, longer living cache is fine for offline-friendly data, as long as you still refetch aggressively when the app is online.
Once persistence in place, the next step is to record status changes while offline.
The easiest way would be to optimistically update the cached todos query, and React Query will automatically persist that change. The system doesn't care whether the change comes from the server or a mutation.
This is not enough. The tricky part is replaying the action or actually saving the state on the server. What happens next—like reconciliation, retries, and handling conflicts—depends on the offline model you choose, which I’ll discuss later in the series.
A word on migrations
One important realisation is that with data stored on the client side, there's one additional place where data exists independently from the rest of your system. There might be a situation where you change the structure of the API being used. You update the application code to match, so everything works together. So what happens in the application?
When a new version of the application launches, the client loads the stored data, and it breaks because the code now expects different data, while the client still has the old format. You have two options.
Run a migration. This means running a script to transform the already stored client-side data into the new format expected by the updated application code. The benefit is a seamless user experience with preserved data and, most importantly, no data loss. The trade-off is added complexity, careful versioning, and the risk of bugs if migrations fail or are only partially applied.
Invalidate the cache. This means discarding all previously stored client-side data and forcing the application to fetch fresh data from the server. This approach is way simpler to implement, but users may lose some offline data.
Summary
Data persistence is the foundation of offline support. With React Query, you can get surprisingly far by:
persisting only what’s needed,
using IndexedDB for storage behind a small abstraction,
and letting React Query manage cleanup and hydration.
Once this is in place, higher-level offline features become much easier to reason about.
If you enjoyed the article or have a question, feel free to reach out on Bluesky! 👋
Further reading and references
Photo by Erol Ahmed on Unsplash






