By Dan · March 29, 2026
The Death of a Thousand Small UI Improvements
Saturday morning, 10 AM. Coffee. Kitchen counter. A small idea:
“Let me just clean up how the milestone view animates after each kilometer split. 20-minute job. Done by lunch.”
It is now Sunday at 2:14 AM. The milestone animation looks roughly the same as it did Saturday morning. I have rewritten it three times. The current implementation has a bug where the milestone overlay sticks for an extra 1.5 seconds when the user hits a split at an interval boundary. I cannot figure out why. I have made the workout completion view briefly flash through the main view when finishing a run — a regression I introduced two hours ago by changing the dismissal timing.
The cat is asleep. I should be asleep.
In the morning, I revert.
This is the pattern that defines indie development on platforms you can’t fully debug. We want to write it down because every indie dev we know has lived through it. Nobody puts it in their case studies.
The pattern
Friday night or Saturday morning. You sit down with the project. No specific goal — there’s a list of small things, none urgent. You pick the smallest one:
- “Polish the milestone overlay.”
- “Tighten the spacing on the heart rate icon.”
- “Make the loading phrases a bit slower.”
You start. Looks easy. Open the relevant view. Change a value. Look at it in the simulator. Hmm, doesn’t quite look right. Tweak another value. Now it looks worse. Revert that. Tweak a third.
An hour later you’ve made three changes, each of which fixed one thing and broke another. You can’t go back to the original because you’ve forgotten exactly what you changed. You don’t have a commit for “before” because you didn’t think this would take an hour.
Two hours later you’re learning something about SwiftUI animation timing that you didn’t know before.
Three hours later you’re debugging why an unrelated view flashes when you push a workout-finished screen.
Four hours later you’re not sure what you started with anymore.
Five hours later you’re either:
- Done, with a real improvement. (Rare.)
- Done, with no measurable improvement. (Common.)
- Not done, but committed enough that you’ll keep going next weekend. (Dangerous.)
Then Monday morning you have a full-time job to go to.
Concrete examples from our commit history
Not apocryphal. In our repo. The honest record:
The “centiseconds time display” arc
- Watch UI: MM:SS.cs time format with hours badge, HR-synced heartbeat
- Watch UI: smooth pulsing heart, 30 Hz centiseconds, 3 s pace throttle
- Heart pulse: HR-responsive sine via
TimelineView, smaller scale range - Re-enable centiseconds time display, bump heart-rate icon spacing
- (partial revert: classic HH:MM:SS by default +
useCentisecondsTimeDisplaytoggle)
The idea: show centiseconds during a workout. Looks more “runner-watch” — like a Garmin chrono display. Slick.
The reality: rendering at 30 Hz on Apple Watch drains battery and makes the cost of SwiftUI body re-evaluation meaningful. We shipped it. Profiled it. Reverted to 1 Hz default with a toggle. ~4 weekends of work; current behaviour roughly what we had before we started, plus a settings toggle.
The “hold to finish” arc
- drop the “Hold to finish workout” overlay
- End button: hold-to-finish → single tap; defer
LoadingViewdismissal
The idea: require a long-press on End to prevent accidental finishes mid-run. 1.5-second hold gesture, fill-up animation. Looked great.
The problem: users found it slow and gimmicky. Apple Watch already requires deliberate taps; the protection was solving a problem most users didn’t have. We had to reimplement it as single-tap. That changed the LoadingView dismissal timing, and the workout view flashed through unexpectedly.
Net: ~3 weekends. Final state: single tap, like it was originally. Plus side: we did fix a real bug (the LoadingView flash) along the way.
The “loading phrases” arc
- Plan creation: sliding status phrases below progress bar
- Loading phrases: serif italic font, slower 1.4–2.1 s random pace
- Loading phrases: match title font (system bold italic), 20 pt
- Loading phrases: medium weight, ellipsis suffix, slower 2.0–2.8 s pace
The idea: while a plan is generating (a 1-2 second operation), show rotating status phrases like “Balancing intervals…”, “Tuning long runs…” — make the wait feel intentional.
Four commits across ten days. Three of them are font, weight, timing tweaks. The functionality didn’t change after the first commit. Everything after was visual polish.
Was it worth it? Probably not, in time-spent. Did it improve the app? Marginally. The loading state does feel nicer. Would I do the 2nd-4th commits again? Honestly: no.
The “scroll zoom” arc
- Workouts list scroll zoom: more pronounced (0.85 / 0.55)
- Workouts list scroll zoom: dial back to 0.90 / 0.65 — previous was too aggressive
Two-commit arc with a revert built in. Tuned the zoom-as-you-scroll effect, decided it was too much, dialed it back. Pure visual taste calibration. ~4–5 hours across two weekends. Current values look better than either extreme.
The actual 2 AM story — “milestone perf + layout”
The commit message for what shipped, eventually:
“Milestone perf + layout (GCD timer, drop TabView wrapper / .animation, GeometryReader-based bottom-align, 200 m DEBUG split threshold); watchOS 26 glass hit-area fixes for workout rows and End/Pause buttons; gate indoor demo to DEBUG/simulator.”
The milestone view — the brief overlay that flashes “1 KM • 5:32 pace” when you hit a split during a workout. Originally shipped with a TabView + .animation modifier swiping the milestone card in from the right. Looked fine.
Then we noticed:
- Animation timing was inconsistent under HK callback contention (heart rate updates competing with animation timing).
- The TabView wrapper introduced layout shifts on the underlying timer.
- Using
.animationmodifier in SwiftUI on Apple Watch is more expensive than you’d think.
The “20-minute fix” that became 6 hours, with Claude as the brainstorming partner for most of it:
- Replaced
Timer.scheduledTimerwith a GCD timer on a background queue. - Dropped the
TabViewwrapper entirely →ZStackoverlay. - Removed the
.animationmodifier →withAnimationblocks only where state actually changes. - Rebuilt bottom-alignment with
GeometryReaderbecause the previousSpacer()-based approach was layout-thrashing.
// Before. TabView wrapper + .animation modifier + main-thread Timer.
// Looked fine in the simulator, competed with HealthKit callbacks
// for main-thread time on a real watch.
TabView(selection: $milestoneIndex) {
ForEach(milestones) { MilestoneCard(milestone: $0) }
}
.animation(.easeOut(duration: 0.3), value: milestoneIndex)
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in
refreshSplits()
}
// After. ZStack overlay + GCD timer on a background queue +
// withAnimation only around the state changes that actually animate.
// Stopped trampling on HK callback timing.
ZStack {
if let milestone = currentMilestone {
MilestoneCard(milestone: milestone)
.transition(.move(edge: .trailing).combined(with: .opacity))
}
}
.onAppear {
splitTimer = DispatchSource.makeTimerSource(queue: splitsQueue)
splitTimer?.schedule(deadline: .now() + 1, repeating: 1)
splitTimer?.setEventHandler {
DispatchQueue.main.async { withAnimation { refreshSplits() } }
}
splitTimer?.resume()
}That fix shipped eventually. But Saturday-night-Dan and Sunday-morning-Dan disagreed on what counted as “done.” Saturday-night-Dan would commit anything. Sunday-morning-Dan reverted half of it before pushing.
There’s no shortcut here. When you’re alone with a UI bug at 2 AM, your judgment is wrong. Walk away.
Why the watch makes this worse
iOS UI work can rabbit-hole on iPhone too, but Apple Watch makes it pathologically worse.
- The simulator lies. Performance issues that only manifest on the watch don’t show in the simulator. You polish in the simulator until it looks “right,” then check on the watch and find it’s choppy.
- The build-deploy-test cycle is slow. Watch build = 2–5 minutes. One “tweak, build, look” round takes 5–10 minutes minimum. 6 hours = maybe 40–50 iterations.
- You can’t see what you can’t see. The watch screen is tiny. Subtle layout issues (4 px misalignment, 30 ms animation glitch) require slow-motion replay or specifically being in the right state.
- No UI test catches “this looks slightly worse.” We’ve tried — AI visual analysis can’t reliably tell broken layout from working layout. Human eyeball is the only sensor.
So you sit there, alone, at midnight, looking at a watch screen, asking yourself “is this 30 milliseconds slower than before, or am I imagining it?” The answer is sometimes yes, sometimes no, and there’s no way to know for sure without checking against the version you committed an hour ago. Which you can’t easily do.
Rules that have saved us time (eventually)
Scope-time your evenings
Before sitting down, decide what “done” looks like AND set a time budget. “I’ll work on the milestone view for 2 hours; if I’m not done, I revert.” The hard part is sticking to it at 11 PM when you almost have it working.
Commit “before” first
About to start a UI tweak? Commit the current state. Now you have a reference point. 4 hours in and you’ve made it worse → git reset --hard HEAD → back to known-good. Without the safety commit, you keep going because reverting feels like work.
Don’t ship at 2 AM
The diff at midnight will not look the same in the morning. Anything committed after 11 PM gets re-reviewed in the morning before pushing.
Distinguish polish from fix
Polish = “this looks slightly better.” Fix = “this doesn’t work for some users.”
Don’t ship polish-only commits in isolation. Batch 5–10 small polish items into one 3-hour session. Finish early → stop. Don’t finish → ship what’s done, revert the rest. “The icon is 2 px too low” is polish. “The button doesn’t respond to taps” is a fix.
The math, by commit count
If we count the commits in our git log that are pure UI polish — animation tweaks, font weight changes, spacing, scroll zoom calibration, color shifts — we get somewhere between 30 and 50 over the project’s lifetime. Out of ~220 total commits.
Roughly 20% of our commits are tweaks to things users don’t really notice.
Many were... fine. Each represents an evening or weekend of someone’s life. If we’d reclaimed half that time and spent it on bigger features — adaptive engine, coach mode, anything strategic — would we be further ahead? Probably yes.
But also: the polish is part of why the app feels good. Run Plan isn’t slick because we polished any one thing brilliantly. It’s slick because we polished hundreds of things, each just a little.
So the rule isn’t “don’t polish.” It’s “decide when to stop, and stop.”
What we tell ourselves now
Three questions before a “quick UI fix”:
- What does done look like? If I can’t describe it in one sentence, I’m setting up to rabbit-hole.
- What’s the time budget? Exceeded 2× the original estimate? Stop. Consider whether the goal is achievable in this session at all.
- Will I revert at midnight if I’m not done? If no → I’m not really committing to the discipline. Better not to start.
These questions don’t always work. Sometimes you start, hit a problem, get hooked, ride it out for 6 hours anyway. But they help.
The deepest lesson — and I’m still learning it:
The goal is the app, not the satisfaction of fixing one thing.
Some Saturdays the highest-leverage move is not work on the app at all. Go for a long run. Read the book that’s been sitting on the kitchen table since November. Come back Monday with a clearer head. The app isn’t going anywhere if you don’t push to it for a week. The app might benefit if you don’t.
Pixel is on the hoodie again. Different hoodie this time. The cat does not care about the milestone animation. The cat is smarter than me.
Further reading
- iOS 26 ‘Liquid Glass’ — the design-language update that triggered roughly a third of the polish commits.
- The Architecture Story — the bigger structural moves polish sits on top of.
- The Kalman Filter Story — what happens when polish turns into a year-long sunk-cost spiral.
Run Plan is an indie iOS + Apple Watch training planner built by a 2-person team in Amsterdam. No accounts, no ads, no subscription. Your data stays on your device.