Back to Blog

99issues is built entirely in Swift 6 with SwiftUI. No React Native. No Flutter. No web views. Here’s the full stack, layer by layer, and why we made the choices we did.

The UI: SwiftUI + @Observable

The entire interface - 72 views across 180 Swift files - is built with SwiftUI using the @Observable macro from Swift 6. No Combine, no ObservableObject, no publishers. State flows through @Observable ViewModels directly into views.

This matters because @Observable gives us fine-grained view updates. SwiftUI only re-renders the views that actually read changed properties, not everything downstream of a published object. For a list-heavy app like 99issues - where you’re scrolling through hundreds of issues with labels, assignees, and status badges - that precision is the difference between smooth and janky.

Animations use spring physics for interactive elements and easing curves for section collapses. Everything targets 120fps on ProMotion displays.

Offline Engine: SwiftData + 34 Mutation Types

99issues is offline-first. Every issue, epic, project, label, and milestone is persisted locally in SwiftData across 14 model types. You can browse, search, and triage without a network connection.

The interesting part is the mutation queue. When you create an issue, update a label, close a task, or add a comment while offline, that action goes into a PendingMutation queue. There are 34 distinct mutation types - from createIssue and closeIssue to createTask, setIssueStatus, and parent/child linking operations.

Each mutation stores its payload as Codable JSON with retry tracking. When the network comes back (detected via NWPathMonitor), the queue flushes in FIFO order. Failed mutations retry up to 3 times before surfacing to the user. Offline-created issues get temporary negative IIDs that are swapped to real server IDs after sync.

The caching layer uses composite keys (accountID:itemID) for deduplication, batch fetches existing items before updating, and calls Task.yield() between saves so the UI stays responsive during large syncs.

Networking: REST for Lists, GraphQL for Work Items

There are no third-party networking libraries. Everything goes through URLSession with a shared session configured with 10-second timeouts and exponential backoff retries.

The API strategy is hybrid:

REST handles the stable, established GitLab endpoints - issue lists, projects, labels, milestones, members, notes. These endpoints are well-documented, paginated, and fast.

GraphQL handles the newer Work Items features - epics as work items, issue status fields (GitLab 18.4+), child tasks, and linked items. GraphQL is the only way to query these newer data structures, and it lets us fetch exactly the fields we need in a single request.

Pagination is fully supported through a PaginatedResponse<T> wrapper that extracts page metadata from response headers. Error handling distinguishes between token expiry, insufficient scopes, rate limiting, and server errors - each with a user-facing message.

Security: Keychain + Four Token Types

Tokens never touch UserDefaults, CoreData, or any file on disk. They’re stored exclusively in the iOS Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly - meaning they can’t be read when the device is locked, and they never leave the device via backup.

99issues supports four GitLab token types:

Each account tracks its token health - valid, expiring soon, expired, or revoked - so users get warned before they lose access. Multi-instance is fully supported: you can connect to gitlab.com, your company’s self-hosted instance, and a client’s instance simultaneously, each with its own token and color-coded UI.

Observability: MetricKit + Sentry, Zero PII

Crash reporting uses Sentry with every privacy toggle locked down. No screenshots, no view hierarchies, no network breadcrumbs (since URLs contain instance-specific paths), no request body capture. sendDefaultPii is false. App hang tracking triggers at 2 seconds. This is GDPR/DSGVO compliant by default - there’s nothing to consent to because we collect nothing personal.

Performance metrics go through MetricKit, Apple’s first-party diagnostics framework. Aggregated metrics are sent to a self-hosted LGTM stack (Grafana Loki) - not a third-party SaaS. We see app launch times, background durations, and system diagnostics without ever touching user data.

System Integration

This is where native Swift pays for itself. Cross-platform frameworks can approximate these features, but they can’t match the depth:

WidgetKit - A quick-create widget with 3 configurable slots. Tap a slot, deep-link into the app with account and project pre-selected. Data syncs through a shared App Group.

App Intents - Siri shortcuts for capturing issues by voice. “Capture issue in 99issues” opens a quick-create flow with title, description, project, and confidential flag.

Spotlight - Issues are indexed for system-wide search. Search “login bug” from the home screen and 99issues results appear alongside Mail and Messages. Confidential issues are excluded from the index.

Share Extension - Share a URL or text from any app directly into 99issues as a new issue.

Why Not Cross-Platform?

The short answer: we tried to find a reason to go cross-platform and couldn’t.

99issues is iOS-only. There’s no Android version to amortize framework costs against. The app is deeply integrated with iOS system features - Keychain, WidgetKit, App Intents, Spotlight, MetricKit - that would require native bridges in any cross-platform framework.

SwiftData gives us a persistence layer that’s tightly integrated with SwiftUI’s rendering pipeline. The @Observable macro eliminates an entire category of state management bugs. Swift 6’s strict concurrency checking catches data races at compile time.

The result is 180 Swift files, roughly 33,000 lines of code, and zero third-party UI or networking dependencies. Every line runs natively on the device.