All posts

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 + useCentisecondsTimeDisplay toggle)

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 thing itself: centiseconds ticking on the watch during a workout. Slick, expensive, optional.

The “hold to finish” arc

  • drop the “Hold to finish workout” overlay
  • End button: hold-to-finish → single tap; defer LoadingView dismissal

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.

Final version: phrases rotating below the progress bar while the plan generates. Three of the four commits above are font / weight / timing on this.

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 shipped 0.90 / 0.65 calibration in motion. Subtle enough that you wouldn't notice it gone — which is sort of the whole problem.

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 .animation modifier 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:

  1. Replaced Timer.scheduledTimer with a GCD timer on a background queue.
  2. Dropped the TabView wrapper entirely → ZStack overlay.
  3. Removed the .animation modifier → withAnimation blocks only where state actually changes.
  4. Rebuilt bottom-alignment with GeometryReader because the previous Spacer()-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.

  1. 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.
  2. 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.
  3. 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.
  4. 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”:

  1. What does done look like? If I can’t describe it in one sentence, I’m setting up to rabbit-hole.
  2. What’s the time budget? Exceeded 2× the original estimate? Stop. Consider whether the goal is achievable in this session at all.
  3. 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

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.