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

I have a native iOS app, EPLog, 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 and precharting, 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.

How a request reaches the app The installed iPhone app and a laptop browser connect through Cloudflare and Traefik to a single Docker container deployed by Dokploy. Inside, Litestream runs as the parent process of the SvelteKit app, which reads and writes a SQLite database on a /data volume. Litestream streams the database's write-ahead log to Cloudflare R2. The app calls ElevenLabs for speech-to-text and Claude Haiku for field extraction. iPhone, installed PWA service worker + IndexedDB Laptop browser desktop layout, same app Cloudflare proxied DNS · client IP in X-Forwarded-For Traefik routes by Compose labels · Let's Encrypt TLS ONE CONTAINER · DOKPLOY BUILDS ON GIT PUSH Litestream, parent process (replicate -exec) SvelteKit (adapter-node) UI + API · better-sqlite3 + Drizzle /data volume SQLite (WAL mode) + images WAL streamed in seconds Cloudflare R2 backups + daily snapshots ElevenLabs · Haiku speech-to-text · extraction
The whole deployment. Both devices hit the same container through Cloudflare and Traefik. Litestream wraps the app process and copies every database write to R2, so a new server can restore from an empty volume.

Part 1: Deploying to Dokploy

One Compose File, Routed by Traefik Labels

Dokploy 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, 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:

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 runs as the container's parent process, ships WAL changes to S3-compatible storage within seconds, and restores the database on an empty volume:

#!/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="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/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:

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:

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 life of a dictation On the phone: record with MediaRecorder, save each five-second slice to IndexedDB, stop and join the slices, then upload with a client upload_id. If the phone locks, a call takes the mic, or iOS kills the page, the saved slices are rebuilt into a take on the next launch. If the upload fails, the audio stays on the phone and retries. On the server: if the upload_id was already seen, the saved text is returned without transcribing again. Otherwise the audio is transcribed and appended in one transaction with the upload_id. Once the server confirms, the phone deletes its local copy. ON THE PHONE WHAT CAN GO WRONG 1. Record MediaRecorder, start(5000) 2. Save each 5 s slice IndexedDB, keyed [recId, seq] 3. Stop join the slices into one Blob 4. Upload with an upload_id audio stays in IndexedDB for now Page dies mid-take lock, call, or iOS kill; next launch rebuilds it No signal or 5xx audio stays on phone; retry on open or online ON THE SERVER 5. Seen this upload_id? checked before speech-to-text yes Return saved text no second transcription no 6. Transcribe and append text + upload_id in one transaction 7. Server confirms phone deletes its local copy
Every dictation, from the microphone to the database. The audio stays on the phone until the server confirms it has the text, and the upload_id makes every retry safe: a duplicate returns the saved text instead of pasting the paragraph twice.

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. 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.

← Back to Blog