The bugs that truly matter in offline-first do not sit inside a single screen - they live on the boundaries. A boundary is a moment of transition: the app leaves online for offline, moves from one version to the next, comes back from a disconnect into reconnect. On a stable screen everything is green; things break precisely at the seam, when the context changes and the code silently assumes the old one. So it is the transitions that must be checked, not the presence of features.
The natural mistake is to trust the checkmark. Lighthouse checks installability and that a worker controls the page, a useful audit, but it does not prove the offline domain is correct. A green badge will not tell you that a note saved offline survives a reload, that reconnect sends the outbox exactly once rather than twice, and that on a conflict the user does not lose their text. An audit measures shape, and what we need is a check of behavior.
From this comes the list of what actually has to be thrown at the app. Not the happy path but a hostile environment: a slow network, DNS failure, a timeout after the server has already committed the operation, 401, 409, 429 responses, a quota error, storage eviction, corrupted data, and closing the tab between the local commit and the send. It is in exactly these states that naive code loses or duplicates data, and it is exactly these that an ordinary mouse click never reproduces.
This should be tested at the lowest level that catches the behavior. A unit test for pure logic is cheaper and more stable than e2e; e2e is reserved for the few end-to-end paths that cannot be checked any other way. Splitting by level defines what is checked where - from route matching in units to the manifest and icons on real browsers.
| Level | What to check |
|---|---|
| Unit | Route matching, retry classification, merge policy, serializers |
| Integration | IDB transactions and migrations, outbox compaction, cache plugin |
| E2E | First load, second offline load, pending mutation, reconnect, conflict |
| Lifecycle | Waiting update with two tabs, reload, old chunks, rollback |
| Platform |
| Manifest, icons, display on Chromium, Safari/iOS, Firefox per support matrix |
End-to-end transitions become checkable only at the e2e level, because only a real browser context can go offline and come back. In Playwright this is context.setOffline: the scenario brings up the page, kills the network, reloads, confirms that offline mode is visible, creates a note, sees the pending-sync status, restores the network, and checks that the note is marked synchronized. One such test covers the whole loop - from first load to reconnect.
When a test fails - or, worse, a user reports a ghost that is nowhere in the code - debugging starts with one question: who gave this answer? There are exactly four candidates: the worker, Cache Storage, the browser HTTP cache, or the network. Confusing them is the reason offline bugs feel like magic: you fix the network while the answer was coming from disk all along.
The answer comes from DevTools, the Application tab. The Service Workers pane shows the registration, the script source, the status, and the clients; the Update on reload checkbox is enabled only during development. The Cache Storage and IndexedDB panes show keys, versions, and the user namespace - there you see at once whether a cache is private or shared. In Network you look at the Initiator column, the Size value reading from ServiceWorker, the response headers, and the request mode; a temporary Bypass for network cuts the worker out and shows what comes from the network directly.
There is one subtle state that trips people most often: a tab with no controller and a tab after a hard reload are different things. Hence the classic ghost: everything works for you because your tab is controlled by an old worker, while a fresh visitor gets the new worker and a different code path. It reproduces by checking a tab without a controller and after a hard reload. The conclusion is simple: tests prove the transitions are correct, and debugging answers which of the four layers lied.
// Playwright: scenario outline
await page.goto('/notes');
await expect(page.locator('[data-ready]')).toBeVisible();
await context.setOffline(true);
await page.reload();
await expect(page.getByText('Offline')).toBeVisible();
await page.getByRole('button', { name: 'New note' }).click();
await page.getByLabel('Text').fill('Durable draft');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Pending sync')).toBeVisible();
await context.setOffline(false);
await expect(page.getByText('Synchronized')).toBeVisible();