
TypeScript or nothing
One language from the phone to the database. Here's why every project I take on is TypeScript end to end, and why I stopped apologising for it.
Offline-first PWA architecture, from the service worker outward. Why you design for the network dropping instead of the network being slow, and the caching, cache-warming, and background-sync patterns that make a web app behave like a native one.

There are two kinds of bad network, and most web apps only plan for one of them.
Slow is the one everyone designs for. Slow gets a spinner, a skeleton screen, a loading bar. Slow is polite. It arrives eventually. The other kind is gone: the signal that drops in a lift, a basement, a metal exhibition hall, a train through a tunnel. Gone does not arrive eventually. And an app built for slow, dropped into gone, does the worst thing it can do at the worst moment: it shows an error to a person who was in the middle of something.
A Progressive Web App is what you build when you decide the second kind of network is the one that matters. It is a web app that stops needing the web. Here is how the architecture actually holds together, from the part that does the real work outward.
Strip away the branding and a PWA is three plain capabilities layered on a normal website. It is installable, so it sits on the home screen and opens fullscreen with no address bar. It is offline-capable, so pages it has seen once keep working with the network off. And it is updatable, so it can refresh itself without an app store in the loop.
None of that is a framework. There is no npm install pwa. It is a set of browser platform features you opt into, and the one that carries the whole thing is the service worker.
A service worker is a script that sits between your app and the network, as a programmable proxy. Every request the app makes, for a page, an image, a video, an API call, passes through it first. The worker decides what happens next: answer from a local cache, go to the network, or some blend of the two.
Every request goes through the worker first. The network becomes an enhancement, not a dependency.
That single indirection is what flips a website into an appliance. Once you own the request, "offline" stops being an error state the browser hands you and becomes a decision you make. I don't write service workers by hand anymore; Serwist (the modern successor to Workbox) handles registration, precaching, and the runtime rules, with first-class Next.js support. But the mental model underneath is worth holding onto, because the defaults will bite you.
Here is the part that looks like a config detail and is actually the whole design. When a request hits the worker, which strategy answers it?
| Strategy | What it does | Use it for |
|---|---|---|
| NetworkFirst | Wait on the network, fall back to cache | Fresh-critical data on reliable connections. Rarely the right default. |
| StaleWhileRevalidate | Serve the cache instantly, refresh in the background | The app shell, pages, RSC payloads, images. Your default. |
| CacheFirst | Serve the cache, only touch the network on a miss | Big immutable assets: video, fonts, a brochure PDF. |
Most offline libraries default pages to NetworkFirst, and on a flaky connection that is the single worst choice. It waits on the network before serving the cache, so when the signal stutters, the wait times out and the fallback fires the offline screen, even though the page was sitting in the cache the entire time. The user sees "You're offline" for a second on a page that was always there. They do not care that it recovered a second later. They saw the error.
The fix is to invert the default to StaleWhileRevalidate. Serve the cached copy now, quietly refresh it in the background if the network happens to be up. The offline screen becomes a true last resort instead of a symptom of a slow first byte.
A service worker can only cache what arrives as a finished thing. This is where the rendering model matters more than people expect.
A client-rendered page ships as a near-empty shell plus JavaScript that fetches its own data after it loads. Cache that and, offline, you have cached the note that says "cook this once you find a stove." There is no stove. A server-rendered page arrives as complete HTML with the content already in it, which is exactly what you want to hand a cache. That is one more reason I render on the server by default: the output is flat, static, and cacheable, with no client fetch left to fail when the signal is gone.
A cache only helps with pages the user has already visited. But people don't move through an app in order. Someone opens the home screen, then jumps straight to the one page you never pre-loaded, and if the signal dropped in between, that page was never cached.
So you warm it. A small routine runs once after the worker is active and while the device is still online, and quietly fetches every route in the app, spaced out so it stays gentle on the connection, re-running whenever the device reconnects. After one online load, the whole app is on the device. Every page opens offline, not just the ones the user happened to tap first. For a kiosk or a field tool that gets one good connection before it goes dark, this is the difference between "mostly works offline" and "works offline."
Reading offline is half the job. The other half is what happens when the user does something offline: submits a form, saves a note, places an order.
The pattern that holds up is a queue with a sync. The submit tries the network first. If it goes through, done. If the request never leaves the device, or the server returns a 5xx, the write goes into local storage and the UI tells the user the truth: saved, and it will send itself when you're back online. A small listener flushes that queue on the browser's online event.
The distinction that keeps this honest: only network failures and 5xx hiccups get queued. A 4xx, a validation rejection, does not go in the queue, because retrying input the server already refused can never succeed. Queuing something that will never be accepted is a slow-motion bug. I built exactly this three-layer capture (server, browser queue, and an on-device export as a last resort) for a showroom kiosk that could not afford to lose a single sales lead, and the full build is written up here.
Two smaller pieces finish the appliance.
Installability is a manifest file: name, icons, display: standalone for fullscreen, orientation, theme color. Get it right and the app installs to the home screen and opens with no browser chrome, which is most of what makes a PWA feel native rather than like a bookmarked tab.
Freshness is the trap nobody thinks about until it bites. A fullscreen app can stay open for days without a navigation, and a browser might not notice a new deploy for up to a day. So you poll for a new worker on an interval and reload when one takes control, silently. Deploy from your desk, the installed app updates itself, no app-store review and no reinstall. On first install it deliberately does nothing, because there is no old version to replace.
I'll be straight about the trade, because a PWA is not a native app with better economics. It is a different tool.
Reach for a PWA when the job is content, capture, and presentation that has to survive a bad network: kiosks, field tools, internal apps, anything installed on hardware you don't fully control. It ships as a URL, updates instantly, and runs one codebase everywhere.
Reach for native when you need deep device integration, reliable background work, or rich push, especially on iOS, where PWA support is real but distinctly second-class. If the product lives or dies on notifications and native gestures, one Expo codebase to both stores is the honest answer, not a web app in a trench coat. The architecture here is about making the web reach further, not pretending the gap doesn't exist.
No. A PWA is browser platform features, not a library. You need a manifest and a service worker. A tool like Serwist just spares you writing the worker by hand and pairs cleanly with Next.js.
Partly. Safari supports service workers and offline caching, so reading and cached playback work. But storage limits are tighter and background features are limited, so test on a real iPhone, never just a desktop browser's offline toggle, which lies about what the worker actually does.
On the real device, in airplane mode. A desktop browser's "offline" checkbox does not truly cut the service worker's network, so it will tell you things work that don't. The only test that counts is the one where the radio is actually off.
For the offline-first jobs it suits, no. Serving from cache is a disk read, which is often faster than a native app's first network call. The gap shows up in device integration and background work, not in raw speed.
A PWA is not a lesser native app. It is the web deciding to keep its promises when the network breaks them. The whole craft is in the failure modes: what the app does when the signal drops, when a write can't send, when nobody is standing there to fix it. Own the request with a service worker, cache with the right strategy, warm what you'll need, queue what you can't send yet, and the happy path takes care of itself.
Build it like an appliance. Turn it on, it works. Lose the signal, it still works. If that's the kind of thing you're trying to ship, tell me the constraint you're working inside and I'll tell you whether the web can reach it.
Prabir Singh
Full-stack engineer from Mumbai. Twenty years in sales, now I build products that sell. More about me or start a project.

One language from the phone to the database. Here's why every project I take on is TypeScript end to end, and why I stopped apologising for it.

Express shaped a generation of Node APIs. Here's why my new backends start with Hono instead, and the three places it actually changes how I build.

One Expo codebase ships to iOS and Android without a Mac maze or two separate builds. Here's the pipeline I use, and the one feature that lets me fix a bug without waiting on Apple.