How to Test IndexedDB Draft Saving and Offline Recovery in Browser Automation Without Losing Form State
By Markus Gasser · September 15, 2026
A practical guide to testing draft autosave, refresh recovery, offline edits, and schema migration behavior in IndexedDB-backed web forms without brittle false failures.
If a form claims to autosave, the real question is not whether a value appears on screen, it is whether the draft survives the exact interruptions users create: refresh, tab close, browser restart, network loss, and schema changes. That is what makes IndexedDB draft testing harder than ordinary field assertions. The UI may still look correct while the underlying write is delayed, queued, migrated, or silently lost.
For teams testing apps with browser form autosave, the safest approach is to verify three things separately: the draft is written, the draft is recoverable, and recovery still works when the browser state changes. That means you need tests for persisted draft state, offline recovery testing, and stale schema migration behavior, not just one happy-path save check.
The failure you are usually trying to prevent is not “field value did not change”. It is “user believed their work was safe, but the recovery path was never exercised.”
What you are actually testing
Before writing automation, separate the storage mechanism from the recovery promise.
Draft saving
This is the write path. The app copies form state into IndexedDB or similar local data storage. The important question is whether the write completes before the app reports success, navigates away, or loses network connectivity.
Offline recovery
This is the restore path. After refresh, reopen, crash, or restart, the app reads the stored draft and rehydrates the form.
State continuity
This is the user experience layer. The draft should preserve field values, validation state where appropriate, attachments if supported, and any dirty-state indicator. A restored form that resets error messaging or drops one subsection can still be a defect even if the main text field comes back.
Schema migration
IndexedDB databases often change over time. If a draft is saved with version N and read after the app ships version N+1, the upgrade path must not corrupt or discard data. The IndexedDB specification defines version upgrades and database opening behavior, so your tests should include this lifecycle instead of assuming storage is static.
A compact decision table for draft-state coverage
| Scenario | What to verify | Typical failure mode |
|---|---|---|
| Normal autosave | Draft appears in storage after edit | UI updates before write completes |
| Refresh recovery | Values reload after page refresh | State exists only in memory |
| Browser restart | Draft survives closing and reopening | Session-only storage mistaken for persistence |
| Offline edit | Edits queue and sync later, or stay locally recoverable | App blocks writes when network is absent |
| Schema upgrade | Old draft still opens after app version change | Migration wipes database or fails silently |
| Multi-tab use | Latest intended draft wins, or conflicts are handled | Tabs overwrite each other without warning |
What to assert, in order
The easiest way to avoid brittle tests is to assert the storage behavior before the visual behavior.
- Trigger a change in a single form field.
- Wait for the write signal the app exposes, if it has one.
- Verify persistence by reopening the page or context.
- Verify rehydration by checking the form value after reload.
- Verify survival across interruption, such as offline mode or browser restart.
If your app does not expose a save indicator, use the browser automation framework to inspect IndexedDB directly or to wait for a known UI state transition after the async write completes. Do not assert immediately after typing, because debounced or batched writes often finish later than the keystroke.
A simple Playwright pattern
Playwright can help because it can set up and reuse browser contexts, inspect storage state, and isolate test cases cleanly. For a draft test, the pattern is usually: edit, wait, close, reopen, verify.
import { test, expect } from '@playwright/test';
test('draft survives refresh', async ({ page }) => {
await page.goto('https://app.example.com/editor');
await page.getByLabel('Title').fill('Draft headline');
// Wait for the app's own save indicator if available.
await expect(page.getByText('Saved')).toBeVisible();
await page.reload();
await expect(page.getByLabel('Title')).toHaveValue('Draft headline');
});
This is intentionally small. The important part is the sequencing, not the syntax.
How to simulate the interruptions users create
1) Refresh or navigate away
Refresh is the fastest check, but it only proves that the app can recover from a page reload. Use it as the first gate, not the only gate.
What to watch for:
- the value reloads but the cursor position is lost, which may be acceptable
- the value reloads but validation state disappears, which may be acceptable or not, depending on the product
- the value only appears if the app has already finished writing, which means your test needs a wait condition
2) Close and reopen the browser context
This is closer to a real restart. In Playwright, closing the context and opening a new one helps distinguish session state from persistent storage.
If the draft vanishes here, check whether the app used session storage, in-memory state, or a private browsing profile by mistake.
3) Go offline, then edit
Offline recovery testing matters when users compose long content, work in flaky networks, or lose connectivity mid-session. With Playwright you can emulate offline conditions at the context level.
import { test, expect } from '@playwright/test';
test('draft can be edited offline', async ({ browser }) => {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://app.example.com/editor');
await context.setOffline(true);
await page.getByLabel('Notes').fill('Offline draft text');
await page.reload().catch(() => {});
await context.setOffline(false);
await expect(page.getByLabel('Notes')).toHaveValue('Offline draft text');
});
A practical note, the exact offline behavior depends on the app. Some editors queue writes locally and sync later, others block edits, and some show a local-only badge. Your test should match the product promise, not a generic offline assumption.
4) Test stale schema migration
Schema migration is where many storage bugs hide. IndexedDB opens a database at a version, and the app may run upgrade logic before it reads drafts. If you only test with an empty database, you miss the code path that migrates existing records.
A good migration test uses at least two app versions or two storage shapes:
- version A writes the draft
- version B opens the same browser profile and reads it back
The goal is to verify that the upgrade path preserves user data or performs a deliberate transformation.
5) Test multi-tab behavior
If two tabs can edit the same record, decide what should happen before you automate it. The app might:
- share live draft state
- write last-save-wins
- warn on conflicting edits
- isolate drafts per tab
Do not assume the product chose one of those by accident. The test should reflect the expected rule.
How to avoid false failures
Autosave tests fail for reasons that have nothing to do with product defects. The most common technical causes are timing, debouncing, and storage cleanup.
Wait for the write, not the keystroke
Typing into a field only proves the DOM accepted input. It does not prove the IndexedDB transaction completed. If the app debounces writes by 500 ms, an assertion at 100 ms will produce a false failure.
Use one of these instead:
- a visible “Saved” state
- a network or storage event the app emits
- a confirmed reload and restore
- a direct IndexedDB inspection step if your framework supports it
Keep the browser profile stable
If each test starts in a fresh context, a draft will disappear by design. That is useful for isolation, but not for persistence checks. Use a persistent profile or a controlled storage reuse setup when the test objective is recovery across sessions.
Clear test data deliberately
Local data storage testing creates leftover state. If you do not clear IndexedDB between tests, the next run may pass because an old draft is still present. If you clear it too aggressively, you may erase the exact state you meant to verify. Make cleanup a named step, not an afterthought.
Check the app’s save contract
Some apps save on blur, some save on debounce, and some save only after an explicit action such as clicking “Save draft”. Your test should mirror that contract. If the UI never promised instantaneous autosave, do not write a test that demands it.
A reliable draft test is a contract test first, and a browser interaction test second.
A practical test matrix for QA managers and frontend teams
Use this as a minimum set for any stateful form, editor, support reply box, or case note screen:
- save after typing a non-empty value
- refresh and recover the same draft
- close the context and reopen the draft
- edit offline and confirm local preservation
- upgrade the schema and confirm old drafts still open
- handle two tabs without silent data loss
You do not need dozens of variants to catch the major failures. You need each interruption type covered at least once with realistic data.
When browser automation is not enough
Browser automation tells you whether the user-facing recovery path works. It does not tell you whether a database write succeeded at the storage layer in every possible race condition. If you are debugging a flaky save path, add lower-level checks:
- inspect IndexedDB entries directly during the run
- log the save promise resolution in the app under test
- verify the migration function with unit tests
- add integration tests for any background sync queue
This layered approach is especially useful when the UI is built on top of async writes, optimistic updates, or background sync. A failing browser test is a symptom. The storage layer and migration logic usually explain why.
A small checklist you can reuse
Before you ship autosave coverage, confirm these questions:
- Does the draft persist after a reload?
- Does it persist after a full browser restart, not just a refresh?
- Does offline editing preserve user input until reconnect?
- Does the draft survive a schema version bump?
- Are waits based on save completion, not typing speed?
- Does cleanup isolate tests without deleting the state you need to verify?
If the answer to any of those is unclear, the test suite is not done yet.
FAQ
Is IndexedDB better than localStorage for draft saving?
Usually yes for richer drafts, because IndexedDB handles larger, structured data better than localStorage. But the right choice depends on the app’s data size, write pattern, and migration needs. The test strategy still needs to verify persistence and recovery either way.
How do I know if a flaky autosave test is a real bug?
Check whether the failure follows the same interruption pattern every time. If the draft disappears only when the test refreshes too soon, the issue is probably timing. If it disappears after a full restart or schema upgrade, that is more likely a product defect.
Should I test drafts through the UI only?
No. UI-only checks are useful, but they do not tell you whether the stored record exists or whether migration logic preserved it. Add at least one storage-level or recovery-level assertion.
What is the most important negative test for offline recovery?
A full browser restart after offline editing. That catches both temporary in-memory state and incorrect persistence assumptions.
Do I need a separate test for schema migrations?
Yes, if the app has released more than once or the stored shape can change. Migration bugs are easy to miss because empty profiles hide them.
What should a good autosave test report on failure?
It should say whether the draft was lost before save, during reload, during offline edit, or after migration. That makes triage much faster than a generic “expected value not found” message.