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:
- Amplify DataStore: The "heavyweight," batteries-included synchronization engine provided by AWS.
- 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)
- Schema Definition: You define your data model with
@modelinschema.graphql. - Code Generation:
amplify codegen modelsgenerates TypeScript/JavaScript classes for these models. - Local Write: When you call
DataStore.save(new Meal(...)), it writes only to the browser's IndexedDB. The Promise resolves immediately. -
Sync Engine (Background):
-
It observes the local IndexedDB.
- It pushes the new record to AppSync (GraphQL mutation).
-
If offline, it keeps the record in a "Mutation Queue" and retries with exponential backoff when connectivity returns.
-
Conflict Resolution:
-
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.
-
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
- App Shell (PWA): A Service Worker (configured via
vite-plugin-pwa) cachesindex.html,main.js, and CSS. This ensures the app opens instantly even in Airplane Mode. -
Read Caching (React Query):
-
When
useMeals()is called, it checks a memory cache. - If data exists, it displays it immediately (Stale-While-Revalidate).
-
It fetches fresh data in the background and updates the UI if distinct.
-
Write (Optimistic UI):
-
When you call
addMeal(...): - Immediate: We manually inject the new meal into the React Query cache. The UI updates instantly.
- Network: The actual API request is sent.
- Offline Handling: If the network fails (Offline), React Query (with
persistQueryClient) pauses the mutation and retries it automatically when theonlineevent fires. - 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_versionfields 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 themutationCacheto persist tolocalStorageorIndexedDB. - 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-keyvalor similar).
- Pros: Achievable with
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.
- Pros: Excellent. Since DataStore maintains a local replica of the DB, you can run rich queries like
- 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
RecentItemsentity 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.
- Pros: Can query the local DB for the date range
- 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.
- When you view "Today", it caches
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.
- To support "Recent Items" with React Query, we create a dedicated API endpoint (e.g.,
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 bedb.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).
- The Challenge: React Query expects a Promise. Usually, that Promise is
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
onMutateof theaddMealfunction:
// 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, orstyles.css. - The PWA Solution: The Service Worker acts as a network proxy. It intercepts the request for
index.htmland 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 (
AddMealfunction code). - React Query Caches: The Furniture (The list of meals, the chart data).
3. How they work together
- User opens app (Offline):
- PWA Service Worker intercepts request -> Serves
index.html. - React mounts -> Runs
useMeals(). - React Query checks network -> Fails.
- React Query checks
localStorage-> Finds cached meal data. - 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.
- 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.
- 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."
- Architecture Hygiene: The "Service Layer" pattern (Scenario 5) allows us to support Guest Mode via
Dexie.jsexplicitly. While more work thanDataStore.save(), the resulting code is transparent, debuggable, and standard JavaScript, rather than proprietary AWS sync logic.
The Roadmap remains
- Phase 1 (Now): React Query +
persistQueryClientfor "Authenticated Offline Support" (Resiliency). - Phase 2 (Future): Service Layer +
Dexie.jsfor "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.