On first launch, WordErase could sit for more than 40 seconds before showing a single tile. The puzzle was right there on the server, the device was online, and still the player stared at nothing. Digging into it taught me a lesson I seem to keep relearning: the improvement came from the order I did the work, not the speed of any one piece of it.
The symptom
WordErase serves one shared puzzle per day, and the app keeps a local library of past days so you can replay them from a calendar. On a brand new install that library is empty. The first time someone opened the app, they were not waiting on today's puzzle; they were waiting on the entire backlog.
The puzzle manager would read the server's manifest, see every day that existed, and try to fill in everything that was missing before it considered itself "ready." The very first thing the player saw was gated behind the very last download. Each individual request was quick. Stacked end to end behind one barrier, a hundred days of history added up to over 40 seconds of blank screen.
How the data is shaped
It helps to know what the app is actually fetching, because the shape of the data is what makes the priority split possible. The server publishes a tiny manifest at /p/puzzles.json, and each day's puzzle is its own small file:
struct PuzzleResponse: Codable {
let last_updated: String
let available_puzzles: [String] // e.g. ["2025-10-26.bin", "2025-10-27.bin", ...]
}
Every entry is a date-named, AES-GCM encrypted blob. Each .bin is laid out as a 12-byte nonce, then the ciphertext, then a 16-byte authentication tag, and the app reassembles it with AES.GCM.SealedBox before decoding the JSON inside. The important property for performance is that the manifest is one small file, and each puzzle is one small independent file. Nothing about today's puzzle depends on yesterday's having been downloaded. That independence is exactly what the original code threw away by treating the whole set as a single unit of work.
My first instinct, and why it was off
My tempting first move was to make the downloads faster: parallelize harder, compress the payloads, tune the networking. I held off, because none of it addresses the real problem. Even an infinitely fast hundred-day download is still a hundred files of work that the player does not need yet. The only puzzle they want in that moment is today's.
So I stopped thinking about throughput and started thinking about priority. What is the one thing that has to be on screen, and what can wait until after it is?
Loading by priority
The launch path now does two distinct things, in order. First, the moment the view appears, it tries to render entirely from disk with no network at all. Second, and only after that, it talks to the server to fetch what is missing.
PuzzleView()
.onAppear {
// 1. Render instantly from cache, no network
puzzleManager.loadTodayPuzzleImmediately()
// 2. Then reconcile with the server in the background
puzzleManager.checkForUpdates { _ in }
}
The cache step has a deliberate fallback. If today's file is already on disk, it decrypts and renders it. If it is not, it does not fail to a blank screen; it loads the most recent puzzle it does have, so the board is never empty while the network step runs:
func loadTodayPuzzleImmediately() {
if let content = getPuzzleContent(for: Date()) {
decodePuzzle(content: content) // instant path
return
}
// Fallback: show the newest cached day rather than nothing
if let mostRecent = downloadedDates.last,
let content = getPuzzleContent(for: mostRecent) {
decodePuzzle(content: content)
}
}
The network step, checkForUpdates, fetches the manifest with a Combine dataTaskPublisher, decodes it into PuzzleResponse, and then makes one decision: is today's file already on disk? If not, it downloads only today's single .bin, re-renders the instant it lands, and hands everything else off to a background queue. I also force the manifest request to ignore the URL cache (reloadIgnoringLocalCacheData), since a stale manifest is the one thing that would quietly hide a brand new daily puzzle.
Being a good citizen to the server
Moving the backfill off the render path solved the user-facing problem, but firing a hundred requests at once just relocates the pain to the server. So the backfill is shaped to stay polite. It runs on a DispatchQueue.global(qos: .utility) queue, well below the UI in priority, and it splits the remaining days into two ordered lists before downloading anything:
- Past days, most-recent-first. The days you are most likely to replay are the ones you just missed, not the ones from months ago, so past puzzles are sorted descending by date and fetched in that order.
- Future days, capped at seven. The manifest can list puzzles ahead of today. The app prefetches a small runway with a
dateComponentscheck (daysAhead <= 7) instead of trying to hoard the whole forward calendar. - A small gap between requests. A
Thread.sleep(forTimeInterval: 0.1)between downloads turns a burst into a trickle, so the device drips the backlog in rather than asking the server for everything at once. - Skip what you already have. Each candidate is checked against
downloadedDatesfirst, and today is skipped because the priority step already handled it.
The player gets today right away. The history fills itself in quietly while they are already playing, and the server only ever sees a gentle, ordered trickle of requests.
A gotcha with dates
One subtlety bit me along the way: comparing dates. The cached list stores midnight-normalized days, but Date() at launch is a precise timestamp. A naive downloadedDates.contains(Date()) almost never matches, so "do I already have today?" kept answering no and re-downloading a file I already had. The fix was to compare on calendar day, with Calendar.current.isDate(_:inSameDayAs:), when deciding what to skip. It is a tiny thing, but it is the kind of off-by-a-timezone bug that quietly undoes a caching layer if you do not notice it.
The result
Subsequent launches are now genuinely instant: today's puzzle decrypts from disk and renders before any network call returns. The very first launch, the worst case where nothing is cached yet, dropped from over 40 seconds to a couple of seconds, just long enough to pull the manifest and one puzzle file. By the time someone has solved their first word, the recent backlog is usually already sitting on disk, so the calendar feels populated almost right away.
What stuck with me was how little of this was about networking. I did not make a single download faster. I changed what I waited for.
Takeaways
- An all-or-nothing barrier turns N independent tasks into one slow task. Render the moment the first essential piece is ready, not when the last optional piece finishes.
- Separate the critical path from the nice-to-have. Today's puzzle is critical; the back catalog is not, and a most-recent-cached fallback means the screen is never blank while you reconcile.
- Background backfill is friendlier when it is ordered by what the user will reach for next, capped, and throttled so it stays a good neighbor to the server.
- Watch your date comparisons. Comparing a calendar day to a precise timestamp will silently defeat a cache.
- Before optimizing how fast you do the work, ask whether the work needs to happen before you show something at all.