Tutorial 15: Build an offline-capable application
Objective: a site-inspection notebook that keeps working without a connection: the screen opens offline, findings recorded offline are sent exactly once when the connection returns, and an edit made on stale data is caught as a conflict.
Prerequisites: Tutorials 7 and 8.
You declare three things — which screens may be stored, which writes may wait, and how conflicts are settled. IWC does the rest on the web, Android and iOS: the service worker and caches, on-device storage, the persistent queue, retries with backoff, idempotency keys, conflict detection, background sync and the sync status bar.
offline/
├── api/
│ ├── finding-create.api.iwc
│ ├── finding-resolve.api.iwc
│ └── login.api.iwc
├── assets/
│ └── icon.png
├── database/
│ ├── finding.schema.iwc
│ └── user.schema.iwc
├── styles/
│ └── main.css
├── tests/
│ └── offline.test.iwc
├── ui/
│ ├── findings.ui.iwc
│ └── login.ui.iwc
├── app.iwc
└── package.iwcStep 1: Turn it on
iwc enable pwa offlinewhich writes pwa and offline into app.iwc:
{
"name": "Site Inspections",
"iwc": 1,
"theme": { "tokens": { "color-primary": "#5b4cf5", "color-on-primary": "#ffffff", "color-border": "#e6e6ef", "color-surface": "#f6f4ff", "color-muted": "#5d6073", "radius": "12px", "font-body": "system-ui, sans-serif" } },
"styles": ["styles/main.css"],
"head": { "favicon": "/assets/icon.png" },
"auth": { "user": "user.schema.iwc", "defaultRole": "inspector", "loginPage": "/login" },
"pwa": { "icon": "/assets/icon.png", "shortName": "Inspections" },
"offline": { "pages": "declared", "maxAge": "7d", "status": "auto" }
}"pages": "declared" stores only screens that say "offline": "cache". maxAge is how long a stored screen may be shown.
Step 2: Writes that may wait
{
"name": "Record finding",
"method": "POST",
"path": "/findings",
"operation": "db.create",
"resource": "finding.schema.iwc",
"request": {
"payload": {
"site": { "type": "string", "required": true, "maxLength": 80 },
"note": { "type": "string", "required": true, "maxLength": 2000 },
"severity": { "type": "string", "enum": ["ok", "minor", "major"] }
}
},
"response": { "status": 201, "fields": { "id": "string", "site": "string" } },
"security": { "auth": true },
"offline": { "mode": "queue", "label": "New finding" }
}{
"name": "Update finding",
"method": "PATCH",
"path": "/findings/:id",
"operation": "db.update",
"resource": "finding.schema.iwc",
"request": { "payload": { "note": { "type": "string", "maxLength": 2000 }, "resolved": { "type": "boolean" } } },
"response": { "fields": { "id": "string", "resolved": "boolean" } },
"security": { "auth": true },
"offline": { "mode": "queue", "conflict": "manual", "label": "Update finding" }
}offline | Meaning |
|---|---|
"mode": "queue" | Made without a connection, the write is kept on the device and sent later, exactly once |
"conflict": "manual" | If the record changed on the server after the screen was opened, the person chooses Keep mine or Discard mine |
"label" | The name in the device's list of waiting changes |
Writes without offline need a connection and say so. Some writes can never be queued — sign-in, uploads, payments — and iwc check refuses them.
Step 3: A screen that opens offline
{
"name": "Findings",
"route": "/",
"auth": "required",
"offline": "cache",
"title": "My findings",
"sections": [
{
"type": "section",
"className": "page",
"layout": { "direction": "column", "gap": "20px" },
"elements": [
{
"type": "row",
"layout": { "justify": "between", "align": "center", "wrap": true, "gap": "8px" },
"elements": [
{ "type": "text", "variant": "h1", "content": "My findings" },
{ "type": "button", "label": "Changes on this device", "variant": "secondary", "action": "sync" }
]
},
{
"type": "column",
"className": "panel",
"elements": [
{ "type": "text", "variant": "h3", "content": "Record a finding" },
{
"type": "form",
"inputs": [
{ "name": "site", "type": "text", "label": "Site", "required": true },
{ "name": "note", "type": "textarea", "label": "What did you find?", "required": true },
{ "name": "severity", "type": "select", "label": "Severity", "value": "ok", "options": [{ "label": "OK", "value": "ok" }, { "label": "Minor", "value": "minor" }, { "label": "Major", "value": "major" }] }
],
"submit": { "api": "finding-create.api.iwc", "label": "Save finding", "success": { "reset": true, "reload": true } }
}
]
},
{
"type": "stack",
"layout": { "direction": "column", "gap": "10px" },
"data": { "source": "DATABASE.findings", "columns": ["site", "note", "severity", "resolved", "updatedAt"], "sort": { "field": "updatedAt", "order": "desc" } },
"item": {
"type": "row",
"className": "finding",
"layout": { "align": "center", "gap": "12px" },
"elements": [
{ "type": "column", "className": "grow", "elements": [
{ "type": "text", "variant": "label", "content": "=concat(site, ' · ', severity)" },
{ "type": "text", "variant": "caption", "bind": "note" }
] },
{ "type": "button", "label": "Mark resolved", "variant": "secondary", "action": "api", "api": "finding-resolve.api.iwc", "value": { "resolved": true }, "success": { "reload": true }, "visible": "=!resolved" },
{ "type": "badge", "content": "Resolved", "visible": "=resolved" }
]
},
"empty": { "type": "text", "content": "No findings yet." }
}
]
}
]
}"offline": "cache"lets this signed-in screen be stored — privately, per person, wiped on sign-out.- The
syncbutton opens Changes on this device. - A queued form says "Saved on this device. It will be sent when you're back online." and never pretends it reached the server.
The stylesheet uses html[data-online="false"], which IWC sets while offline:
.page { max-width: 720px; margin: 0 auto; padding: 40px 16px; }
.panel, .finding { padding: 16px; border: 1px solid var(--color-border); border-radius: var(--radius); }
.grow { flex: 1; }
.iwc-form, .iwc-field { display: flex; flex-direction: column; gap: 8px; }
html[data-online="false"] .page::before { content: "Offline — changes are saved on this device"; display: block; margin-bottom: 12px; color: var(--color-muted); }Step 4: Exactly once and conflicts — tested
{
"name": "offline",
"cases": [
{ "name": "the service worker is served", "request": { "path": "/sw.js" }, "expect": { "status": 200 } },
{ "name": "the findings screen renders for its inspector", "as": { "email": "ira@example.com", "password": "ira-pass-1234" }, "request": { "path": "/" }, "expect": { "status": 200, "contains": ["Block A, roof", "Changes on this device"] } },
{
"name": "a queued write is created once",
"as": { "email": "ira@example.com", "password": "ira-pass-1234" },
"request": { "method": "POST", "path": "/api/findings", "headers": { "Idempotency-Key": "device-key-0001" }, "body": { "site": "Block B, lift", "note": "Inspection certificate expired.", "severity": "major" } },
"expect": { "status": 201 },
"save": { "findingId": "id" }
},
{
"name": "the same write sent again after a lost reply is answered from the first run",
"as": { "email": "ira@example.com", "password": "ira-pass-1234" },
"request": { "method": "POST", "path": "/api/findings", "headers": { "Idempotency-Key": "device-key-0001" }, "body": { "site": "Block B, lift", "note": "Inspection certificate expired.", "severity": "major" } },
"expect": { "status": 201, "body": { "id": "{{findingId}}" } }
},
{
"name": "an edit made on an out-of-date screen is a conflict",
"as": { "email": "ira@example.com", "password": "ira-pass-1234" },
"request": { "method": "PATCH", "path": "/api/findings/f-1", "headers": { "X-IWC-Base": "2000-01-01T00:00:00Z" }, "body": { "resolved": true } },
"expect": { "status": 409, "contains": ["edit_conflict"] }
},
{
"name": "an edit made on a current screen goes through",
"as": { "email": "ira@example.com", "password": "ira-pass-1234" },
"request": { "method": "PATCH", "path": "/api/findings/f-1", "body": { "resolved": true } },
"expect": { "status": 200, "body": { "resolved": true } }
}
]
}- Every queued write carries an
Idempotency-Key. Cases 3 and 4 send the same write twice, as a phone does when the reply was lost: the second gets the first one's answer, and only one finding exists. - A queued write remembers when its screen was loaded (
X-IWC-Base). Case 5 edits with an old base: the server refuses with409 edit_conflict, and the device shows the choice.
iwc test ✓ offline › the service worker is served
✓ offline › the findings screen renders for its inspector
✓ offline › a queued write is created once
✓ offline › the same write sent again after a lost reply is answered from the first run
✓ offline › an edit made on an out-of-date screen is a conflict
✓ offline › an edit made on a current screen goes through
6 passed, 0 failedStep 5: Try it in a browser
iwc dev --open, sign in asira@example.com/ira-pass-1234, and open the findings screen once.- In Chrome DevTools → Network, choose Offline. Reload: the screen still opens.
- Record a finding. It is saved on the device; the status bar says how many changes are waiting.
- Go back online. The finding is sent and the list re-renders.
DevTools → Application shows the manifest, the service worker, Cache Storage and IndexedDB (iwc-offline).
On Android and iOS
The same declarations work in native apps: SQLite on the device, WorkManager on Android, BGTaskScheduler on iOS, stored screens encrypted with keys in the Android Keystore or iOS Keychain. See Offline & installable apps.
Source: apps/marketing/content/docs/tutorials/offline.md