DEV Community

Cover image for Building an Offline-First React App: A Practical Guide for Frontend Developers
Tech In Vernacular
Tech In Vernacular

Posted on

Building an Offline-First React App: A Practical Guide for Frontend Developers

Your app dey fast, your UI dey polished, your API calls dey clean. Then your user enter lift abi na elevator, or dem enter bus for Lagos, or dem waka enter building wey get thick concrete wall and weak signal, then kasala start dey unfold.

Wetin dey sup? Loading spinner. Error toast. Abi blank screen. All the data wey dem dey look, fiam, na once e just comot.

Na wetin offline-first development dey fix be this. Instead make you treat network as requirement and dey patch error message anytime e fail, you go treat the user device as the primary source of truth and network as optional enhancement wey dey sync for background.

This article go carry you waka pass how to build this thing for real React + TanStack Query codebase, no be small toy demo, but production app wey get authentication, role-based access, and API-driven data.


Wetin "Offline-First" Really Mean

The mindset shift dey very simple: build for no network first, then add network as bonus.

For traditional web app, the flow fit go like this: user act, the app go ask the server, e go wait, then e go show result. If network no good or e no even dey at all, the user go wait or make e jam error.

For offline-first app, the flow be say: user act, the app go respond immediately from local data, then e go sync with server for background when possible or necessary.

User no go even know whether dem dey online or offline. The app go just dey work.


The Four Layers Wey You Need

Offline-first for frontend no be one man army dey run am, e get four layers wey dey gather work together. Think of am as a stack, where each one dey solve different problem:

Layer 1 - Service Worker : na e dey make your app dey loadable when e no get network. Without this man, your browser go show you the "you are offline" page before any of your code even run. Na this service worker dey cache your HTML, CSS, and JavaScript so the app shell go render immediately.

Layer 2 - IndexedDB : this one na full database for inside browsers. E dey store API response cache (so your data go dey available offline) and sync queue (pending write operations wey we need send to the server later).

Layer 3 - Cache-Aware Fetch Utilities : this ones na wrapper functions wey go try network first, cache the result inside IndexedDB if e succeed, and fall back to cached data if e fail. Na this one be the decision making layer.

Layer 4 - TanStack Query + Persistence : na this man dey manage in-memory caching, stale data, background refetching, and optimistic updates. The persistQueryClient plugin go dey serialize the query cache go the localStorage so e go fit to survive page refresh.

Make we see how requests like "fetch user roles" go take waka through all the four layers when the device no get network:

  1. Component go call useGetRoles(), TanStack Query go first check im in-memory cache
  2. If e empty (fresh page load), persistQueryClient go hydrate from the localStorage
  3. If dem both dey empty, the queryFn go fire, e go call AuthService.getRoles(), which na our service setup, where we make the endpoint request
  4. The service go con call cacheFetch() wey go try the network through axiosService
  5. Service worker go con intercept the fetch, if e no see network, e go check im own cache
  6. If even that one miss, cacheFetch() go check IndexedDB for local cached copy

So we get four separate levels of fallback. User go sha see data for every level except na total cache miss.


The Architectural Layers

Make we look inside the different architectural layers with code samples.

Layer 1: IndexedDB, The Filing Cabinet

IndexedDB na the database wey dem build put for inside every browser. Think of am as like filing cabinet with labeled drawers. We go create three drawers:

  • apiCache - saved API responses with expiration timestamps
  • syncQueue - pending write operations wey dey wait to send go server
  • offlineMeta - small settings like "when we sync last?"

See the core setup using idb library (npm install idb), wey wrap IndexedDB old callback API inside modern Promises:

// services/offline/offlineDb.ts
import { openDB, type IDBPDatabase, type DBSchema } from "idb";

interface OfflineDBSchema extends DBSchema {
  apiCache: {
    key: string;
    value: {
      key: string;
      data: unknown;
      timestamp: number;
      expiresAt: number;
    };
    indexes: { "by-expiry": number };
  };
  syncQueue: {
    key: number;
    value: {
      id?: number;
      operationType: string;
      service: string;
      url: string;
      method: string;
      data?: unknown;
      timestamp: number;
      retryCount: number;
      maxRetries: number;
      status: "pending" | "processing" | "failed";
    };
  };
  offlineMeta: {
    key: string;
    value: { key: string; value: unknown; updatedAt: number };
  };
}

const DB_NAME = "app-offline-db";
const DB_VERSION = 1;
let dbInstance: IDBPDatabase<OfflineDBSchema> | null = null;

export async function getDb() {
  if (dbInstance) return dbInstance;

  dbInstance = await openDB<OfflineDBSchema>(DB_NAME, DB_VERSION, {
    upgrade(db) {
      const cache = db.createObjectStore("apiCache", { keyPath: "key" });
      cache.createIndex("by-expiry", "expiresAt");
      db.createObjectStore("syncQueue", { keyPath: "id", autoIncrement: true });
      db.createObjectStore("offlineMeta", { keyPath: "key" });
    },
  });

  return dbInstance;
}
Enter fullscreen mode Exit fullscreen mode

The upgrade callback na the only place wey you fit create or modify stores. E dey run once when the database first dey created, or when you increase DB_VERSION. The singleton pattern (dbInstance) dey ensure say we only open one connection at a time.

Key operations wey dey needed:

// Save an API response with a TTL (time-to-live)
export async function cacheResponse(key: string, data: unknown, ttlMs: number) {
  const db = await getDb();
  await db.put("apiCache", {
    key,
    data,
    timestamp: Date.now(),
    expiresAt: Date.now() + ttlMs,
  });
}

// Retrieve a cached response
export async function getCachedResponse<T>(key: string) {
  const db = await getDb();
  const entry = await db.get("apiCache", key);
  if (!entry) return null;
  return {
    data: entry.data as T,
    isExpired: Date.now() > entry.expiresAt,
  };
}

// Queue an operation for later sync
export async function enqueueSyncOp(op: {
  operationType: string;
  service: string;
  url: string;
  method: string;
  data?: unknown;
}) {
  const db = await getDb();
  return db.add("syncQueue", {
    ...op,
    timestamp: Date.now(),
    retryCount: 0,
    maxRetries: 5,
    status: "pending",
  });
}
Enter fullscreen mode Exit fullscreen mode

db.put() dey insert or replace. db.add() dey insert and e go fail if the key don already exist (e make sense for the auto-incrementing sync queue). db.get() dey retrieve by key. Simple CRUD, nothing fancy.


Layer 2: Cache-Aware Fetch Wrapper

This one na the "librarian" wey dey decide whether make e go internet or make e check the filing cabinet. E dey sit between your service methods and your axiosService setup:

// services/offline/offlineCache.ts
import axiosService from "@/axiosService";
import { cacheResponse, getCachedResponse, enqueueSyncOp } from "./offlineDb";

// Consistent return shape — the callers dey get the same response structure
export interface OfflineResult<T> {
  success: boolean;
  data?: T;
  error?: string;
  source: "network" | "cache" | "none";
  isStale?: boolean;
}

// Centralized cache keys — one source of truth for all labels
export const CACHE_KEYS = {
  roles: "auth:roles",
  mfaMethods: "auth:mfa-methods",
  users: (id: string) => `users:${id}`,
} as const;

// TTL presets (in milliseconds)
export const TTL = {
  SHORT: 1000 * 60 * 5,        // 5 minutes
  MEDIUM: 1000 * 60 * 30,      // 30 minutes
  LONG: 1000 * 60 * 60 * 4,    // 4 hours
  DAY: 1000 * 60 * 60 * 24,    // 24 hours
} as const;
Enter fullscreen mode Exit fullscreen mode

The OfflineResult<T> type na the contract. Every function go return this shape. The source field dey tell caller "I get this one from internet" or "I get this one from filing cabinet" or "I no fit get am from anywhere." This one dey allow your UI code decide wetin to show.

The cacheFetch function (for reads)

Strategy: Try network first, cache the result, fall back to cached data if e fail.

export async function cacheFetch<T>(opts: {
  cacheKey: string;
  ttl: number;
  service: string;
  url: string;
  transform?: (response: any) => T;
}): Promise<OfflineResult<T>> {
  const { cacheKey, ttl, service, url, transform } = opts;

  // Try the network
  try {
    const response = await axiosService({ service, url, method: "GET" });
    const data = transform ? transform(response) : response.data;

    // Cache the successful response (no let cache failures break the flow)
    await cacheResponse(cacheKey, data, ttl).catch(console.warn);

    return { success: true, data, source: "network" };
  } catch (networkError: any) {
    // Network failed — check the cache (filing cabinet)
    try {
      const cached = await getCachedResponse<T>(cacheKey);
      if (cached) {
        return {
          success: true,
          data: cached.data,
          source: "cache",
          isStale: cached.isExpired,
        };
      }
    } catch (cacheError) {
      console.warn("Cache read failed:", cacheError);
    }

    // Both failed — nothing to show
    const errorMsg = networkError?.response?.data?.error?.message
      || "Request failed and no cached data available";
    return { success: false, error: errorMsg, source: "none" };
  }
}
Enter fullscreen mode Exit fullscreen mode

The key design decision for here be say: if caching the response fail (maybe storage don full), we no go crash. The .catch(console.warn) go quietly log warning and continue. User don already get im data from network, so missed caching opportunity no go be big issue.

The queueableMutation function (for writes)

Strategy: if online, send am normally. If offline, queue am inside IndexedDB for later.

export async function queueableMutation<T>(opts: {
  operationType: string;
  service: string;
  url: string;
  method: string;
  data?: unknown;
}): Promise<OfflineResult<T> & { queued?: boolean }> {
  if (navigator.onLine) {
    try {
      const response = await axiosService({
        service: opts.service,
        url: opts.url,
        method: opts.method,
        data: opts.data,
      });
      return { success: true, data: response.data, source: "network" };
    } catch (error: any) {
      // No response at all = network error, safe to queue
      if (!error.response) {
        await enqueueSyncOp(opts);
        return { success: true, source: "none", queued: true };
      }
      // Server return error (4xx/5xx) — no queue am, e go fail again
      return { success: false, error: error.response.data?.error?.message, source: "network" };
    }
  }

  // Offline — queue the operation
  await enqueueSyncOp(opts);
  return { success: true, source: "none", queued: true };
}
Enter fullscreen mode Exit fullscreen mode

The subtle but important distinction be say: if the server receive our request and e reject am (400 error), to queue am go just useless, e go still get the same rejection letter. We go only add the request for the queue when the request never reach server at all.

Notice say we dey return success: true with queued: true even though nothing really send. From the user perspective, dem intent don dey captured. Na the hook layer go con decide which toast to show based on the queued flag.


Layer 3: To Refactor Your Services

The biggest architectural change na to make your services pure data layer. No toast. No navigation. No store update. Just fetch data and return am in consistent shape.

Your typical service method fit be like this:

static async getRoles() {
  try {
    const response = await axiosService({
      service: "AUTH",
      url: "/superadmin/get-roles",
      method: "GET",
    });
    toast.success("Roles fetched!"); // Side effect in the service
    return { success: true, data: response.data };
  } catch (error) {
    toast.error("Failed to fetch roles"); // Side effect in the service
    return { success: false };
  }
}
Enter fullscreen mode Exit fullscreen mode

But make this for work well, turn am to pure data layer with offline fallback:

static async getRoles(): Promise<OfflineResult<Role[]>> {
  return cacheFetch<Role[]>({
    cacheKey: CACHE_KEYS.roles,
    ttl: TTL.LONG,
    service: "AUTH",
    url: "/superadmin/get-roles",
    transform: (response) => response.data,
  });
}
Enter fullscreen mode Exit fullscreen mode

Like this, the whole method don become one single cacheFetch call. No try/catch, no toast, no error handling, cacheFetch don dey handle all of that internally and e go just return the structured OfflineResult.

Why this one matter? Because now the same method fit return data from network or from cache, and you no wan show "Roles fetched successfully!" when you just read am from IndexedDB. The decision about wetin to tell user go reside for the hook layer, where you sabi the context.


Layer 4: Hooks As the Orchestration Layer

Side effects, toast, navigation, store update, accessibility announcement, all of dem dey move here:

export const useGetRoles = () => {
  return useQuery({
    queryKey: ["auth", "roles"],
    queryFn: async () => {
      const result = await AuthService.getRoles();
      if (!result.success) throw new Error(result.error);

      // Only notify if the source na cache — network success is silent, just as expected
      if (result.source === "cache") {
        toast.info("Showing cached roles. Will refresh when online.");
      }

      return result.data;
    },
    staleTime: 1000 * 60 * 30,    // 30 min, to match the service's TTL
    gcTime: 1000 * 60 * 60 * 24,  // 24 hours
  });
};
Enter fullscreen mode Exit fullscreen mode

For mutations wey fit queue:

export const useAcceptInvite = () => {
  return useMutation({
    mutationKey: ["auth", "invite", "accept"],
    mutationFn: (token: string) => AuthService.acceptInvite(token),
    networkMode: "offlineFirst",
    onSuccess: (result) => {
      if (result.queued) {
        toast.info("You're offline. Invitation will be accepted when you reconnect.");
      } else {
        toast.success("Invitation accepted!");
      }
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

For mutations wey ghas happen online (like login):

export const useLogin = () => {
  return useMutation({
    mutationFn: (data: LoginInput) => AuthService.loginUser(data),
    networkMode: "online", // Don't even try if offline
    onSuccess: (result) => {
      // Set tokens, cookies, navigate and whatever else...
    },
    onError: (error) => {
      if (!navigator.onLine) {
        toast.error("You're offline. Please connect to log in.");
        return;
      }
      toast.error(getErrorMessage(error, "Login failed"));
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

networkMode for TanStack Query actually get three possible values: "online", "always", and "offlineFirst". For our own use case: "online" na for operations wey need real-time server validation (login, MFA), and "offlineFirst" na for operations wey fit queue. For plain reads wey dey use cacheFetch internally, e no really matter which networkMode you set for the useQuery, because the online/offline decision don already dey happen inside cacheFetch before TanStack Query even sabi wetin dey sup. networkMode na TanStack Query own separate mechanism for pausing retries, e no be the same thing as your cache-fallback logic.


Layer 5: TanStack Query Persistence

Wrap your app with PersistQueryClientProvider to serialize the TanStack Query cache go localStorage across sessions:

import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";

const persister = createAsyncStoragePersister({
  storage: {
    getItem: (key) => Promise.resolve(localStorage.getItem(key)),
    setItem: (key, value) => { localStorage.setItem(key, value); return Promise.resolve(); },
    removeItem: (key) => { localStorage.removeItem(key); return Promise.resolve(); },
  },
  key: "app-query-cache",
  throttleTime: 1000,
});

function AppProviders({ children }) {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 1000 * 60 * 60 * 24,
        dehydrateOptions: {
          shouldDehydrateQuery: (query) => {
            if (query.state.status !== "success") return false;
            // Only persist stable reference data — never tokens or sensitive data
            const allowlist = ["roles", "mfa-methods"];
            const keyString = JSON.stringify(query.queryKey);
            return allowlist.some((pattern) => keyString.includes(pattern));
          },
        },
      }}
      onSuccess={() => {
        queryClient.resumePausedMutations().then(() => {
          queryClient.invalidateQueries();
        });
      }}
    >
      {children}
    </PersistQueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The shouldDehydrateQuery filter na critical thing for security. Without am, everything go just dey serialize dey go, including queries wey fit contain auth token or PII. The allowlist pattern dey ensure say na only stable reference data go persist.

The onSuccess callback go fire when the persisted cache don restore for page load. E go resume any mutation wey dem pause mid-flight (e.g. user close tab while offline) and e go invalidate queries so dem go refetch fresh data if device don dey online again.


Layer 6: The Service Worker

This one na wetin dey make the app fit load when e no get network. Without am, browser go show im own "no internet" page before your React code even run.

// public/sw.js
const CACHE_NAME = "app-shell-v1";
const API_CACHE_NAME = "api-responses-v1";
const PRECACHE_URLS = self.__PRECACHE_MANIFEST; // Injected at build time

// INSTALL: Download and cache the app shell
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => {
      const urls = PRECACHE_URLS.map((e) => typeof e === "string" ? e : e.url);
      return cache.addAll(urls);
    }).then(() => self.skipWaiting())
  );
});

// ACTIVATE: Clean up old caches
self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((names) =>
      Promise.all(
        names
          .filter((n) => n !== CACHE_NAME && n !== API_CACHE_NAME)
          .map((n) => caches.delete(n))
      )
    ).then(() => self.clients.claim())
  );
});

// FETCH: Intercept every GET request
self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return; // Only cache GETs

  const url = new URL(event.request.url);

  if (isStaticAsset(url)) {
    event.respondWith(cacheFirst(event.request));
  } else if (isApiCall(url)) {
    event.respondWith(networkFirst(event.request));
  } else if (event.request.mode === "navigate") {
    event.respondWith(navigationHandler(event.request));
  }
});
Enter fullscreen mode Exit fullscreen mode

Three caching strategies for three types of content:

Cache-first for static assets (JS, CSS, images). Dem get hashed filenames, main.a3b2c1.js, so the URL go change when content change. Serve am from cache immediately, only fetch from network when cache miss.

Network-first for API calls. We want fresh data when possible, but stale data still better pass no data at all. Try network, cache the response, fall back go the cached version if e dey offline.

Navigation handler for page loads. For SPA, serve the cached index.html for any route, React Router go handle routing client-side. Na this one dey make navigation work offline.

const cacheFirst = async (request) => {
  const cached = await caches.match(request);
  if (cached) return cached;

  const response = await fetch(request);
  if (response.ok) {
    const cache = await caches.open(CACHE_NAME);
    cache.put(request, response.clone()); // clone() because a response can only be read once
  }
  return response;
}

const networkFirst = async (request) => {
  try {
    const response = await fetch(request);
    if (response.ok) {
      const cache = await caches.open(API_CACHE_NAME);
      cache.put(request, response.clone());
    }
    return response;
  } catch {
    return (await caches.match(request)) || new Response(
      JSON.stringify({ error: "offline" }),
      { status: 503, headers: { "Content-Type": "application/json" } }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Background Sync

Service worker dey also process the sync queue when connectivity return:

self.addEventListener("sync", (event) => {
  if (event.tag === "sync-pending-ops") {
    event.waitUntil(processSyncQueue());
  }
});
Enter fullscreen mode Exit fullscreen mode

Browser go first fire this sync event automatically when device regain connectivity, even if user don close the tab. Service worker go con open the same IndexedDB wey your app dey write to, e go read the pending operations, e go send dem go server, and e go remove dem when e succeed.

Important gap to sabi: the Background Sync API wey we just describe no dey supported everywhere, Safari (both desktop and iOS) na alagidi, e no gree support am at all as of now, and na Safari/iOS heavy for market for many places including Nigeria. So if your users dey heavy on iPhone, you no fit fully rely on automatic background sync, you go need fallback: check navigator.onLine or listen for the online event, and manually trigger processSyncQueue() when the app come back to foreground or when connectivity return, no just go depend on the browser sync event only to save you.

The Precache Manifest Problem

Vite dey produce hashed filenames wey dey change for every build. Hardcoded precache list go go wrong immediately. The solution na to get one small custom Vite plugin wey go run after build, e go scan the /dist folder, and e go inject the file list inside your sw.js by replacing the self.__PRECACHE_MANIFEST placeholder. Zero extra dependency, just Node.js built-ins.


Everything No Need To Be Offline

Critical design decision: categorize your operations before you implement anything.

Category Examples Strategy
Cacheable reads Get roles, list users, fetch MFA methods cacheFetch() with IndexedDB fallback
Queueable writes Accept invitation, resend OTP, forgot password queueableMutation() with sync queue
Online-only Login, MFA verification, password reset Direct axiosService with offline error message

Login no fit work offline, server na the authority on credentials. No just try queue am. Show clear message instead: "You're offline. Please connect to log in."


The Online Status Hook

Your components need sabi the connectivity status. Use useSyncExternalStore for concurrent-safe implementation:

import { useSyncExternalStore } from "react";

function subscribe(cb: () => void) {
  window.addEventListener("online", cb);
  window.addEventListener("offline", cb);
  return () => {
    window.removeEventListener("online", cb);
    window.removeEventListener("offline", cb);
  };
}

export function useOnlineStatus() {
  return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
}
Enter fullscreen mode Exit fullscreen mode

You fit con use am to disable buttons, show banners, or adjust UI behavior:

function LoginButton() {
  const isOnline = useOnlineStatus();
  return (
    <button disabled={!isOnline || isPending}>
      {isOnline ? "Log in" : "You're offline"}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

One nice thing worth sabi-ing: navigator.onLine na "am I connected to a network" no be "does the internet actually work." Phone fit dey connected to WiFi wey no get internet access (like public WiFi wey need login), and navigator.onLine go still return true. For real production app, na good idea to also do small periodic health-check ping (like small HEAD request to your API) if you want more accurate "are we truly online" signal, no go depend on the browser event only.


For Next.js nko?

Everything wey we don talk about na framework-agnostic client-side code. If you dey use Next.js:

  • IndexedDB, cache utilities, services, hooks : all identical, no changes need
  • TanStack Query hooks : you need add "use client" for top of the file
  • Providers : wrap dem inside Client Component boundary
  • Service worker : use Serwist (@serwist/next) instead of custom Vite plugin. E dey integrate with Next.js build system and e dey handle precache manifest injection wella.

The one architectural difference: for SPA, service worker dey serve one cached index.html for every route. For Next.js, server-rendered pages fit need different HTML per route. Your service worker go need fallback-shell strategy for uncached dynamic routes.


Summary

Offline-first no be single feature, na layered architecture:

  1. Service Worker : e dey make app loadable offline (e dey cache app shell and API responses)
  2. IndexedDB : e dey store cached data and queued write operations durably
  3. Cache-Aware Utilities : cacheFetch() and queueableMutation() dey abstract the online/offline decision
  4. Pure Services : dem dey return data from network or cache in consistent shape, no side effects
  5. Hook Layer : e dey orchestrate side effects (toasts, navigation) based on the data source
  6. TanStack Query Persistence : e dey serialize the query cache to survive page refresh

User no go need see spinner because of connectivity. User no go ever lose work because of tunnel. Network go become invisible, and that na the main point.


If you find this one useful, I dey write about frontend and backend architecture, and how to build resilient applications. Follow for more.

Happy reading! Cheers🥂!

Top comments (0)