Local-First Autosave Without Losing the Last Keystroke

Storyie Engineering Team
8 min read

A debounced autosave can silently drop a force-quit or the keystroke typed mid-save. Here is how Storyie splits every save into an immediate local write and a deferred remote one — and guards the race.

Autosave is one of those features that looks trivial until you count the ways it loses data. Storyie is a diary and notes app: people write on a phone, on a train, with the network flickering in and out, and they leave the screen the moment a thought is done. If the last sentence they typed doesn't make it, the feature has failed in the one way a writing app cannot afford.

The naive implementation is a debounce: wait until the user stops typing for a couple of seconds, then save. That is the right instinct for the network call — you don't want a request per keystroke — but it is also a data-loss window. Everything typed inside the debounce interval lives only in the editor's memory. Force-quit the app, drop the connection, or hit back before the timer fires, and it's gone.

This post is about how Storyie's Expo (React Native) app closes that window. The short version: every change triggers two saves with opposite timing — an immediate synchronous write to on-device SQLite, and a debounced network save — and a set of useRef guards keeps the race between them honest. There are two nearly-identical hooks, useOfflineAutosave (diary) and useNoteOfflineAutosave (note, the one wired into the live note editor at app/note/new.tsx and app/note/[slug]), and the interesting parts are where they diverge.

TL;DR

  • Debounce is a data-loss window. The fix isn't a shorter debounce — it's to make one of the two saves immediate.
  • Local-immediate, remote-deferred. On every change, write to on-device SQLite synchronously (contract: <100ms), then debounce the network save (default 2s) behind a de-duplicating guard.
  • Synchronous writes = crash durability. Because the local write finishes before the keystroke handler returns, content is on disk before the OS can force-quit the app.
  • Refs, not state. Async/debounced callbacks close over a stale render; the is-saving / entity-id / last-content guards are all refs so they read live values.
  • Don't drop the last keystroke. The note hook coalesces content typed during an in-flight save (pendingContentRef); the diary hook awaits the in-flight save as a promise so a forced save returns the real id.
  • Recovery on mount only re-offers drafts that were never synced (sync status is not synced).

Debounce alone throws away the last thing you typed

Here is the trailing-debounce core, from the diary hook. On each change it schedules a network save and cancels the previous timer, so only a 2-second-quiet burst actually hits the API:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
const triggerAutosave = useCallback(
  (content: SerializedEditorState) => {
    if (!enabled) return;

    // Check if content has actually changed
    const contentString = JSON.stringify(content);
    if (contentString === lastContentRef.current) {
      return;
    }
    lastContentRef.current = contentString;

    // PHASE 4 (T022, T023): Save to local storage IMMEDIATELY (within 100ms)
    // This ensures content survives app crashes and force quits
    saveToLocalDraft(content);

    // ... schedule debounced remote save below
    debounceTimerRef.current = setTimeout(() => {
      void runSave(content);
    }, debounceMs);
  },
  [enabled, isSaving, debounceMs, runSave, saveToLocalDraft]
);

Two details carry the whole design. First, lastContentRef short-circuits no-op changes — an editor re-render that produces identical serialized content never touches storage. Second, and this is the point: saveToLocalDraft(content) runs before the setTimeout, unconditionally, on the current tick. The network save is the thing being debounced; the local save is not. Shrinking debounceMs would only narrow the window, not close it. Splitting the two saves closes it.

The split: immediate local, deferred remote

The local write goes through a small service, localDraftService, whose docstring states the contract outright:

// apps/expo/services/localDraftService.ts (excerpt)
/**
 * Save a diary draft to local storage (sync operation)
 * ...
 * @performance Must complete in <100ms for navigation safety
 */
export function saveDiaryDraft(options: SaveDiaryDraftOptions): DiaryDraft {
  const { id, userId, content, /* ... */ serverId } = options;
  const now = new Date().toISOString();
  const draftKey = getDraftKey("diary", id);
  const existingDraft = getItemSync<DiaryDraft>(draftKey);

  const draft: DiaryDraft = {
    type: "diary",
    id,
    userId,
    isNew: serverId == null,
    content,
    // ...
    version: existingDraft ? existingDraft.version + 1 : 1,
    localUpdatedAt: now,
    syncStatus: "pending",
    lastSyncError: null,
    createdAt: existingDraft?.createdAt ?? now,
  };

  setItemSync(draftKey, draft);
  // ... update the draft index
  return draft;
}

setItemSync is a thin wrapper over expo-sqlite/kv-store's synchronous API. "Synchronous" is the whole reason it's there: the row is durably on disk the instant this function returns, so it survives the frame in which the user backgrounds or kills the app. The service deliberately keeps a sync variant for exactly these navigation-critical writes and an async variant for everything else.

Every local save stamps syncStatus: "pending" and bumps a monotonic version. Those two fields are what the recovery and background-sync layers key off later — pending means "there is unpushed work here," and the version lets the crash-recovery test assert that after N rapid saves the recovered draft is version N with the latest content.

The remote half is the opposite: it runs inside the debounce timer, talks to the Hono API that all Storyie mobile data flows through, and — crucially — is allowed to be slow and to fail. A local-save failure is even swallowed on purpose so it can never block the network path:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
} catch (err) {
  // Log error but don't block - local save failure shouldn't prevent remote sync
  console.error("[useOfflineAutosave] Local draft save failed:", err);
}

Refs, not state: the stale-closure trap

Autosave is a nest of async callbacks — debounce timers, awaited mutations, force-saves on navigation — and every one of them closes over the render that created it. If a guard is React state, a callback that started two seconds ago reads the value from two seconds ago. That is exactly the wrong time to be checking "is a save already running?"

So the guards are refs, and the hook says as much:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
// Refs for avoiding stale closures
const currentDiaryIdRef = useRef<string | null>(initialDiaryId);
const isSavingRef = useRef(false);
const isCreatingRef = useRef(!initialDiaryId);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// In-flight remote save promise, so forceSave can await an already-running save.
const pendingSavePromiseRef = useRef<Promise<string | null> | null>(null);
const lastContentRef = useRef<string>("");
const draftIdRef = useRef<string | null>(initialDiaryId);

The most consequential one is isSavingRef. performSave reads it synchronously and bails if a save is already in flight:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
// Prevent duplicate saves
if (isSavingRef.current) {
  console.log("[useOfflineAutosave] Save already in progress, aborting");
  return currentDiaryIdRef.current;
}

The bug this prevents is specific and nasty. A brand-new diary has no server id yet, so the first save calls createDiary. If a second save slips in while the first is mid-flight, it also sees no id and also creates — two server rows for one entry. isSavingRef (a ref, read live) closes that. And when the create returns, the hook writes the new id into currentDiaryIdRef.current immediately, before anything else can read it, so the next save correctly does an update instead of a second create.

Not losing the keystroke typed during a save

The isSavingRef early-return raises its own question: what about content the user types while a save is running? A naive guard just drops it — the save in flight predates that keystroke, and the guard blocks a new one. This is the "last keystroke" failure mode, and the two hooks solve it differently on purpose.

The note hook coalesces. When a change arrives during an in-flight save, it stashes the content instead of scheduling anything:

// apps/expo/hooks/useNoteOfflineAutosave.ts (excerpt)
// T044: If a save is in progress, store content in pendingContentRef instead of skipping
// This ensures content typed during save is preserved
if (isSavingRef.current || isSaving) {
  // ...
  pendingContentRef.current = content;
  return;
}

Then, when the current save resolves, it checks that stash and replays it — recursively — so the final state on the server always reflects the last thing typed:

// apps/expo/hooks/useNoteOfflineAutosave.ts (excerpt)
// T044: Check for pending content that arrived during save
if (pendingContentRef.current) {
  const pendingContent = pendingContentRef.current;
  pendingContentRef.current = null;
  const pendingContentString = JSON.stringify(pendingContent);
  if (pendingContentString !== lastSavedContentRef.current) {
    // Reset saving flag before recursive call
    isSavingRef.current = false;
    return await performSave(pendingContent);
  }
}

That same machinery powers the note editor's canNavigate flag and a waitForSaves() promise, so a screen can block back-navigation until there is genuinely no pending content left.

The diary hook has a different worry, so it uses a different tool. When the user hits back on a brand-new diary that is still being created, the screen wants the diary's real id — for example to attach it to a letter share. But currentDiaryIdRef stays null until the create resolves. Returning that null would silently drop the share. So the diary hook tracks the in-flight save as a promise (pendingSavePromiseRef via a runSave wrapper) and forceSave awaits it:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
// A remote save is already running (e.g. a debounced autosave). Await it
// so the caller gets the real diary id: for a brand-new diary
// currentDiaryIdRef stays null until that save resolves, and returning it
// here would drop a pending letter share in handleBack.
if (isSavingRef.current || isSaving) {
  if (pendingSavePromiseRef.current) {
    return await pendingSavePromiseRef.current;
  }
  return currentDiaryIdRef.current;
}

Same underlying race — a save in flight and a caller that can't wait — but the note editor needs the content to survive, while the diary back-button needs the id. Reaching for one shared abstraction here would have obscured that difference; two small, honest hooks were the better trade.

Recovery: only re-offer genuinely unsaved work

Crash durability is only useful if something reads the draft back. On mount, the hook looks for a local draft and surfaces it only if it was never synced:

// apps/expo/hooks/useOfflineAutosave.ts (excerpt)
const draftId = initialDiaryId ?? draftIdRef.current;
if (draftId) {
  const existingDraft = localDraftService.getDiaryDraft(draftId);
  if (existingDraft && existingDraft.syncStatus !== "synced") {
    setRecoveredDraft(existingDraft);
  }
}

The syncStatus !== "synced" gate is the difference between a helpful recovery prompt and an annoying one. After a successful remote save, forceSave (and the background sync processor) call markAsSynced, which flips the status. So a normal reopen of an already-saved entry recovers nothing; only a draft with real unpushed changes — the force-quit case — comes back.

How we test a race you can't reproduce by hand

Data-loss races are miserable to verify manually — you'd have to force-quit at exactly the wrong microsecond. So the guards are pinned by tests instead, driven with fake timers and a persistent storage stub. The unit suite for useOfflineAutosave asserts the observable contract: the local draft is written once and immediately on change; unchanged content doesn't re-save; the debounced create fires only after advanceTimersByTime(2000); forceSave on a new diary creates and marks synced; and a save error surfaces without leaving isSaving stuck. A separate integration suite simulates a crash by keeping the storage object alive across a fresh service instance, then asserts the content — at the correct version — comes back with syncStatus: "pending". That crash test is the guarantee; the <100ms comment is just a promise.

This is the same "encode the invariant as a test" discipline we lean on across the monorepo — see our test strategy for a Next.js + Expo monorepo.

Takeaways

  • A debounce protects the network, not the user's data. Split the save: local write immediate and synchronous, network write debounced and de-duplicated.
  • Synchronous on-device writes are what make content survive a force-quit — the durability comes from the timing, not from a shorter interval.
  • In autosave code, prefer useRef over state for anything a delayed callback must read; state you closed over is a lie by the time the timer fires.
  • "Don't lose the last keystroke" and "return the real id on the way out" are different races with different fixes. Two small hooks beat one leaky abstraction.

The content being autosaved is a serialized Lexical editor state shared across web and mobile; the debounced remote save lands through the default-deny Hono API.

Available on iOS & Android

Download on the App StoreGet it on Google Play