Your Content Shouldn't Vanish When the Network Does
How we built an AsyncStorage-based draft system to protect user content from network failures — without changing the UX at all.
Your Content Shouldn’t Vanish When the Network Does
It Happened to Me
I was on the subway, writing a daily review. I’d spent a good few minutes on it — what went well, what I missed, what I wanted to try tomorrow. I tapped confirm. The screen went back.
I assumed it saved. Later I opened the review again. It was blank.
The subway had gone through a tunnel. The network dropped. The API call failed. The screen had already dismissed. My content existed nowhere.
There’s a special kind of feeling when you lose data in your own app. It’s anger and embarrassment at the same time.
The Shape of the Problem
Most content-editing flows in Fecit follow this pattern:
- Open a screen and edit content
- Press back (or confirm)
- The
beforeRemoveevent fires an API call - The screen closes
The issue is between steps 3 and 4. The API call is fire-and-forget. We don’t wait for the result before closing the screen. This is intentional — nobody wants to press back and stare at a spinner while the app decides whether to let them leave.
But fire-and-forget assumes success. If the network is down, or the request times out, the content only exists in component state. When the screen unmounts, it’s gone.
The same applies to creating new tasks. You fill in all the fields, press create, and if the network fails, you start from scratch.
The Safety Net Idea
When thinking about a fix, I set one rule: don’t change the user experience.
No loading spinners. No “Saving…” toasts. No network status warnings. The lightweight fire-and-forget feel should stay exactly as it is.
Instead, I’d add an invisible safety net. When the network works — which is 99% of the time — nothing is different. When it fails, the next time you open the same screen, your content is restored.
DraftStorage: The Core Utility
The implementation is a simple utility class.
class DraftStorage {
static async save<T>(key: string, draft: T): Promise<void> {
const entry = {
data: draft,
savedAt: Date.now(),
};
await AsyncStorage.setItem(key, JSON.stringify(entry));
}
static async load<T>(key: string): Promise<T | null> {
const raw = await AsyncStorage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw);
const age = Date.now() - entry.savedAt;
if (age > 24 * 60 * 60 * 1000) {
await AsyncStorage.removeItem(key);
return null;
}
return entry.data as T;
}
static async clear(key: string): Promise<void> {
await AsyncStorage.removeItem(key);
}
}
Every draft is saved with a timestamp. On load, if it’s older than 24 hours, it’s automatically discarded. A three-day-old half-written draft suddenly appearing would be more confusing than helpful.
Keys are scoped by screen type:
// Creation screens — one key per screen type
const CREATE_TASK_RECORD_KEY = "@draft:create-task-record";
// Edit screens — scoped by item ID
const editTaskRecordKey = (id: string) => `@draft:edit-task-record:${id}`;
// Review screens — scoped by date
const editDailyReviewKey = (date: string) => `@draft:edit-daily-review:${date}`;
const editWeeklyReviewKey = (date: string) => `@draft:edit-weekly-review:${date}`;
Creation vs Editing: Two Different Flows
The draft lifecycle is slightly different depending on whether you’re creating something new or editing something existing.
Creating (CreateTaskRecord, etc.)
User fills in the form
-> Presses create
-> API call fires
-> On failure: save the form content as a draft
-> Next time they open the creation screen: restore the draft, show a notification
-> On success: clear the draft
For creation, we wait for the API result. We only save a draft on failure. On success, there’s nothing to save.
Editing (BrowseTaskRecord, edit-daily-review, etc.)
User edits content
-> Presses back (triggers beforeRemove)
-> Save current content as draft
-> Fire the API call
-> On success: clear the draft
-> On failure: the draft remains
-> Next time they open the same item: restore from draft
For editing, we save the draft before the API call. Because beforeRemove means the screen is about to disappear. Save first, then try the API, then clean up on success. The order matters.
Making Update Functions Return Boolean
One small but essential change made this whole system possible. Previously, update API functions returned void:
// Before
async function updateTaskRecord(id: string, data: UpdateData): Promise<void> {
await authorizedPut(`/task/record/${id}`, data);
}
Now they return boolean:
// After
async function updateTaskRecord(id: string, data: UpdateData): Promise<boolean> {
try {
await authorizedPut(`/task/record/${id}`, data);
return true;
} catch {
return false;
}
}
The caller can now distinguish success from failure and decide whether to clear the draft:
// Inside the beforeRemove handler
await DraftStorage.save(editTaskRecordKey(id), currentDraft);
const success = await updateTaskRecord(id, updateData);
if (success) {
await DraftStorage.clear(editTaskRecordKey(id));
}
Coverage: 7+ Screen Types
This pattern is applied across every screen where users create or edit content:
| Screen | Draft Key | Type |
|---|---|---|
| CreateTaskRecord | @draft:create-task-record | Creation |
| BrowseTaskRecord | @draft:edit-task-record:{id} | Edit |
| BrowseSubTaskRecord | @draft:edit-subtask-record:{id} | Edit |
| BrowseTaskTemplate | @draft:edit-task-template:{id} | Edit |
| BrowseSubTaskTemplate | @draft:edit-subtask-template:{id} | Edit |
| edit-daily-review | @draft:edit-daily-review:{date} | Edit |
| edit-weekly-review | @draft:edit-weekly-review:{date} | Edit |
Each screen type has its own draft interface because the data shapes differ:
interface CreateTaskRecordDraft {
title: string;
startDate: string;
endDate: string;
memo: string;
subTasks: SubTaskDraft[];
// ...
}
interface EditTaskRecordDraft {
title: string;
memo: string;
state: string;
// ...
}
interface EditReviewDraft {
content: string;
rating: number;
}
interface EditTaskTemplateDraft {
title: string;
description: string;
// ...
}
Creation screens need everything — dates, subtask configurations, the full form state. Edit screens only need the fields the user can actually change.
Restoration UX
When a draft is restored, should the app tell the user? Two options:
- Restore silently, say nothing
- Show a brief notification: “Previously unsaved content has been restored”
I went with option 2. If a user intentionally left a screen empty, having it suddenly filled with content would be confusing. A small toast is enough.
Why 24-Hour TTL
The expiration is deliberate. Draft data that lives forever causes problems:
- A week-old half-written task suddenly appearing is confusing, not helpful
- The user may have already re-created the same content on another device
- Stale data accumulating in AsyncStorage is just clutter
24 hours comfortably covers the scenario we actually care about: the network blips, the user comes back later that day or the next morning, and their content is waiting.
The UX Doesn’t Change
The most important aspect of this entire system is that when the network is working normally, the user experience is completely identical to before.
- Press back, the screen closes immediately
- Press create, it creates immediately
- No spinners, no “Saving…” messages, no progress indicators
The best safety net is the one you never notice. In 99% of cases, the draft system does absolutely nothing. In the remaining 1%, it saves a 30-minute review from disappearing into the void.
If I hadn’t lost my own content on that subway ride, this feature probably would have waited months. Maybe forever. Nothing reprioritizes a backlog quite like being your own frustrated user.