Skip to content
ONIWC
iwc 0.1.0

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.

Project files
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.iwc

Step 1: Turn it on

Terminal
iwc enable pwa offline

which writes pwa and offline into app.iwc:

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

api/finding-create.api.iwc
{
  "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" }
}
api/finding-resolve.api.iwc
{
  "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" }
}
offlineMeaning
"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

ui/findings.ui.iwc
{
  "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 sync button 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:

styles/main.css
.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

tests/offline.test.iwc
{
  "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 with 409 edit_conflict, and the device shows the choice.
Terminal
iwc test
Output
  ✓ 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 failed

Step 5: Try it in a browser

  1. iwc dev --open, sign in as ira@example.com / ira-pass-1234, and open the findings screen once.
  2. In Chrome DevTools → Network, choose Offline. Reload: the screen still opens.
  3. Record a finding. It is saved on the device; the status bar says how many changes are waiting.
  4. 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.

Next: Build and deploy a production application.

Source: apps/marketing/content/docs/tutorials/offline.md