Radar pagination crashes. Alert toggles that never fired. Dark mode wars with third-party libraries. IPO cut-off times reading the wrong field. Here's what shipped in 30 days and what each fix taught me.

I work on the iOS side of a stock trading and mutual fund investment app.

It has 20+ feature frameworks, real-time WebSocket price feeds, IPO ordering, alerts, watchlists, charts the full retail-investor toolkit.

Last month I closed around 50 tickets.

Some were one-line fixes.

Some were week-long refactors.

All of them taught me something I wish I'd known a month earlier.

This is a retrospective of the eight biggest themes from those 30 days.

Not a victory lap most of these were bugs that shouldn't have shipped in the first place.

But each one revealed a pattern, and the patterns are where the real learning lives.

1. Radar ➟ Pagination, Filters, and the API That Lies

Radar is a real-time stock-signal feed.

Users filter by watchlist, by industry sector, by signal type.

It looked finished.

It wasn't.

What broke this month:

  • Scroll pagination crashed when the user paginated into the screen from a watchlist
  • The time-width column ellipsis-truncated on small phones, hiding the timestamp
  • Filter state was set in the UI but ignored by the API call — so users saw signals for stocks they hadn't selected
  • The empty-state screen rendered white in dark mode
  • "No internet" showed a generic error instead of a retry screen
  • Re-entering the screen sometimes fired the empty-filter check before the preset finished loading, flashing the wrong state

The pattern:

Radar has two modes preset-based (server picks) and filter-based (user picks).

Almost every bug came from one mode silently falling through to the other:

// ❌ Always fetches preset, even when user has an active filter
case .stockt:
    let request = RadarHistoricalRequest(offsetId: offsetId, pageSize: defaultPageSize)
    result = await worker.fetchHistoryByPreset(request: request)
 
// ✅ Branch on filter state
case .stock:
    if let stockCodes = filteredStockCodes, !stockCodes.isEmpty {
        let validCodes = stockCodes.filter { !$0.contains(",") && !$0.isEmpty }
        let request = buildSignalRequest(
            radarType: "stock",
            stockListStr: validCodes.joined(separator: ",")
        )
        result = await worker.fetchHistoryIndividual(request: request)
    } else {
        let request = RadarHistoricalRequest(offsetId: offsetId, pageSize: defaultPageSize)
        result = await worker.fetchHistoryByPreset(request: request)
    }

The lesson: When a screen has multiple modes, every layer UI, state, API, response mapping needs to know about both.

Skipping the mode check in any one layer makes the whole feature unreliable.

We added unit tests that lock the API call against the active filter state. The class of bugs disappeared overnight.

2. IPO ➟ When the Backend Adds Fields and iOS Doesn't Notice

Electronic IPO ordering is the highest-stakes flow in the app.

Real money.

Real cut-off times.

Real regulatory consequences if you let users tap "Amend" five seconds after the broker stopped accepting amendments.

What broke:

  • "Amend" and "Withdraw" buttons stayed tappable past the actual cut-off
  • The "Deposit Now" CTA appeared for orders that had originated from the website (and shouldn't show on mobile)
  • A crash on the Confirm button caused by a missing dot in a SwiftUI modifier chain
  • The wrong balance field (cashBalance instead of withdrawBalance) drove the available-funds calculation
  • A 30-line method tried four different date formats in sequence because nobody knew which API field to trust

The root pattern: API drift.

The backend had added orderCutOffTime and depositCutOffDate months ago. iOS was still reading

lastOfferingTimeStamp a field that almost worked but was always slightly later than the real cut-off.

// ❌ Almost right. Subtly wrong.
var isLastOfferingDateTimePassed: Bool {
    guard let lastOfferingDateTime = lastOfferingDateTime else { return false }
    return Date() > lastOfferingDateTime
}
 
// ✅ The field the backend actually meant for us to use
var isOrderCutOffDateTimePassed: Bool {
    guard let cutOff = orderCutOffDateTime else { return false }
    return Date() > cutOff
}

The lesson: When you see code that tries multiple date formats or field name variations in a fallback chain,

it's almost always a symptom of using a legacy field. Check the API docs.

Check the Android codebase.

The canonical field is probably already there, sitting unused.

Audit your model layer the same way you audit dependencies fields go stale.

3. The Alert System ➟ Where UI Polish Hides Architecture Bugs

The alert feature lets users set price/event triggers on stocks and mutual funds.

UI looked clean.

The plumbing underneath had cracks.

What broke:

  • Toggling an alert on/off worked silently no toast, no feedback. Users couldn't tell if they actually toggled it
  • The detail screen UI didn't match Figma (wrong fonts, wrong icon tints, wrong row heights)
  • Edit mode had layout issues (overlapping rows, wrong disclaimer position)
  • The bottom sheet for alert info used a deprecated sheet manager
  • Mutual fund alerts opened the search screen on the wrong tab

The toggle bug is worth zooming in on:

// ❌ Direct binding — bypasses business logic entirely
Toggle(isOn: $viewModel.alertItem.active) {
    alertContentButton
}
 
// ✅ Custom binding routes through the handler
Toggle(isOn: Binding(
    get: { viewModel.alertItem.active },
    set: { newValue in handleToggleChange(isActive: newValue) }
)) {
    alertContentButton
}
 
private func handleToggleChange(isActive: Bool) {
    Task {
        try await viewModel.updateAlertStatus(isActive: isActive)
        DispatchQueue.main.async {
            self.refreshAction?(viewModel.alertItem)
            showToggleToast(isActive: isActive)  // ← now reachable
        }
    }
}

$viewModel.property is convenient.

It's also a pipeline that skips every handler, side effect, and analytics call you wanted to run.

The lesson: When a feature has been "done" for a while but keeps generating polish tickets, the polish tickets are signal.

They're not aesthetic disagreements they're the visible surface of architectural debt.

We migrated the alert feature to a single sheet manager, centralized toast feedback, and rewrote the toggle bindings.

The polish tickets stopped.

4. Dark Mode ➟ A Long War With Third-Party Libraries

Half the screens look perfect in dark mode.

The other half have a stubborn white rectangle somewhere.

Every dark mode bug last month traced back to one root cause: third-party libraries with hardcoded .white backgrounds.

The offenders:

  • A paginated tab library set its inner PageViewController.view.backgroundColor = .white with no public API to change it
  • A chart library set its empty-state background to system white
  • A bottom sheet library forgot to forward traitCollectionDidChange to its content

The fix is always the same shape:

// Override the library's hardcoded backgrounds AFTER setup
tabViewController.view.backgroundColor = .clear
for subview in tabViewController.view.subviews {
    subview.backgroundColor = .clear
}
// Let your container's theme-aware color show through

The trick: .clear on the library's internals, theme-aware color on your container, and let the transparency reveal it.

The lesson: Test dark mode on every screen that uses a wrapped third-party component.

The library worked when it was written, before dark mode existed in iOS. Nobody on the open-source side has incentive to fix this.

You will. Build a dark-mode QA checklist scoped to "screens with third-party UI" and run it every release.

5. Charts ➟ Real-Time Updates Without Re-rendering the World

The trading charts get hammered with WebSocket price ticks.

Naive implementation: every tick triggers a full chart redraw.

Less naive: maintain a rolling buffer.

What we actually need: append-only updates to the last point.

What we shipped:

// New method: update only the last point in a time-series
func updateLastPoint(with newPoint: ChartDataPoint) {
    guard !dataPoints.isEmpty else {
        dataPoints.append(newPoint)
        return
    }
    dataPoints[dataPoints.count - 1] = newPoint
    invalidateLastPoint()  // partial redraw, not full
}

Also shipped this month:

  • WebSocket subscription guarded by reachability — no subscribing while offline (just produces timeout noise)
  • Chart Pro time-interval bottom sheet limited to actually-supported intervals (1m, 5m, 10m, 15m, 1h) instead of showing 12 options where half returned empty
  • Empty-state view for charts (replaces the previous "blank gray rectangle")
  • showAxisLabels flag for compact chart variants
  • Bold italic font for selected tab in chart index detail
  • Falling back to "Intraday" when restoring an unsupported interval mode

The lesson: Real-time UI is a streaming problem, not a rendering problem. Your data structure decides your performance ceiling.

If you can update one point in O(1) instead of redrawing N points in O(N), do it even if N is small today.

Tomorrow N is bigger and the same code path is now your bottleneck.

6. Stock Detail ➟ Layout Consistency on a Screen With 12 Sections

The stock detail screen is the most-visited screen in the app.

It's also a hierarchy of nested scroll views, custom headers, dynamic-height cells, and conditional sections.

Every change risks breaking layout somewhere.

What broke this month:

  • Section heights drifted off by 2–8 points after the parent recalculated
  • Thousand separators missing from price values (1234567 instead of 1,234,567)
  • Tab titles weren't localized at display time — they used the cached translation from app launch, ignoring language changes
  • The "Bullish Signals" section was clipped because its parent stack view didn't re-measure
  • Follow/unfollow icons were swapped (the arrow pointed the wrong way)
  • The trade button on Right Issue stocks was incorrectly enabled

The pattern that fixed most of these:

// ❌ Translate at construction time — cache stale on language switch
let tabTitle = "overview".setLocalized
tabs.append(Tab(title: tabTitle))
 
// ✅ Translate at display time — always current
func tabTitle(for tab: TabType) -> String {
    return tab.localization.setLocalized  // re-resolved every read
}

The lesson: "Display-time" beats "construction-time" for anything that can change without your code knowing language, theme, locale, accessibility text size. The slight performance cost is worth it.

Cache invalidation is the harder problem; re-resolving on read sidesteps the whole class.

7. Cross-Platform Parity ➟ When Android Is Right and iOS Is Wrong

Two production bugs this month came from the same root cause: iOS business logic had been "simplified" relative to Android, and the simplifications were wrong.

Example: the Deposit CTA on IPO orders.

// ❌ iOS — three conditions missing
var shouldShowDepositCTA: Bool {
    guard shouldShowPortfolioInformation else { return false }
    return !isBalanceSufficient
}
// Android — full logic
!isUserBalanceLock && balance < totalOrder &&
    (NEED_CONFIRMATION || SUBMITTED || (APPROVED && !isFromWebsite))

iOS shipped without the balance-lock check, without the status-type filter, and without the website-source exclusion.

Each missing condition was a real production bug.

// ✅ iOS — matches Android
var shouldShowDepositCTA: Bool {
    guard let response = orderDetailResponse else { return false }
    guard !(response.isUserBalanceLock ?? false) else { return false }
    guard !isBalanceSufficient else { return false }
 
    let isOrderFromWebsite = response.source == EIPOSourceReference.web.rawValue
 
    switch response.statusType {
    case .needConfirmation, .submitted:
        return true
    case .approved where !isOrderFromWebsite:
        return true
    default:
        return false
    }
}

The lesson: When iOS and Android consume the same API, the business logic must match exactly.

During code review, always ask "does Android have this condition?" The Android codebase is documentation when specs are ambiguous.

Divergent logic is one of the most common sources of production bugs on cross-platform teams, and it's never caught by unit tests because each platform tests its own (incorrect) logic in isolation.

8. Navigation Context ➟ The Three-Line Fixes That Make Apps Feel Right

A category of bug I keep encountering: screen A navigates to screen B with the wrong default state.

Screen B supports the right state there's a selected_type parameter, an initial-tab argument, a context dictionary.

Nobody connected the wire.

Examples this month:

  • Alert action on Mutual Fund tab → search screen opens on Stock tab (user has to manually re-tap MF every time)
  • Investing Screener stock tap → didn't navigate to Individual Stock at all
  • Watchlist → top bar margin shifted on pull-to-refresh
  • Settlement data missing from withdraw confirmation flow
  • Auth force-logout sheet collided with an existing bottom sheet

The fix is almost always small:

// ✅ Pass the current context to the destination
var params: [String: Any] = [
    "hideRecentSearch": true,
    "callbackAlert": callbackAlert,
    "modal": 1,
]
if type == .mutualFund {
    params["selected_type"] = SearchProductType.funds
}
let searchController = accountFactory.createSearchViewController(parameters: params)

Three lines.

The destination already handled selected_type it had been written months ago in anticipation.

Nobody had wired it up from this entry point.

The lesson: When screen A → screen B feels wrong, the bug is rarely in screen B.

Screen B is usually flexible enough to do the right thing.

The bug is at the boundary the navigation call site that didn't pass the available context.

Always trace the parameter dictionary across navigation boundaries.

The fix is small. Finding it is the work.

What 30 Days Actually Looked Like

Reduced to numbers:

  • ~50 closed tickets
  • Roughly half were one- or two-line fixes (wrong field, missing parameter, misplaced dot)
  • A quarter were UI polish driven by Figma comparison
  • A quarter were architectural refactors, migrations, deprecating old patterns

The split surprised me.

I'd guessed beforehand that the architectural work would dominate.

It didn't.

Most of the month was tiny, surgical fixes the kind that look trivial in the diff and reveal entire categories of risk in retrospect.

Three Patterns That Recurred

1. Almost every bug was a wiring failure, not a logic failure. The right data existed.

The right API field existed.

The right parameter was supported.

Someone just didn't connect them.

When you're debugging, don't start with "what's the algorithm doing wrong?" Start with "is the right data reaching the right place?"

2. Cross-platform parity is a feature, not an accident. When iOS and Android consume the same backend, treat the other platform's source as the spec.

Especially for business-logic conditions: balance locks, cut-off times, status guards.

The Android repo is the closest thing to ground truth when product docs are unclear.

3. Polish tickets are architectural signal. A feature that keeps generating UI tickets isn't a feature with aesthetic problems.

It's a feature whose architecture is too fragile to land changes cleanly.

Migrate, refactor, centralize and the polish tickets stop arriving.

What I'm Doing Differently This Month

Concrete changes I made to my own workflow based on these 30 days:

  • Audit model fields quarterly. Old API fields go stale. Build a checklist of "fields we currently read" vs "fields the backend now exposes" and reconcile.
  • Run dark mode QA on third-party-heavy screens specifically. Make it a separate checklist item, not "did you test dark mode in general."
  • Before merging, check the Android equivalent screen. Five minutes of git log in the Android repo prevents most parity bugs.
  • Treat Binding(get:set:) as the default, not the exception. Direct $binding is convenient and dangerous handlers get skipped silently. Default to the explicit form for anything with side effects.
  • Resolve localized strings at display time, not construction time. No more caching translations in instance variables.

If this was useful, follow me for weekly retrospectives on shipping iOS at fintech scale. I write about real bugs, real fixes, and the patterns that prevent the next one.

Thank you for reading! If you enjoyed it, please consider clapping and following for more updates! 👏