Skip to content
Chandler Nguyen
AI6 min read

AI

Offline Mode for My AI App Didn't Need AI. It Needed Boring.

I almost built a clever, AI-specific offline mode for DIALØGUE's iOS app. Then I asked a more boring question — what does iOS already give me? — and the real work turned out to be resisting the urge to be clever. Here's the standard, unglamorous machinery behind "download an episode and listen on a plane," and why boring was the senior choice.

I recently shipped offline downloads for DIALØGUE's iOS app — the part I waved at in my last post as "resilient offline" without ever explaining what that meant. This is the explanation. And it starts with me almost building the wrong thing.

Here's the confession. DIALØGUE is an AI podcast app: you give it a topic or a PDF, and it generates a two-host conversation. So when I started on offline mode, my brain went straight to something AI-shaped. Maybe "offline" meant pre-generating audio on the device. Maybe caching model output, or some clever pipeline that could rebuild an episode without a network. I had a whole mental architecture forming. I almost wrote the design doc that way.

Then I stopped and asked a more boring question: what does iOS already give me for this?

The answer, it turned out, was "almost everything." And the real work of this feature wasn't building something smart. It was resisting the urge to.

The boring question saved me a month

Here's the thing I keep relearning, post after post: complexity is not your friend. Especially when you're one person building on evenings and weekends.

An AI podcast, once it has been generated, is just an audio file sitting at a URL. "Offline" doesn't mean regenerating it. It means downloading that file and playing it later. That isn't an AI problem. It's a problem Apple has been solving since before the App Store existed.

So instead of a clever pipeline, I reached for the most boring, battle-tested tool in the box: a background URLSession. If you haven't met it, the idea is simple — you hand the download to the operating system, and it keeps going even when your app is in the background or has been killed, then relaunches your app to hand you the finished file.

let config = URLSessionConfiguration.background(withIdentifier: "com.yourapp.downloads")
config.sessionSendsLaunchEvents = true

Roughly two lines, and the OS now owns the genuinely hard parts. I didn't write a download engine. I borrowed Apple's.

The same story repeated everywhere I looked:

  • Interrupted download? iOS hands you "resume data" — a small blob that lets a download pick up where it left off instead of starting from zero. I just write it to disk (in the Caches folder, since it's disposable) and pass it back next time.
  • Don't want to burn the user's cellular data? One line: request.allowsCellularAccess = !wifiOnly. The OS then waits for WiFi on its own. I don't monitor connectivity. I don't poll anything.
  • Need to show how much space downloads are using? ByteCountFormatter turns a byte count into "23.4 MB" with the correct localized units. I didn't write that either.

I want to be honest about how good this feels, because it cuts against the instinct. Looking back, the right move wasn't cleverer code. It was admitting this was a solved problem and borrowing the work instead of trying to out-think it.

But "boring" did not mean "easy"

Now I have to admit something, because if I told you standard practices made this trivial, I'd be lying — and any iOS developer reading this would catch me in about ten seconds.

Standard is not the same as trivial. The boring tools are boring because they're well-worn, not because they're frictionless. Here are the things that actually cost me time, in case the list saves you some:

1. The temp file disappears while you're looking at it. When a background download finishes, iOS calls you back and points at the file in a temporary location. The catch nobody mentions: that file is deleted the instant your callback returns. You have to move it somewhere permanent synchronously, right there in the function, before you do anything else. It gets more awkward, because that callback runs off the main thread and only knows which download finished, not which episode it belongs to in my app's terms. So the code does an ugly little two-step: move the file to a temporary name immediately, then hop to the main thread to rename it once it knows what it is. Not pretty. But the alternative is a file that's gone before you can save it.

2. Background sessions silently do nothing in the simulator. I spent an embarrassing stretch convinced my code was broken. It wasn't. Background URLSession simply doesn't behave reliably in the iOS Simulator — no error, no crash, nothing downloads. The fix is ugly and I'll own it:

#if DEBUG
// Use a default session in the simulator — background sessions can silently fail
session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
#else
session = URLSession(configuration: .background(withIdentifier: identifier), delegate: self, delegateQueue: nil)
#endif

That #if DEBUG is not elegant. But pretending the simulator behaves like a real device would have cost me far more than admitting it doesn't.

3. Swift 6 made me say exactly who runs on which thread. The download callbacks arrive on a background thread. My UI state — the little progress rings, the green "Downloaded" checkmark — has to update on the main thread. Swift 6's stricter concurrency checking refused to let me be sloppy about that boundary. I ended up with a cleanup commit literally titled "resolve Swift 6 actor-isolation warnings." Annoying in the moment, correct in hindsight. The compiler was right and I was wrong.

4. The server doesn't always name the file cleanly. Audio comes back sometimes as audio/mpeg, sometimes as an m4a, occasionally something else. So there's a small, dull function that works out the file extension from the response's content type, falls back to the URL, and defaults to .mp3. Not glamorous. Necessary.

None of these are AI problems. Every one is a "how computers actually work" problem. That's the tax on the boring road. It's real — but I'd pay it every single time over the cost of maintaining something clever I invented myself.

What I deliberately did NOT build

I think the most useful part of this whole feature is the list of things I didn't do:

  • I didn't write a download engine. (Background URLSession.)
  • I didn't write a connectivity monitor. (allowsCellularAccess.)
  • I didn't invent a resume protocol. (OS resume data.)
  • I didn't build an AI-specific anything. (It's a file. It downloads.)

What I did spend care on was small and unglamorous: a tiny queue so no more than three downloads run at once and the rest wait their turn; persisting episode titles so the storage screen shows real names after a cold launch instead of "Episode, Episode, Episode"; sorting that screen largest-file-first, so when you're trying to free up space the worst offenders sit at the top. And I pulled the actual decisions — how many slots are free, how to sort the list — out into plain functions with no URLSession attached, so I could unit-test them properly. Boring. Testable. Boring because testable.

The lesson, for roughly the hundredth time

I've now written some version of this post about rebuilding my website, about a $54 API bill, about rebuilding this very app. The lesson keeps being the same one, and I keep needing to relearn it: the clever solution is usually the expensive one. "What does the platform already give me?" — that's the most valuable question I can ask before writing a line of code.

For a solo builder especially, every clever thing you invent is a thing you alone have to maintain when it breaks at the worst possible time. Every boring, standard thing you borrow is maintained by a company with thousands of engineers behind it. From my experience, that trade is almost always worth making — and honestly, the discipline to make it is harder than the cleverness it replaces. I might be wrong about exactly where the line sits, though. There are surely problems where the platform hands you nothing and you have to build the hard thing yourself — I just keep finding that's rarer than my instinct insists.

A few of the people using DIALØGUE can now download an episode, get on a plane, and listen with no signal. It feels like a meaningful feature. The code behind it is almost aggressively unremarkable. I've made my peace with that. I think that's the job.

Do you do this too — catch yourself reaching for the clever version when the boring one was sitting right there? Or am I just slow to learn the same lesson over and over? I'd genuinely like to hear how other people fight this instinct.

Cheers, Chandler