Flutter and React Native error tracking with the Sentry SDK.
Mobile apps fail in ways that backend services do not: network gaps swallow events, obfuscated Dart bytecode strips method names, the iOS watchdog kills processes without leaving a crash report, and Android ANRs produce a stack that points at the wrong frame. This guide covers how to wire up sentry_flutter and @sentry/react-native for complete mobile error tracking, how to symbolicate every layer of the stack (Dart, JavaScript, native), and how to point all of it at urgentry by changing only the DSN.
20 seconds. For Flutter: add sentry_flutter to pubspec.yaml, wrap runApp in SentryFlutter.init with your urgentry DSN, pass runZonedGuarded as the appRunner. For React Native: run npx @sentry/react-native@latest init, call Sentry.init before the root component registers, swap the DSN to your urgentry DSN. Upload debug symbols with sentry-cli --url https://your-urgentry-host and nothing else changes.
60 seconds. The SDK captures Dart unhandled exceptions and Flutter widget errors automatically. What it does not capture without extra work: errors inside background isolates (including FCM push handlers in Flutter), Hermes-compiled React Native frames that lose their source position, and iOS watchdog terminations that look like clean exits. Native crashes underneath both frameworks require platform-specific symbol files: dSYM bundles on iOS, ProGuard or R8 mapping files on Android. All three upload through the same sentry-cli debug-files upload command.
Mobile devices spend hours offline. The SDK queues events locally and delivers them on the next network connection. The queue is finite (30 envelopes by default), so a device that crashes repeatedly in airplane mode eventually drops the oldest events. This guide covers how the queue works, what happens at rate-limit boundaries, and which breadcrumbs signal real problems versus noise.
Where mobile errors come from in 2026
Mobile error surfaces are more fragmented than server-side equivalents, and the failure modes do not map cleanly onto the exception model developers expect from backend work.
Dart exceptions in Flutter. Flutter's Dart VM runs in a single root isolate during normal app execution. Unhandled exceptions in the root isolate fire FlutterError.onError (for widget-layer errors, such as layout overflows and invalid key types) and the zone error handler (for asynchronous code outside the widget layer). Both surfaces require separate instrumentation. An exception thrown inside a Future that nobody awaits produces no error event unless you configure an onError handler in runZonedGuarded.
JS bridge crashes in React Native. React Native runs JavaScript in a separate thread, connected to the native layer through a bridge (or through JSI in newer architectures). An unhandled JavaScript exception crashes the JS thread and triggers a red screen in development. In production, the app may freeze, show a blank screen, or silently fall back to a native error depending on the RN version and the error boundary configuration. The @sentry/react-native SDK hooks into the JS global error handler to capture these before they reach the bridge.
Native crashes underneath the framework. Both Flutter and React Native run on top of native iOS and Android code. Memory corruption, null pointer dereferences in native modules, and JNI boundary violations produce native crashes that the Dart VM or the JavaScript engine cannot catch. These require native crash reporters (Sentry's NDK for Android, KSCrash for iOS) that the mobile SDKs include automatically. Reading the resulting crash report requires platform-specific debug symbols.
ANRs on Android. An Application Not Responding event fires when the main thread is blocked for more than five seconds. ANRs do not produce a standard exception; they generate a thread dump attached to an ANR tombstone. The Sentry Android SDK detects blocked main threads and creates synthetic events from the thread dump. The frame at the top of the dump is often the symptom (a lock or I/O call) rather than the root cause, which makes ANR debugging harder than crash debugging.
Watchdog terminations on iOS. The iOS watchdog kills processes that stop responding to the system's heartbeat. The termination is not a crash and leaves no crash report in the standard crash log directories. The process receives a SIGKILL with no stack trace. The Sentry iOS SDK detects likely watchdog terminations by observing app state at launch and generating a synthetic event when the previous session appears to have ended mid-run without an explicit exit. The underlying cause is almost always main-thread work: a long-running synchronous network call, blocking file I/O, or a deadlock.
Flutter install
Add the package to pubspec.yaml:
dependencies:
sentry_flutter: ^8.0.0
Run flutter pub get to fetch the dependency. The package bundles the Sentry iOS SDK and the Sentry Android NDK; no separate native SDK installation is needed.
In your app entry point, replace the standard runApp call with the SentryFlutter.init pattern:
import 'package:flutter/widgets.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
Future<void> main() async {
await SentryFlutter.init(
(options) {
options.dsn = 'https://<public_key>@errors.example.com/<project_id>';
options.environment = const String.fromEnvironment(
'ENVIRONMENT',
defaultValue: 'production',
);
options.release = const String.fromEnvironment('RELEASE');
options.tracesSampleRate = 0.1;
options.attachScreenshot = true;
},
appRunner: () => runZonedGuarded(
() => runApp(const MyApp()),
(error, stackTrace) => Sentry.captureException(
error,
stackTrace: stackTrace,
),
),
);
}
The appRunner parameter is where the runZonedGuarded wrapper goes. This is not optional. SentryFlutter.init installs handlers for FlutterError.onError (widget errors) and for platform dispatcher errors. But Dart's async zone is a separate error surface: any exception thrown inside a Future or async function that completes with an error, and that has no .catchError handler, propagates to the current error zone. runZonedGuarded makes the current zone's error handler a Sentry capture call, so those errors reach urgentry instead of disappearing.
The RELEASE and ENVIRONMENT values come from Dart's compile-time defines, passed during the build:
flutter build apk \
--dart-define=RELEASE=1.4.2+42 \
--dart-define=ENVIRONMENT=production
Set release to a value that identifies the build: a semantic version, a git SHA, or a version code. The same value must appear in your sentry-cli upload commands for symbolication to match events to their debug symbols.
React Native install
The @sentry/react-native package ships an init wizard that handles most of the native configuration automatically:
npx @sentry/react-native@latest init
The wizard adds the native modules to your iOS and Android projects, installs the Metro plugin, and creates a basic Sentry.init call. For projects where the wizard is not appropriate (monorepos, Expo managed workflow), install manually:
npm install @sentry/react-native
# then run pod install for iOS
cd ios && pod install
Call Sentry.init before your root component registers. The earliest safe place is the top of your entry file, before the AppRegistry.registerComponent call:
import * as Sentry from '@sentry/react-native';
import { AppRegistry } from 'react-native';
import App from './App';
Sentry.init({
dsn: 'https://<public_key>@errors.example.com/<project_id>',
environment: process.env.NODE_ENV,
release: process.env.RELEASE,
tracesSampleRate: 0.1,
enableNativeCrashHandling: true,
enableAutoSessionTracking: true,
attachScreenshot: true,
});
AppRegistry.registerComponent('MyApp', () => App);
Wrap your root component with Sentry.wrap to enable automatic navigation instrumentation and performance tracing:
export default Sentry.wrap(App);
For autoinstrumentation with React Navigation, add the navigation integration after your navigator is set up. The SDK provides a Sentry.reactNavigationIntegration that attaches to the navigator ref and records screen transitions as breadcrumbs and spans automatically. Refer to the SDK documentation for the specific navigation library you use; the integration pattern differs between React Navigation 5/6, Expo Router, and React Native Navigation.
For Expo managed workflow, use the sentry-expo package instead of @sentry/react-native directly. The setup is equivalent, but the build hooks are wired through the Expo plugin system rather than the RN CLI.
Symbolicating Dart obfuscation
Flutter's release builds obfuscate Dart code by default when you pass the --obfuscate flag alongside --split-debug-info. Without these flags, Dart method names remain readable but the binary is larger. With them, the binary shrinks and the stack frames in any crash report become unreadable identifiers.
The --split-debug-info flag writes a separate debug symbol file for each build target. Collect these files and upload them to urgentry before distributing the release:
# Build with obfuscation
flutter build apk \
--release \
--obfuscate \
--split-debug-info=./debug-info \
--dart-define=RELEASE=1.4.2+42
# Upload the debug-info directory to urgentry
sentry-cli debug-files upload \
--url https://errors.example.com \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org your-org-slug \
--project your-project-slug \
./debug-info
The ./debug-info directory contains one .symbols file per ABI (arm64-v8a, armeabi-v7a, x86_64). Upload all of them. urgentry stores each file indexed by a UUID that matches the UUID embedded in the corresponding compiled binary. When a Dart crash report arrives with an obfuscated frame, urgentry reads the UUID from the frame, finds the matching symbol file, and resolves the obfuscated name back to the original Dart method and line.
For iOS builds:
flutter build ipa \
--release \
--obfuscate \
--split-debug-info=./debug-info \
--dart-define=RELEASE=1.4.2+42
sentry-cli debug-files upload \
--url https://errors.example.com \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org your-org-slug \
--project your-project-slug \
./debug-info
The release value in SentryFlutter.init and the build identifier in the uploaded symbol file do not have to match by convention; what links them is the UUID in the binary. But setting a consistent release string makes it easier to filter which events in urgentry should receive symbolication when you manage multiple active releases.
Symbolicating React Native JS source maps
React Native bundles JavaScript with Metro and optionally compiles it with Hermes. The output is either a Metro bundle (plain JavaScript, minified) or Hermes bytecode. Both discard original file paths and line numbers unless source maps are generated and uploaded.
For Metro bundles, the Gradle plugin and the Xcode build phase added by sentry-cli react-native gradle automate source map generation and upload during a release build. Verify the integration in your android/app/build.gradle:
apply from: "../../node_modules/@sentry/react-native/sentry.gradle"
sentry {
uploadNativeSymbols true
includeNativeSources false
autoUpload true
url "https://errors.example.com"
org "your-org-slug"
project "your-project-slug"
}
The url field here is your urgentry base URL. This is distinct from the DSN. The DSN is where the SDK sends events at runtime. The url is where sentry-cli sends build artifacts (source maps, symbol files) at build time.
For Hermes bytecode, source map upload requires one additional step. Hermes compiles the Metro bundle into bytecode and produces a Hermes source map. The final source map must merge the Metro source map and the Hermes source map so that bytecode offsets resolve through the Hermes layer and then through the Metro layer back to the original TypeScript or JavaScript file. The sentry-cli sourcemaps upload command handles this when you pass both map files:
sentry-cli sourcemaps upload \
--url https://errors.example.com \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org your-org-slug \
--project your-project-slug \
--release "$RELEASE" \
--dist "$BUILD_NUMBER" \
./android/app/build/generated/sourcemaps/react/release/
The --dist value must match the dist option in Sentry.init. It identifies which specific build within a release the source map belongs to, which matters when a release contains multiple Android ABIs or both iOS and Android builds.
If you see frames like at ?anon_0_() [unknown] or blank file paths in urgentry after uploading, the Hermes and Metro maps were not merged correctly. Check that the Gradle plugin version matches the @sentry/react-native version and that enableHermes: true is set in your Metro config.
Native crash symbolication on iOS
Native iOS crashes produce reports that reference raw memory addresses. Converting those addresses to method names and line numbers requires the dSYM bundle generated during the Xcode build. The dSYM is a directory with the suffix .dSYM containing a DWARF debug information file.
Upload dSYMs to urgentry after each release build:
sentry-cli debug-files upload \
--url https://errors.example.com \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org your-org-slug \
--project your-project-slug \
/path/to/YourApp.app.dSYM
For App Store distribution, Xcode generates bitcode-recompiled dSYMs after the App Store processes your upload. These are distinct from the dSYMs you generated locally: the App Store strips the original binary, recompiles from bitcode for optimization, and produces a new binary with a new UUID. The local dSYM UUID will not match, so locally-uploaded dSYMs will not symbolicate crashes from App Store installations.
Retrieve the App Store dSYMs from Xcode Organizer or from the iTunes Connect API after processing completes. The typical window is one to four hours after upload. Add a CI step that polls for the dSYMs and uploads them to urgentry as soon as they appear. Missing this window means crashes from the first hours after release arrive unsymbolicated. The data is there; the frames just show addresses until you upload the correct dSYMs.
For Flutter apps distributed through the App Store with bitcode disabled (which is the default since Xcode 14), the locally generated dSYMs match the App Store binary. Upload at build time and the race is gone.
dSYMs disappear when you delete an Xcode archive or clean the derived data directory. Archive every release build as a .xcarchive and keep it until you retire the corresponding app version from the App Store. The dSYM lives inside the archive at YourApp.xcarchive/dSYMs/.
Native crash symbolication on Android
Android release builds run ProGuard or R8 (the default since Android Gradle Plugin 3.4) to shrink and obfuscate bytecode. Both tools produce a mapping.txt file that maps obfuscated class, method, and field names back to their originals. Upload this file to urgentry after each release build:
sentry-cli debug-files upload \
--url https://errors.example.com \
--auth-token "$SENTRY_AUTH_TOKEN" \
--org your-org-slug \
--project your-project-slug \
./android/app/build/outputs/mapping/release/mapping.txt
R8 differs from ProGuard in a few ways that affect error tracking. R8 inlines more aggressively, which means a stack frame in a crash report may refer to a method that R8 merged with its caller. The mapping file records inlined methods as separate entries, so urgentry can reconstruct the inlined call chain. ProGuard does not record inlining in the same way, so ProGuard-mapped stack traces sometimes show one fewer frame at the point of inlining.
Version code coordination is the Android equivalent of the dSYM upload race. Each mapping.txt applies to a specific version code. If you upload a mapping file and then increment the version code before the Play Store distributes the build, crashes from that build arrive with a version code that has no matching mapping in urgentry. The safe pattern is to upload the mapping file as part of the same CI step that publishes the AAB to the Play Store, keyed by the version code in the manifest.
For React Native Android, the native crash includes both a Java/Kotlin stack (from the Android runtime) and a JavaScript stack (from the JS engine). Both require separate symbol sources: the ProGuard mapping file for the Java frames and the Metro/Hermes source maps for the JavaScript frames. The Sentry Android SDK and the @sentry/react-native package attach both stacks to the same event. urgentry symbolicates each layer independently using whichever debug files are present.
Pointing all of this at urgentry
The DSN is the only runtime configuration change. Create a project in urgentry and copy its DSN. The format is the same as a Sentry DSN:
https://<public_key>@errors.example.com/<project_id>
In Flutter, set options.dsn to this value inside SentryFlutter.init. In React Native, set dsn in Sentry.init. The SDK sends the same Sentry envelope protocol it always has. urgentry receives that envelope and stores the event. Nothing in your application code changes.
For build-time artifact uploads (source maps, dSYMs, ProGuard mapping files, Dart debug-info bundles), set two environment variables in your CI:
SENTRY_URL=https://errors.example.com
SENTRY_AUTH_TOKEN=your_urgentry_auth_token
Or pass --url and --auth-token directly to each sentry-cli invocation as shown in the sections above. The CLI calls the same debug-files API endpoints regardless of which server it points at. urgentry implements all 218 Sentry API operations including the debug-files endpoints, so every sentry-cli debug-files upload command that works with Sentry also works with urgentry.
For the React Native Gradle plugin, the url key in the sentry block of build.gradle sets the upload destination. For the Xcode build phase script added by sentry-cli react-native gradle, set the SENTRY_URL environment variable in your scheme or in the build script. Both paths end at the same urgentry artifact ingest.
Check the urgentry compatibility matrix for the full list of covered API operations if you use less common SDK features such as user feedback, session replay, or profiling.
Offline event queue and rate limits
Mobile devices go offline for minutes, hours, or days. A crash that happens in a subway tunnel, on a flight, or in a building with poor signal produces an event that the SDK cannot deliver immediately. Both sentry_flutter and @sentry/react-native persist unsent events to an on-device envelope cache before attempting delivery.
When the SDK initializes on the next app launch, it reads the cache and delivers queued events in order. The delivery happens before the app is interactive, which means crashes from the previous session arrive in urgentry before any new events from the current session. This gives you a reliable picture of what killed the previous session.
The default cache holds 30 envelopes. An envelope contains one or more items (events, attachments, session updates). At 30 envelopes, the oldest is dropped when a new one arrives. A device that crashes in a loop while offline will lose early crash events by the time it reconnects. You can raise the limit with the maxCacheItems option in SentryFlutter.init or Sentry.init, at the cost of more disk space per device.
Rate limits work differently from the queue. When urgentry returns an HTTP 429 (rate limit exceeded), the SDK reads the Retry-After header and stops sending for that duration. Events that arrive during the rate-limit window go into the disk cache, not into a memory buffer. This means rate-limited events survive app restarts. After the window expires, the SDK delivers the cached events before processing new ones.
urgentry does not enforce per-project rate limits by default (unlike Sentry's hosted plans). On a $5 VPS running urgentry at 400 events per second with 52 MB resident memory, the practical limit is the throughput of a single machine, not an artificial plan ceiling. Rate limiting in urgentry activates under resource pressure, not subscription tiers.
Breadcrumbs that matter on mobile
Both SDKs collect breadcrumbs automatically: navigation events, lifecycle changes, HTTP requests, and user interactions. The default collection gives you a trail to replay before each crash. The problem is volume: a user who taps through a complex flow before hitting a bug can generate hundreds of breadcrumbs, and the default limit of 100 means early context is gone by the time the crash fires.
The breadcrumbs with the highest signal-to-noise ratio on mobile are:
Navigation events. Screen transitions tell you which screen the user was on immediately before the crash. In Flutter, the SentryNavigatorObserver added to your MaterialApp routes logs each push, pop, and replace as a breadcrumb. In React Native, the navigation integration logs each screen view. These are almost always the first breadcrumb you look at.
App lifecycle transitions. Foreground, background, and resume events establish whether the crash happened in the background or the foreground. A crash in the background often points to a background fetch handler, a push notification handler, or a background isolate. A crash immediately after a resume suggests a state restoration bug.
Network failures. HTTP errors captured as breadcrumbs show which requests returned non-200 status codes before the crash. These are often the immediate cause: a 401 that leaves the app in an unauthenticated state, a 500 that leaves a required object null.
The noise problem is user interaction breadcrumbs. Touch events on every tap, scroll, and drag produce breadcrumbs that pad the trail without adding information you do not already have from navigation events. Disable interaction breadcrumbs if you hit the 100-item limit before meaningful context arrives:
// Flutter: disable user interaction breadcrumbs
options.enableUserInteractionBreadcrumbs = false;
// React Native: disable touch breadcrumbs
Sentry.init({
dsn: 'https://<public_key>@errors.example.com/<project_id>',
enableUserInteractionTracing: false,
});
Alternatively, raise the breadcrumb limit. Both SDKs expose a maxBreadcrumbs option. Setting it to 200 doubles the context trail at a small memory cost.
The three mobile gotchas
1. Background-isolate FCM handlers in Flutter
Firebase Cloud Messaging in Flutter uses a background isolate to run the FirebaseMessaging.onBackgroundMessage handler. This isolate is a separate Dart VM instance. Sentry's instrumentation from the root isolate does not carry over. Any exception thrown inside a background message handler produces no Sentry event unless you call SentryFlutter.init at the top of the background handler function.
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// Must re-initialize in the background isolate
await SentryFlutter.init((options) {
options.dsn = 'https://<public_key>@errors.example.com/<project_id>';
});
try {
await handleBackgroundMessage(message);
} catch (error, stackTrace) {
await Sentry.captureException(error, stackTrace: stackTrace);
await Sentry.close();
}
}
Call Sentry.close() at the end of the handler to flush the SDK and release resources. Background isolates are short-lived; without an explicit close, buffered events may not flush before the isolate is destroyed.
2. Hermes stack frame loss
Hermes compiles JavaScript to bytecode ahead of time, which improves startup performance but changes how the JS engine reports stack frames. Hermes does not produce a standard V8-format stack trace. The frames use internal bytecode offsets rather than file paths and line numbers, and the function names are often missing entirely.
The fix is the merged source map described in the symbolication section above. Without the merged Hermes source map uploaded to urgentry, crashes from Hermes-compiled builds show frames like at p (2:54) where 2:54 is a bytecode offset. With the merged map uploaded, urgentry translates each offset through the Hermes map (bytecode offset to Metro bundle position) and then through the Metro map (bundle position to original source file and line).
Verify the chain works before releasing. In your CI pipeline, build a debug APK with Hermes enabled, trigger a test crash, and confirm that the resulting event in urgentry shows original file paths. If the frames still show bytecode offsets, the merged map was not generated or was not uploaded to the correct release and dist combination.
3. iOS watchdog terminations that look like normal exits
The iOS watchdog sends SIGKILL without a crash report. From the app's perspective, nothing happened: no signal handler ran, no crash log was written, no applicationWillTerminate delegate method was called. The next time the app launches, it starts fresh with no direct evidence of what happened.
The Sentry iOS SDK detects likely watchdog terminations by tracking session state on disk. At launch, the SDK checks whether the previous session ended cleanly (the app called applicationWillTerminate or went to the background) or whether the session simply stopped mid-run. A session that stopped mid-run without a recorded crash or a clean exit is a candidate for a watchdog termination. The SDK generates a synthetic event flagged as a potential watchdog termination.
These events appear in urgentry with the type WatchdogTermination or similar, depending on the SDK version. The stack trace is the state of the main thread at the last recorded checkpoint, not at the moment of termination. Use it to identify what was running on the main thread before the kill, not where the fatal condition occurred.
To reduce false positives, the SDK requires that the device was not low on memory at the time of the suspected termination (which would instead generate an out-of-memory event) and that the OS did not trigger a forced quit by the user. If you see a high volume of watchdog termination events after a specific release, check for main-thread work introduced in that release: synchronous network calls, blocking I/O, or a new library that performs setup work on the main thread.
FAQ
Does sentry_flutter capture Dart errors in isolates other than the main isolate?
Not automatically. Background isolates, including the one spawned by FirebaseMessaging.onBackgroundMessage, run in a separate Dart VM and must call SentryFlutter.init independently before any Sentry calls work. Call Sentry.close() at the end of the background handler to flush buffered events before the isolate is destroyed.
Do I need to run sentry-cli for source maps and debug files, or is there a Gradle/Xcode plugin?
For React Native, the @sentry/react-native Gradle plugin and Xcode build phase script automate source map upload during release builds. For Flutter, add a sentry-cli debug-files upload step to your CI pipeline manually. Both paths call the same debug-files endpoints on urgentry, which are fully covered by the 218/218 Sentry API compatibility.
What happens to events queued on device when urgentry is temporarily unreachable?
The SDK writes unsent events to an on-device envelope cache. On next launch, the SDK delivers cached events before processing new ones. The default cache holds 30 envelopes; the oldest is dropped when the limit is reached. Rate-limited events follow the same disk-cache path and survive app restarts until the retry window expires.
How do I verify that Dart obfuscation symbolication is working in urgentry?
Throw a deliberate test exception after uploading debug symbols. Open the event in urgentry and check whether stack frames show Dart method names and file paths or obfuscated identifiers. If frames are still obfuscated, confirm the --split-debug-info output directory was uploaded and that the release value in SentryFlutter.init matches the value passed to sentry-cli.
Does urgentry support the full sentry-cli debug-files upload workflow?
Yes. urgentry implements all 218 Sentry REST API operations including the debug-files endpoints. Point sentry-cli at your urgentry instance with the --url flag. dSYM bundles, ProGuard mapping files, Dart debug-info bundles, and source maps all upload through the same commands documented by Sentry, with only the URL and auth token changed.
Sources
- sentry_flutter SDK documentation — installation,
SentryFlutter.initoptions, therunZonedGuardedpattern, isolate handling, and Dart obfuscation symbolication. - @sentry/react-native SDK documentation — installation wizard,
Sentry.initoptions, Hermes source maps, the Gradle plugin, and the Xcode build phase integration. - sentry-cli debug-files documentation — the
debug-files uploadcommand reference for dSYM, ProGuard mapping files, and Dart debug-info bundles. - Apple Technical Note: Identifying the Cause of Common Crashes — watchdog terminations, SIGKILL semantics, and the absence of a crash report for watchdog kills.
- Android developer documentation: Shrink, obfuscate, and optimize your app — R8 versus ProGuard differences, the
mapping.txtfile location, and version code coordination for release builds. - FSL-1.1-Apache-2.0 license text — the FSL license under which urgentry is distributed, converting to Apache 2.0 after two years.
- urgentry Sentry API compatibility matrix — the full list of 218/218 covered Sentry REST API operations including debug-files, source maps, and mobile SDK ingest endpoints.
Ready to track mobile errors on infrastructure you control?
urgentry runs as a single 52 MB binary, accepts the Sentry mobile SDKs without code changes, and handles native symbolication for iOS dSYM and Android ProGuard mapping files through the same sentry-cli workflow you already know. Create a project, swap the DSN, upload your debug files, and your first mobile crash lands in a UI running on your own server.