# Building a PWA That Feels Like a Native iOS App in 2026

> Source: <https://segar.me/blog/posts/pwa_native_ios.html>
> Published: 2026-11-12 00:00:00+00:00

# Building a PWA That Feels Like a Native iOS App in 2026

I have a native iOS app, [EPLog](https://segar.me/eplog.html), that logs my electrophysiology cases. This year I ported it to the web as a progressive web app, and then added a feature the iOS app never had: a scribe. I've used [Doximity Scribe for clinic visits](https://segar.me/blog/posts/doximity.html) and [precharting](https://segar.me/blog/posts/doximity_precharting.html), but procedure notes follow a fixed structure, and I wanted one built around how I dictate them. I dictate a procedure into my phone, the server transcribes it, Claude pulls out the structured fields, and code assembles the procedure note and discharge summary. On the computer, the same dictation is waiting, ready to copy.

I built the PWA as the compromise version, and now I use it instead of the native app. It installs to the home screen, opens full screen, has a floating tab bar, slides between pages like UIKit, works offline, and updates on `git push` with no App Store review.

The scribe makes a good test case because it hits the four things PWAs handle worst on iOS: the microphone, backgrounding, flaky hospital Wi-Fi, and syncing between devices.

**A note on data:** every dictation, transcript, and note in this post is fictional. No PHI appears in the text, screenshots, or code. If you build something like this for real patient data, the compliance questions (BAAs with your speech-to-text vendor, LLM provider, object storage, and host, plus your institution's policy) come before any of the code below.

## The Stack

One user, one container, one database file:

- **SvelteKit** with`adapter-node` serves the UI and the API from the same process.
- **SQLite** through`better-sqlite3` and Drizzle, in WAL mode.
- **Litestream** streams every write to Cloudflare R2.
- **Dokploy** builds and runs the container on a small VPS, with Traefik in front for TLS.
- **A service worker** caches the app shell.**IndexedDB** holds anything that has not reached the server yet.
- **ElevenLabs Scribe v2 Medical** for speech-to-text, and**Claude Haiku** for field extraction.

A single-user app has no use for Postgres, Redis, or a job queue. Each service I skip is one less thing to fail at 7 a.m. before a case.

## Part 1: Deploying to Dokploy

### One Compose File, Routed by Traefik Labels

[Dokploy](https://dokploy.com) is a self-hosted Heroku: point it at a Git repo, and it builds and runs on every push. I covered the server setup in [Migrating Static Sites to Dokploy with Traefik and Let's Encrypt](https://segar.me/blog/posts/dokploy.html), so this section sticks to what changes for an app with a database. I use a **Compose** service rather than a plain Dockerfile service, because Compose lets me declare the volume and the routing in the repo instead of clicking them into a dashboard. The file defines one service, a named volume mounted at `/data` for the database and images, and Traefik labels for the host, the HTTPS entrypoint, and the Let's Encrypt resolver.

Things that cost me time:

- **Pick one place for routing.** If you declare Traefik labels, don't also add the domain in Dokploy's Domains tab. You get two routers for the same host and confusing certificate behavior.
- **`${VAR:?message}` stops the deploy with your message** when a required secret is missing. I prefer that to an app that boots and then 500s on login.
- **SvelteKit needs `ORIGIN`** set to the exact URL you open, or its CSRF check rejects every form POST in production while dev works fine.
- **Behind Cloudflare and Traefik, the client IP is not the last hop.** With a proxied DNS record, the real client sits second from the right in`X-Forwarded-For` . adapter-node reads that through`ADDRESS_HEADER=X-Forwarded-For` and`XFF_DEPTH=2` . Get it wrong and a login lockout locks out Cloudflare instead of the attacker.
- **Add a `HEALTHCHECK`** that hits a real endpoint. Dokploy shows container health, and a container that is "running" but can't answer HTTP is the failure you want to see.

### SQLite in Production

For one user, SQLite is the fastest database I have deployed, because no network sits between the app and the data. I set four pragmas when the app opens the file:

``` js
const sqlite = new Database(path);
sqlite.pragma('journal_mode = WAL');   // readers don't block the writer; Litestream requires it
sqlite.pragma('synchronous = NORMAL'); // safe in WAL mode, much faster than FULL
sqlite.pragma('foreign_keys = ON');    // off by default, per connection
sqlite.pragma('busy_timeout = 5000');  // wait for a lock instead of throwing SQLITE_BUSY
const db = drizzle(sqlite, { schema });
migrate(db, { migrationsFolder: migrationsDir });
```

Drizzle runs migrations at startup, so a deploy that changes the schema needs no separate step. `better-sqlite3` is synchronous, which sounds wrong in Node until you notice that a single-row query takes microseconds and you no longer need `await` inside transactions.

The constraint you accept: **one container, one writer.** Never scale the service to two replicas. Two processes writing the same SQLite file over a Docker volume is how you corrupt it.

### Litestream for Backups and Disaster Recovery

The volume survives redeploys. It does not survive the VPS dying. [Litestream](https://litestream.io) runs as the container's parent process, ships WAL changes to S3-compatible storage within seconds, and restores the database on an empty volume:

``` bash
#!/bin/sh
set -eu
if [ -n "${LITESTREAM_REPLICA_URL:-}" ]; then
  if [ ! -f "$DATABASE_PATH" ]; then
    litestream restore -config /app/docker/litestream.yml \
      -if-db-not-exists -if-replica-exists "$DATABASE_PATH"
  fi
  exec litestream replicate -config /app/docker/litestream.yml -exec "node /app/build"
fi
exec node /app/build
```

With `-exec`, Litestream starts the app as its child and keeps replicating until the app exits, so the app can't write while replication is down. Moving to a new server is: create the service, set the same environment variables, deploy. The entrypoint finds an empty volume and pulls the database back from R2.

The `litestream.yml` reads the bucket URL, R2 endpoint, and keys from environment variables, so the secrets stay in Dokploy. A daily snapshot kept for 30 days sets how far back a point-in-time restore can go.

Scope the R2 token to that one bucket with Object Read & Write only. If someone breaks into the container, the token reaches nothing else.

### Know Which Build Is Live

A git pre-commit hook bumps the patch version in `package.json` on every commit. The build bakes it in, and `GET /api/health` returns `{"ok":true,"version":"0.0.36","built":"..."}`. After a push, I check that number instead of guessing whether Dokploy finished. The service worker names its cache with the same version.

## Part 2: Looking Native

### Decide the Shell Before First Paint

The app has two looks: an iOS-style UI for the installed app and phones, and a denser web UI for a desktop browser. Choosing in a Svelte component means a flash of the wrong layout. A ten-line blocking script in `<head>` decides first. It checks `matchMedia('(display-mode: standalone)')` (plus `navigator.standalone`, which older iOS versions use instead) and the window width, then sets a class on `<html>` and a cookie.

The cookie lets the server render the right defaults on the next request. The scribe uses it: on the phone, it opens on a blank dictation ready to record, and on the computer it opens the newest dictation ready to copy.

### The Head Tags That Still Matter on iOS

```
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0B6E75" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0E1214" media="(prefers-color-scheme: dark)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="EPLog" />
<link rel="manifest" href="https://segar.me/manifest.webmanifest" />
<link rel="apple-touch-icon" href="https://segar.me/icons/apple-touch-icon-v2.png" />
```

- **`viewport-fit=cover`** lets the page draw under the notch and home indicator. Without it you get white bars above and below the app.
- **Version the touch icon's filename.** iOS holds on to the home-screen icon for weeks. Replacing the file at the same URL changes nothing;`-v2.png` works.
- **Fill in the manifest.**`"display": "standalone"` , a sensible`start_url` (mine opens straight to the case list), and a maskable icon. A`share_target` also lets Android and desktop share images into the app.

### Use Apple's Colors, Font, and Type Scale

To look like iOS, copy iOS's values. I defined UIKit's system colors as CSS variables (`systemGroupedBackground` is `#f2f2f7`, the separator is `rgba(60, 60, 67, 0.29)`), redefined them for dark mode under `prefers-color-scheme`, and set the font stack to `-apple-system, BlinkMacSystemFont, system-ui`.

With 17px body text, 34px large titles, uppercase 13px section headers, and grouped lists on a gray background with hairline separators, you'd struggle to tell a screenshot from the Settings app. Keep tap targets at 44px or larger.

### Respect the Safe Areas

Give each fixed element `env(safe-area-inset-*)` padding. My page container pads the top by `env(safe-area-inset-top)` for the status bar, and the bottom by the tab bar height plus `env(safe-area-inset-bottom)` for the home indicator.

Use `dvh`, not `vh`, for anything sized to the screen. `100vh` on iOS is the height with the toolbar hidden, so bottom sheets end up behind it.

### A Liquid Glass Tab Bar

iOS 26 moved the tab bar off the bottom edge into a floating, translucent capsule, and a flat bar pinned to the bottom now looks like an app nobody has updated. `backdrop-filter` gets you most of the way:

```
.tabbar {
  position: fixed; left: 12px; right: 12px;
  bottom: max(10px, calc(env(safe-area-inset-bottom) - 12px));
  border-radius: 999px;
  background: rgb(255 255 255 / 0.55);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  backdrop-filter: blur(20px) saturate(180%);
  box-shadow:
    inset 0 1px 0 rgb(255 255 255 / 0.7),  /* specular rim */
    0 8px 28px rgb(0 0 0 / 0.14);          /* float off the content */
}
```

`saturate(180%)` sells the glass: colors scrolling underneath get richer instead of washing out. The inset white line on top is the specular highlight. Swap the tint and rim for dark mode, and the `@supports` fallback keeps the text readable where blur isn't available.

### Remove the Web Tells

Each fix takes one line of CSS:

```
/* No rubber-band bounce that shows the page behind the app */
@media (display-mode: standalone) { html, body { overscroll-behavior: none; } }

/* No double-tap zoom (or its delay) on controls; pinch zoom still works */
a, button, input, select, textarea, label, summary { touch-action: manipulation; }

/* No gray flash on tap */
body { -webkit-tap-highlight-color: transparent; }

/* Long-pressing a button shouldn't select its label or open a callout menu */
button, .btn, .tabbar, h1, h2 { user-select: none; -webkit-touch-callout: none; }

/* Show the pressed state before the server answers */
.btn:active { opacity: 0.55; transition: none; }

/* Under 16px, iOS zooms the page when an input gets focus */
input, select, textarea { font-size: 16px; }
```

Two more in markup: `enterkeyhint="search"` or `"done"` changes the keyboard's return key, and `inputmode="numeric"` gets the number pad without `type="number"`'s quirks.

### Push and Pop Transitions

Navigation gives a web app away fastest. An iOS app slides the new screen in from the right and pushes the old one back. Safari now supports the View Transitions API, so you can do this with no animation library. In SvelteKit's `onNavigate` hook, I work out the direction (deeper path is a push, back to a parent or the browser back button is a pop, a tab switch is a crossfade), set it as a `data-nav` attribute on `<html>`, and wrap the navigation in `document.startViewTransition()`. CSS keyframes on `::view-transition-new(root)` and `::view-transition-old(root)` do the sliding, with a 0.32 s duration and a `cubic-bezier(0.2, 0.9, 0.3, 1)` curve that feels close to UIKit.

Moving the old page 28% left and dimming it while the new one slides in gives you UIKit's parallax. Turn the animation off under `prefers-reduced-motion`. Giving the tab bar its own `view-transition-name` lifts it out of the page snapshot, so it doesn't slide away with the page.

## Part 3: Behaving Native

Behaving native takes more code than looking native. The app has to keep every dictation, work with no signal, and survive what iOS does to web apps in the background.

### A Service Worker That Caches the Shell and Nothing Else

I cache the built JS and CSS, icons, an offline page, and one static offline form. Pages, API responses, and images go to the network, because I don't want a cache showing me yesterday's case list.

The cache name includes the build version, so each deploy installs a fresh shell and `activate` deletes the old one. On install, fetch the shell with `cache: 'reload'`, or the browser's HTTP cache can hand the service worker last month's icon. The fallback for page loads looks like this:

``` js
const reachable = (r) => r.status < 500; // the app answered, not a proxy error page
const offline = async () => (await caches.match('/offline.html')) ?? Response.error();

if (req.mode === 'navigate')
  e.respondWith(fetch(req).then((r) => (reachable(r) ? r : offline())).catch(offline));
```

`reachable()` is the trick I wish I had known first. Behind Cloudflare, a down origin doesn't make `fetch` throw. Cloudflare returns its own 502 or 522 page, the fetch "succeeds," and your offline fallback never runs. Treating 5xx on navigation as offline fixes it.

### Tell the User When a New Version Is Ready

A home-screen app keeps running the JavaScript it launched with until you close it from the app switcher, which can take days. After a deploy, a new service worker installs and takes control, but the open page keeps running old code. I listen for the swap and offer a reload. I also call `registration.update()` each time the app returns to the foreground, since I tend to deploy while it sits in the background. One catch: the first install also fires `controllerchange`, so check whether the page already had a controller before you show the prompt.

The banner never appears while a recording is running, because a reload would end it.

### Recording Audio on iOS Without Losing It

`MediaRecorder` works in Safari and in installed web apps, with four caveats.

**Pick the format the browser supports.** Safari records `audio/mp4` (AAC); Chrome and Firefox prefer WebM/Opus. Loop through the candidates with `MediaRecorder.isTypeSupported()` and use the first that passes.

**Write the audio to disk as you record.** iOS kills web pages in the background whenever it wants the memory. If the only copy of an eight-minute dictation is a `Blob[]` in memory, one stray swipe loses it. `recorder.start(5000)` fires `dataavailable` every five seconds, and each slice goes into IndexedDB under a compound key:

``` js
const recId = crypto.randomUUID();
putAudio({ id: recId, partial: true, mime, created: Date.now(), blob: new Blob([]) });
recorder.ondataavailable = (e) => {
  if (!e.data?.size) return;
  chunks.push(e.data);                  // in-memory copy, used on a clean stop
  putChunk(recId, seq++, e.data);       // { recId, seq, blob } keyed by [recId, seq]
};
recorder.start(5000);
```

On the next launch, any item still marked `partial` is a take the page died in. Its slices are read with `IDBKeyRange.bound([recId, 0], [recId, Infinity])`, sorted, and joined with `new Blob(parts, { type: mime })`. Concatenating `MediaRecorder` slices in order produces the same file as one uninterrupted recording, cut short, so the server sees a normal upload.

**Keep the screen on, because iOS stops the mic when it locks.** `navigator.wakeLock.request('screen')` keeps the display awake while recording, and it has to be re-requested on every return to the foreground because the system releases it when the page is hidden. If the phone locks anyway, iOS cuts the microphone, and in my testing it often doesn't come back even when the track reports `readyState: 'live'`. So I don't trust the track. In the `visibilitychange` handler, I note the time when the page goes hidden (and save any unsaved edits, since `pagehide` and `beforeunload` are unreliable on iOS). When the page comes back, if it was gone for more than two seconds, I stop the recorder, save everything up to the lock, and tell the user the mic stopped.

The next take appends to the same transcript, so you resume with one tap.

**Handle a call taking the mic.** A phone call or another app grabbing audio ends the track, and `track.onended` stops the recorder and saves what it captured.

**Make the screen-on requirement livable.** Keeping the screen on means a bright phone on the desk. A "Dim" button covers the screen in a full-screen black element. On an OLED iPhone, black pixels are off, so the display stays awake at almost no battery, and a tap anywhere brings the controls back.

### Idempotent Uploads, or How to Retry Without Duplicates

The phone uploads each finished take, and the audio stays in IndexedDB until the server confirms it has the transcript. Bad signal means retries, and retries on a "transcribe and append" endpoint mean the same paragraph pasted into the note twice.

The phone gives each upload its own id. The server records that id in a `dictation_uploads` table, in the same SQLite transaction that appends the text to the dictation. If the id is already in the table, the transaction returns the dictation unchanged.

Before calling the speech-to-text API at all, the endpoint checks whether that `upload_id` already has text and returns it. A retry after a success costs neither a duplicate paragraph nor a second transcription bill.

The client splits errors into **permanent** (bad audio, deleted dictation: drop the local copy and say so) and **transient** (network, 5xx: keep it and retry). Pending uploads retry on page load and on the `online` event. Safari has no Background Sync API, so a queued upload waits until you next open the app.

The offline New Case form follows the same rule: each case gets a `crypto.randomUUID()` on the device, sits in IndexedDB until the server confirms it, and the server treats a repeated id as already saved. A banner shows how many are waiting.

### Syncing Between Phone and Computer Without a Sync Engine

I dictate on the phone and copy the note on the computer. I looked at CRDTs and local-first sync libraries and decided I didn't need them. One person edits one dictation at a time, so last write wins is correct, provided neither device clobbers text someone is typing.

- **Debounced saves.** Edits save 800 ms after the last keystroke, and again the moment you leave the page.
- **Polling.** Every 15 seconds, and on every return to the foreground, the client fetches the list of dictations with their`updatedAt` . If the open dictation changed on the server, it refetches it.
- **Never overwrite local work.** The client skips a refresh if the local draft has unsaved changes or your cursor sits in a text field.

That's about 40 lines. WebSockets or server-sent events would cut the latency, but I take longer than 15 seconds to walk from the procedure room to a computer, and polling survives proxies, sleeping laptops, and dropped connections with no reconnect logic.

## Part 4: The Scribe Pipeline, Briefly

I keep the LLM out of the prose.

1. **Speech-to-text.** ElevenLabs Scribe v2 Medical, with a list of keyterms (device manufacturers, lead models, sheath names, drugs) sent with each request. Medical terms are where general dictation fails; I wrote about that in[the local Whisper post](https://segar.me/blog/posts/local_whisper_dictation.html) . The provider sits behind a small interface, so swapping in OpenAI is an environment variable.
2. **Extraction.** One Claude Haiku call with a single tool,`record_procedure` , whose JSON schema comes from the note template: device type, leads, thresholds, sedation, complications. The model fills the schema. It doesn't write prose.
3. **Assembly.** Code renders the note from a plain-text template and the extracted fields. Fixed text never changes from one note to the next, and any field that wasn't dictated renders as`____` with a "Check before using" warning instead of a guess.

Because code writes every sentence, I can test the output. A golden-file test suite renders fictional cases through the templates and diffs each note against the expected text, and the Dockerfile runs `npm test` during the build, so a template regression fails the deploy instead of reaching a chart.

## What Still Doesn't Work on iOS

Five limits still shape the design:

- **No background audio.** Lock the screen and the mic stops. Plan around it: wake lock, save as you go, make resuming one tap.
- **No Background Sync.** Queued work runs when the app is open, not when the network returns.
- **Update lag.** The installed app runs old code until you close or reload it. Build the update prompt early.
- **Icon and splash caching.** Version the filenames.
- **Testing is manual.** Simulator Safari is close but not identical to an installed web app on a device. Test recording, locking, and backgrounding on a real phone, installed to the home screen, on cellular.

## Bottom Line

My checklist, ordered by payoff:

1. `viewport-fit=cover` and safe-area padding everywhere.
2. UIKit's real colors, 17px system font, 44px targets.
3. Kill the web tells: overscroll, double-tap zoom, tap highlight, text selection on chrome, sub-16px inputs.
4. View Transitions for push and pop.
5. A shell-only service worker that treats proxy 5xx as offline, plus an update prompt.
6. Save user input to IndexedDB before the network sees it, and give every upload a client id.
7. One container, SQLite in WAL mode, Litestream to object storage, deployed by Dokploy on `git push` .

I ship several times a day from my laptop, check `/api/health` for the new version, and see "A new version is ready" the next time I open the app on my phone. One codebase runs on both devices, and I haven't opened TestFlight since.
