WellAlly Logo
WellAlly康心伴
Development

Building an Offline-First Health Tracker with React, PWA, and IndexedDB

Learn how to build a resilient, offline-first health tracker PWA with React. This tutorial covers local data storage with IndexedDB (Dexie.js) and automatic background sync with Workbox.

W
2025-12-10
9 min read

As developers, we often build applications with the assumption of a stable internet connection. But what about users who are hiking in the mountains, running in a park with spotty reception, or on a flight? For a health tracking application, these are prime use cases. If a user can't log their workout because they're offline, the app has failed them.

This is where an offline-first architecture comes in. Instead of treating offline as an error state, we design the application to work locally by default, and then synchronize with a server when a connection is available.

In this tutorial, we'll build a simple but powerful Health Tracker PWA. It will allow users to log their runs and hikes, storing the data locally. When they're back online, the app will automatically sync the data to a server.

What we'll build/learn:

  • How to set up a Progressive Web App (PWA) using Create React App.
  • How to use Dexie.js as a user-friendly wrapper for IndexedDB to store data in the browser.
  • How to use Workbox to implement service worker caching for the app shell.
  • The magic of workbox-background-sync to queue failed network requests and retry them automatically.

Prerequisites:

  • Solid understanding of React and JavaScript (ES6+).
  • Node.js and npm (or yarn) installed.
  • Basic familiarity with browser developer tools.

Why this matters to developers: Offline-first isn't just a niche feature; it's a paradigm shift towards building more resilient, faster, and user-centric applications. The techniques you'll learn here are applicable to a wide range of apps, from note-taking tools to complex enterprise applications.

Understanding the Problem

The core challenge is to maintain data integrity and a smooth user experience in unpredictable network conditions. A traditional, online-only app would show a loading spinner and eventually fail when the user tries to save data without a connection.

Limitations of existing solutions:

  • localStorage: It's synchronous (can block the main thread) and has a small storage limit (around 5MB), making it unsuitable for storing large amounts of data like a user's entire workout history.
  • Manual sync handling: Building a robust retry mechanism from scratch is complex. You need to handle edge cases like the user closing the browser before the sync is complete.

Our approach is better because:

  • IndexedDB offers a much larger, asynchronous, and more powerful database in the browser.
  • Dexie.js provides a clean, promise-based API for IndexedDB, making it as easy to use as any modern data-fetching library.
  • Workbox abstracts away the complexities of service workers, especially for background sync, making our implementation declarative and robust.

Prerequisites

Let's get our development environment set up.

  1. Create a new React PWA: We'll use the Create React App PWA template to get a head start with a service worker and Workbox configuration.

    code
    npx create-react-app offline-health-tracker --template cra-template-pwa
    cd offline-health-tracker
    
    Code collapsed
  2. Install Dexie.js: This will be our IndexedDB wrapper.

    code
    npm install dexie
    
    Code collapsed
  3. Start the development server:

    code
    npm start
    
    Code collapsed

    You should see the default React app running on http://localhost:3000.

Step 1: Setting Up the Local Database with Dexie.js

First, we need a place to store our health data locally. We'll define a simple database schema for our workout sessions.

What we're doing

We'll create a db.js file to define our IndexedDB database and its "tables" (called object stores in IndexedDB terminology). We'll define a workouts store with an auto-incrementing primary key and indexes for querying.

Implementation

Create a new file src/db.js:

code
// src/db.js
import Dexie from 'dexie';

export const db = new Dexie('healthTracker');

// Define the database schema
db.version(1).stores({
  workouts: '++id, date, type, distance, duration', // Primary key and indexed props
});
Code collapsed

How it works

  • new Dexie('healthTracker'): This line creates a new database instance named healthTracker.
  • db.version(1).stores({...}): Dexie uses versioning to manage schema changes. Here, we're defining version 1 of our database.
  • workouts: '++id, date, type, distance, duration': This defines an object store called workouts.
    • ++id: Specifies an auto-incrementing primary key named id.
    • The other comma-separated values (date, type, etc.) are properties we want to index for efficient querying.

That's it! Dexie handles all the low-level IndexedDB setup for us.

Step 2: Building the React UI

Now, let's create a simple UI to add and display workouts.

What we're doing

We'll modify src/App.js to include a form for adding new workouts and a list to display workouts fetched from our local Dexie database. We'll use React hooks to manage state.

Implementation

Replace the content of src/App.js with the following:

code
// src/App.js
import React, { useState, useEffect } from 'react';
import { db } from './db';
import './App.css';

function App() {
  const [workouts, setWorkouts] = useState([]);
  const [type, setType] = useState('Run');
  const [distance, setDistance] = useState('');
  const [duration, setDuration] = useState('');

  useEffect(() => {
    // Fetch all workouts from Dexie
    const fetchWorkouts = async () => {
      const allWorkouts = await db.workouts.toArray();
      setWorkouts(allWorkouts);
    };
    fetchWorkouts();
  }, []);

  const handleSubmit = async (event) => {
    event.preventDefault();
    if (!distance || !duration) {
      alert('Please enter distance and duration.');
      return;
    }

    try {
      // Add the new workout to the database
      const id = await db.workouts.add({
        type,
        distance: parseFloat(distance),
        duration: parseInt(duration),
        date: new Date(),
      });

      // Update the UI
      setWorkouts([...workouts, { id, type, distance, duration, date: new Date() }]);
      
      // Clear form
      setDistance('');
      setDuration('');
    } catch (error) {
      console.error('Failed to add workout: ', error);
    }
  };

  return (
    <div className="App">
      <header>
        <h1>Offline Health Tracker 🏃‍♀️</h1>
      </header>
      <form onSubmit={handleSubmit}>
        <h2>Log a New Workout</h2>
        <select value={type} onChange={(e) => setType(e.target.value)}>
          <option value="Run">Run</option>
          <option value="Hike">Hike</option>
        </select>
        <input
          type="number"
          placeholder="Distance (km)"
          value={distance}
          onChange={(e) => setDistance(e.target.value)}
        />
        <input
          type="number"
          placeholder="Duration (minutes)"
          value={duration}
          onChange={(e) => setDuration(e.target.value)}
        />
        <button type="submit">Add Workout</button>
      </form>
      <div className="workout-list">
        <h2>My Workouts</h2>
        {workouts.map((workout) => (
          <div key={workout.id} className="workout-item">
            <strong>{workout.type}</strong> - {workout.distance} km in {workout.duration} mins
            <br />
            <small>{new Date(workout.date).toLocaleString()}</small>
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;
Code collapsed

And add some basic styling to src/App.css.

How it works

  • useEffect: When the component mounts, it fetches all existing workouts from the workouts object store using db.workouts.toArray() and updates the component's state.
  • handleSubmit: When the form is submitted, we call db.workouts.add() with the new workout data. This is an asynchronous operation that returns the ID of the newly created record. We then update our local React state to immediately reflect the change in the UI.

At this point, you have a fully functional offline app! You can add workouts, refresh the page, and the data will still be there, stored securely in IndexedDB.

Step 3: Enabling the Service Worker and Background Sync

This is where the magic happens. We'll enable the service worker to make our app work offline and set up a background sync queue to handle data synchronization.

What we're doing

  1. Register the Service Worker: We need to change one line in src/index.js to activate the service worker.
  2. Configure Workbox: We'll modify the src/service-worker.js file to define our caching strategies and, most importantly, set up the BackgroundSyncPlugin.

Implementation

1. Register the Service Worker

Open src/index.js and find this line:

code
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://cra.link/PWA
serviceWorkerRegistration.unregister();
Code collapsed

Change unregister() to register():

code
serviceWorkerRegistration.register();
Code collapsed

2. Configure Background Sync in the Service Worker

Now, open src/service-worker.js. This file is where you configure Workbox. Replace its contents with the following:

code
// src/service-worker.js
import { clientsClaim } from 'workbox-core';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { NetworkOnly } from 'workbox-strategies';

clientsClaim();

// Precache all of the assets generated by your build process.
precacheAndRoute(self.__WB_MANIFEST);

const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$');
registerRoute(
  ({ request, url }) => {
    if (request.mode !== 'navigate') {
      return false;
    }
    if (url.pathname.startsWith('/_')) {
      return false;
    }
    if (url.pathname.match(fileExtensionRegexp)) {
      return false;
    }
    return true;
  },
  createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html')
);

// Cache other assets like images with a stale-while-revalidate strategy.
registerRoute(
  ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'),
  new StaleWhileRevalidate({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 }),
    ],
  })
);

// ✨ Set up Background Sync for API requests
const bgSyncPlugin = new BackgroundSyncPlugin('workout-queue', {
  maxRetentionTime: 24 * 60 // Retry for max of 24 Hours
});

registerRoute(
  ({ url }) => url.pathname.startsWith('/api/workouts'),
  new NetworkOnly({
    plugins: [bgSyncPlugin],
  }),
  'POST' // Only apply this for POST requests
);

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

Code collapsed

How it works

  • precacheAndRoute(self.__WB_MANIFEST): This is the core of PWA caching. It tells Workbox to take all the files from your build folder (your app shell) and cache them. The next time the user opens the app, the service worker will serve these files directly from the cache, making it load instantly, even offline.
  • BackgroundSyncPlugin: We create a new instance of the plugin, giving our queue a name (workout-queue).
  • registerRoute: We tell the service worker to intercept any POST requests going to /api/workouts.
  • new NetworkOnly({ plugins: [bgSyncPlugin] }): We apply a NetworkOnly strategy. This means the request will always go to the network. However, if the network request fails (i.e., the user is offline), the bgSyncPlugin will catch it and add it to the 'workout-queue' in IndexedDB. When the browser detects that connectivity is back, Workbox will automatically retry the requests in the queue.

Step 4: Putting It All Together - Triggering the Sync

The final piece is to modify our handleSubmit function to actually make a network request.

Implementation

Let's update src/App.js to send the data to a (mock) API endpoint.

code
// src/App.js

// ... (imports and component setup are the same)

const handleSubmit = async (event) => {
  event.preventDefault();
  if (!distance || !duration) {
    alert('Please enter distance and duration.');
    return;
  }

  const newWorkout = {
    type,
    distance: parseFloat(distance),
    duration: parseInt(duration),
    date: new Date().toISOString(),
  };

  try {
    // 1. Save to local DB first for instant UI feedback
    const id = await db.workouts.add(newWorkout);

    setWorkouts((prevWorkouts) => [...prevWorkouts, { ...newWorkout, id }]);
    
    setDistance('');
    setDuration('');

    // 2. Then, try to send to the server
    await fetch('/api/workouts', {
      method: 'POST',
      body: JSON.stringify(newWorkout),
      headers: {
        'Content-Type': 'application/json',
      },
    });

  } catch (error) {
    console.error('Operation failed: ', error);
    // The background sync will handle the failed fetch.
  }
};

// ... (rest of the component is the same)
Code collapsed

How it works

  1. We still save the data to Dexie immediately. This is crucial for the offline-first experience. The user gets instant feedback that their data is saved.
  2. We then fetch the data to our API endpoint /api/workouts.
    • If online: The request succeeds.
    • If offline: The fetch will fail and throw an error. Our service worker, listening for this exact request, will intercept the failed request and queue it for later.

Testing the Offline Sync

  1. Build and serve the app: The service worker only runs on the production build.

    code
    npm run build
    npx serve -s build
    
    Code collapsed

    Open your app from the local server (e.g., http://localhost:3000).

  2. Go offline: In your browser's DevTools, go to the "Network" tab and check the "Offline" box.

  3. Add a workout: Fill out the form and submit it. The workout will appear in your list instantly (thanks to IndexedDB). In the console, you will see a net::ERR_INTERNET_DISCONNECTED error, which is expected.

  4. Check the queue: Go to the "Application" tab in DevTools, then "IndexedDB". You should see your new workout in the workouts object store. Now, go to "Background Sync". You will see that a request is pending in your workbox-background-sync queue.

  5. Go back online: Uncheck the "Offline" box in the Network tab. After a few moments, Workbox will automatically send the queued request. The entry in the Background Sync tab will disappear! ✨

Conclusion

We've successfully built a resilient, offline-first health tracking application. Users can now confidently log their workouts anywhere, knowing their data is safe locally and will sync automatically when they're back online.

We achieved:

  • A fast, app-like experience by caching the UI with a service worker.
  • Reliable data storage with IndexedDB and the user-friendly Dexie.js.
  • A robust, automatic data synchronization mechanism using Workbox's Background Sync.

Next steps for readers:

  • Implement a "syncing" status in the UI for workouts that are still in the queue.
  • Handle potential data conflicts if the user uses the app on multiple devices.
  • Set up a real backend API to receive the synced data.

Resources

#

Article Tags

pwaofflinereacthealthtech
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare TechnologySoftware DevelopmentUser ExperienceAI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey

© 2024 康心伴 WellAlly · Professional Health Management
Building an Offline-First Health Tracker with React, PWA, and IndexedDB