Skip to content

ADR 002: Offline Strategy

Metadata

  • Status: Accepted & Implemented
  • Date: 2025-12-30
  • Type: Architecture Decision Record (ADR)

This document analyzes two competing strategies for implementing offline capabilities in ChatKcal:

  1. Amplify DataStore: The "heavyweight," batteries-included synchronization engine provided by AWS.
  2. PWA + React Query: A "lightweight" composition of standard web technologies (Service Workers + Optimistic UI).

Option A: Amplify DataStore

The Local Database First

DataStore changes the fundamental mental model of the app. Instead of talking to the API, the frontend talks to a local IndexedDB database. A background "Sync Engine" handles the replication to the cloud.

1. The Mechanism (Step-by-Step)

  1. Schema Definition: You define your data model with @model in schema.graphql.
  2. Code Generation: amplify codegen models generates TypeScript/JavaScript classes for these models.
  3. Local Write: When you call DataStore.save(new Meal(...)), it writes only to the browser's IndexedDB. The Promise resolves immediately.
  4. Sync Engine (Background):

  5. It observes the local IndexedDB.

  6. It pushes the new record to AppSync (GraphQL mutation).
  7. If offline, it keeps the record in a "Mutation Queue" and retries with exponential backoff when connectivity returns.

  8. Conflict Resolution:

  9. If the server has a newer version of the record (modified by another device), DataStore uses a strategy (Automerge, Optimistic Concurrency, or Lambda) to merge changes.

  10. Subscription: The engine opens a WebSocket connection to receive real-time updates from the server to keep the local DB in sync.

2. The "Gotchas" for ChatKcal

  • Schema Pollution: DataStore requires specific fields in your DynamoDB table to handle versioning and conflict detection:
    • _version: Integer (Monotonically increasing)
    • _deleted: Boolean
    • _lastChangedAt: Timestamp
  • Existing Data: Since our DynamoDB table (MEAL#...) already exists without these fields, enabling DataStore would treat all existing records as "invalid" or require a complex migration script to backfill these fields.
  • Resolver Conflict: ChatKcal uses custom JS resolvers (appsync/addMeal.js, etc.) for business logic (converting "logical dates", aggregation). DataStore prefers to own the resolvers (standard VTL auto-generated by Amplify) to ensure the sync protocol is strictly followed. Integrating custom business logic into the DataStore sync pipeline is complex.

Option B: PWA + React Query (Proposed)

Cache-First Web Architecture

This approach uses standard web patterns. The app talks to the API, but "cheats" by showing the user the predicted result before the server confirms it.

1. The React Query Mechanism

  1. App Shell (PWA): A Service Worker (configured via vite-plugin-pwa) caches index.html, main.js, and CSS. This ensures the app opens instantly even in Airplane Mode.
  2. Read Caching (React Query):

  3. When useMeals() is called, it checks a memory cache.

  4. If data exists, it displays it immediately (Stale-While-Revalidate).
  5. It fetches fresh data in the background and updates the UI if distinct.

  6. Write (Optimistic UI):

  7. When you call addMeal(...):

  8. Immediate: We manually inject the new meal into the React Query cache. The UI updates instantly.
  9. Network: The actual API request is sent.
  10. Offline Handling: If the network fails (Offline), React Query (with persistQueryClient) pauses the mutation and retries it automatically when the online event fires.
  11. Rollback: If the server eventually rejects the request (e.g., validation error), the UI is reverted.

2. Fit for ChatKcal

  • Schema Agnostic: It works perfectly with our existing clean DynamoDB schema (PK, SK, Calories). No _version fields needed.
  • Logic Preservation: It continues to use our custom appsync/ resolvers. The backend remains the "source of truth" for complex logic.
  • Lighter Weight: No IndexedDB sync engine running in the background. Less battery drain.

Summary Comparison

Feature Amplify DataStore PWA + React Query
Paradigm Local Database + Sync Engine Cache + Optimistic UI
Complexity High (Black box magic) Medium (Explicit logic)
DynamoDB Schema Intrusive (Adds _version, etc.) Non-Intrusive (As-is)
Backend Logic Hard to customize (Standard VTL) Easy (Custom JS Resolvers)
Conflict Resolution Built-in (Robust) Last-Write-Wins (Basic)
Setup Cost High (Migration required) Low (Library install)

Recommendation

Go with PWA + React Query.

Since ChatKcal is a single-user-per-view application (users don't edit the same meal simultaneously from different devices), the complex conflict resolution of DataStore is overkill. The strict schema requirements of DataStore would force a rewrite of our backend, whereas React Query layers purely on top of the frontend.


Deep Dive: Real-World Scenarios

Here is how each strategy handles specific future features on the roadmap.

Scenario 1: Batch Offline Logging

User goes on a hike (no signal), eats an apple, a sandwich, and drinks a soda. They log all three one by one.

  • Amplify DataStore:
    • Pros: Handles this natively. The "Outbox" pattern is built-in. It queues 3 separate mutations in IndexedDB. When the user regains signal, it processes them sequentially.
    • Cons: If one fails (e.g., validation), handling that error UI can be tricky as it happens in the background later.
  • React Query:
    • Pros: Achievable with persistQueryClient. We manually configure the mutationCache to persist to localStorage or IndexedDB.
    • Cons: We must implement the "Queue UI" ourselves (e.g., showing a "Pending Sync..." badge). If the browser tab is closed before syncing, we need to ensure the queue persists (requires idb-keyval or similar).

Scenario 2: "Recent Items" Lookup

User wants to quickly re-log "Oatmeal" by typing "Oat..." in a search bar.

  • Amplify DataStore:
    • Pros: Excellent. Since DataStore maintains a local replica of the DB, you can run rich queries like DataStore.query(Meal, m => m.name.contains('Oat')) instantly without network.
    • Cons: Requires syncing all history to the device first. If a user has 5 years of history, the initial sync might be heavy.
  • React Query:
    • Pros: Fast if we only care about recently fetched data.
    • Cons: Not a Database. React Query is a cache. We cannot query "all history" unless we've fetched it. To implement this, we would likely build a dedicated RecentItems entity in the backend and fetch/cache that specific list (e.g., useQuery('recents')). This is actually more efficient for bandwidth but requires specific backend support.

Scenario 3: Weekly Stats & Graphs

User wants to see a bar chart of calories for the last 7 days.

  • Amplify DataStore:
    • Pros: Can query the local DB for the date range [Now-7d, Now]. Fast and works offline.
    • Cons: No native aggregation functions (SUM, AVG) in local DataStore. You must fetch the raw records and sum them up in JavaScript on the client.
  • React Query:
    • Pros: We fetch the data via API (which can be optimized). The response is cached. If the user goes offline, we can still render the chart from the cache.
    • Cons: If the cache is empty and the user is offline, we can't generate the chart. DataStore might have the data if it synced previously.

Scenario 4: The "Duplication" Dilemma (Flicking & Recents)

User asks: "If I want to flick between days instantly, or look up recent items, isn't fetching a separate 'Recents' list wasteful duplication if I already have the meals logged?"

1. Flicking Between Days (Navigation)

  • React Query: This is its superpower.
    • When you view "Today", it caches ['meals', '2025-12-30'].
    • When you view "Yesterday", it caches ['meals', '2025-12-29'].
    • Result: Flicking back and forth is instant (0ms latency) because the data is in memory. It is duplicated (Server + Client Cache), but this is necessary for UI responsiveness.

2. Recent Items (The "Partial Knowledge" Problem)

This is where the difference between a Cache (React Query) and a Database (DataStore) becomes critical.

  • The Problem:

    • Imagine you ate "Sushi" 3 months ago.
    • DataStore (Local DB): Since it (ideally) syncs your entire history, it knows about "Sushi". A local search finds it.
    • React Query (Cache): Unless you have manually scrolled back 3 months in the current session, the browser does not know "Sushi" exists. It only holds the days you've looked at recently.
  • The Solution: Why we "Duplicate" via API

    • To support "Recent Items" with React Query, we create a dedicated API endpoint (e.g., getRecentMeals).
    • Why? The Server has Total Recall (access to all 5 years of your data). The Client has Partial Recall (only the days loaded in RAM).
    • Trade-off: We "duplicate" the data (fetching "Sushi" again in a list) to save us from having to sync/store the user's entire multi-year history on their phone just to find one old meal.
    • Verdict: The bandwidth cost of fetching a small "Recents" JSON list is significantly lower than the storage/sync cost of maintaining a full local database replica.

Scenario 5: The "Guest Mode" (Offline-Only Trial)

User asks: "Can I try the app without creating an account? I want to log meals locally on my device and only sync if I decide to sign up later."

This is the most disruptive requirement because none of our API endpoints work without a logged-in user.

  • Amplify DataStore:

    • Pros: Native Capability. DataStore is designed to work without a user. It saves to IndexedDB by default. When the user eventually signs in, DataStore attempts to merge the anonymous local data with the newly authenticated account.
    • Cons: "User Merging" is notoriously difficult to get right (handling conflicts between local anonymous data and potentially existing cloud data). You still pay the "Schema Tax" (must use their rigid schema).
  • React Query + "Service Layer":

    • The Challenge: React Query expects a Promise. Usually, that Promise is fetch('/api/...'). In Guest Mode, that Promise must be db.get('...').
    • The Solution (Abstraction): We would need to introduce a Repository/Service Layer in the frontend (e.g., MealService.js) and a lightweight local DB wrapper (like Dexie.js for IndexedDB).
    // mealService.js
    async function getMeals(date) {
        if (isGuest) {
            return await localDB.meals.where('date').equals(date).toArray();
        } else {
            return await api.graphql(GET_MEALS, {
                date
            });
        }
    }
    
    • Pros: Keeps our architecture clean. We only import the "Local DB" logic if we actually build Guest Mode.
    • Cons: We essentially have to build a "Mini-Backend" in the browser (duplicating the logic to filter/sort meals locally).

Scenario 6: The Connected Data Problem (Weekly Stats)

User asks: "If I add a meal while offline, does my Weekly Calorie Chart update immediately?"

  • The Challenge:

    • useMeals(today) fetches the List.
    • useWeeklyStats() fetches the Chart.
    • These are separate cache keys. Updating the list does not automatically recalculate the chart.
  • The Solution (Manual Optimistic Updates):

    • We must write code to bridge this gap.
    • In onMutate of the addMeal function:
    // 1. Update the Daily List
    queryClient.setQueryData(['meals', today], (old) => [...old, newMeal]);
    
    // 2. MANUALLY Update the Weekly Chart
    const weekKey = ['stats', currentWeek];
    const oldStats = queryClient.getQueryData(weekKey);
    
    if (oldStats) {
        // Manually increment the specific day's bar in the cached chart
        const newStats = oldStats.map(day =>
            day.date === today ? {
                ...day,
                calories: day.calories + newMeal.calories
            } :
            day
        );
        queryClient.setQueryData(weekKey, newStats);
    }
    
    • Result: The user sees the chart update instantly. When online, the server confirms the true numbers.

Scenario 7: The "Cold Cache" Limit (Navigation)

User asks: "I'm on a plane. Can I look at what I ate 3 weeks ago?"

  • Amplify DataStore:
    • Yes. (Assuming the initial sync completed). The database is local.
  • React Query:
    • It depends.
    • If you viewed that day recently: YES. It is in the cache/storage.
    • If you haven't opened the app in a month: NO.
    • Why? React Query is a Cache, not a Database. It only remembers what it has seen. It cannot "invent" data it never fetched.
    • The UX: The user will see a loading spinner, followed by an "Offline / Network Error" message.

Implication:

This is the main trade-off of the "Lightweight" approach. We accept that Offline Mode is primarily for "Recent Read + New Write", not "Full Historical Analysis".


Critical Analysis & Advocacy

Before making the final call, let's steelman both arguments based on the scenarios above.

The Case for Amplify DataStore ("True Local-First")

If we want ChatKcal to be a robust, professional tool that feels native, DataStore is the superior UX choice.

  • Total Recall: Users expect to see their history. If a user is on a long flight and wants to review their progress from last month, React Query fails. DataStore succeeds.
  • Complex Integrity: Handling "Batch Logging" (Scenario 1) and "Guest Merging" (Scenario 5) manually in React Query is prone to bugs. DataStore handles the "Outbox" pattern, retry logic, and conflict resolution at the protocol level.
  • Search: Implementing "Recent Items" (Scenario 2) via API is a workaround. The "Correct" engineering solution is having the data local and indexing it locally.
  • Critique of React Query: It is fundamentally a Network State Manager, not a Database. Stretching it to handle offline writes and "Guest Mode" (via Dexie) creates a fragmented architecture where we are manually reimplementing features DataStore gives us for free.

The Case for PWA + React Query ("Resilient Web App")

If we want ChatKcal to be a fast, flexible, and maintainable project, React Query is the superior Engineering choice.

  • The "Schema Tax": DataStore is an invasive abstraction. It demands we alter our DynamoDB table (_version, _deleted) and surrender our Resolvers to VTL auto-generation. For an AI-driven app where backend logic (AppSync JS) is the "Secret Sauce," this is a massive regression.
  • Performance: Syncing 5 years of history (DataStore) consumes storage and bandwidth. React Query is "Lazy" - it only fetches what the user actually asks for.
  • Simplicity: Scenario 6 (Connected Data) is a minor code challenge compared to the complexity of debugging DataStore sync conflicts or schema version mismatches.
  • Critique of DataStore: It is a "Black Box." When it works, it's magic. When it breaks (sync gets stuck, models desync), it is a nightmare to debug because the logic lives in the hidden Sync Engine, not your code.

What is the Role of PWA?

It is easy to confuse the responsibilities of React Query and PWA (Progressive Web App). They solve two different "Offline" problems.

1. The "No Internet" Browser Error

Without a PWA Service Worker, if you turn on Airplane Mode and refresh chatkcal.app, you get the Chrome dinosaur game.

  • The Problem: The browser cannot fetch index.html, main.js, or styles.css.
  • The PWA Solution: The Service Worker acts as a network proxy. It intercepts the request for index.html and serves it from the Cache Storage API.
  • Result: The "White Screen" or UI loads successfully, even with no signal.

2. The "App Shell" Concept

PWA ensures the Skeleton of the house exists.

  • PWA Caches: The Layout, The Navigation Bar, The Buttons, The Logic (AddMeal function code).
  • React Query Caches: The Furniture (The list of meals, the chart data).

3. How they work together

  1. User opens app (Offline):
  2. PWA Service Worker intercepts request -> Serves index.html.
  3. React mounts -> Runs useMeals().
  4. React Query checks network -> Fails.
  5. React Query checks localStorage -> Finds cached meal data.
  6. UI renders the meals.

Without PWA: You can't even start the app to let React Query run.

Without React Query: You can load the UI, but it will be empty (0 meals).


Final Verdict

Decision: Proceed with PWA + React Query.

Why?

The deciding factor is Control vs. Magic.

  1. Backend Control: ChatKcal relies heavily on custom AppSync JS resolvers for its AI logic and aggregation. DataStore struggles with custom logic. We cannot afford to fight the framework to implement our core value proposition.
  2. The "Cold Cache" Compromise: We explicitly accept that users cannot browse deep history while offline (Scenario 7). This is an acceptable trade-off for a calorie tracker, where 90% of utility is "Log Now" and "View Today/This Week."
  3. Architecture Hygiene: The "Service Layer" pattern (Scenario 5) allows us to support Guest Mode via Dexie.js explicitly. While more work than DataStore.save(), the resulting code is transparent, debuggable, and standard JavaScript, rather than proprietary AWS sync logic.

The Roadmap remains

  1. Phase 1 (Now): React Query + persistQueryClient for "Authenticated Offline Support" (Resiliency).
  2. Phase 2 (Future): Service Layer + Dexie.js for "Guest Mode" (Local-Only).

Implementation Update (2025-12-31)

To mitigate the "Cold Cache" limitation (Scenario 7), we have configured React Query's gcTime (Garbage Collection Time) to 30 days.

  • Impact: Users can view their meal history for the last month while offline, provided they have opened the app at least once in that period.
  • Trade-off: slightly higher storage usage (IndexedDB), which is negligible for text-based meal data.