
The Boot Splash Bug That Was Never in Android
Building a native boot splash for masicn's React Native app meant getting three rendering layers — Android, iOS, and JS — to agree on the same colors and timing. Two real iOS bugs, one convincing-but-wrong Android detour, and a one-line root cause that had been missing since the very first commit.
Every React Native app needs a boot splash, and every boot splash has the same trap: it exists across three completely different rendering layers — Android's native theme system, iOS's storyboard, and a JS overlay — that all have to agree on exactly the same background color, exactly the same logo, and exactly the same timing. Get any one layer slightly wrong and the seam shows: a flash of the wrong color, a logo that appears to render twice, a blank frame where the logo should be. Building the boot splash for masicn's design-system app took a full session, four real bugs, and one debugging detour that turned out to be researching the right platform's documentation for a bug that had nothing to do with it.
Solid background, two platforms, one brand mark
The splash itself was meant to be simple: a solid background color, light and dark, with the brand mark centered on top — no gradient, no entrance animation, just the app's actual palette colors so the transition into the real UI feels seamless. react-native-bootsplash generates most of the native scaffolding from a single CLI command, but two of its convenience features are paywalled: --dark-background, --dark-logo, and --brand all require a --license-key. Free tier means dark mode gets hand-written instead — a values-night/colors.xml on Android, which the OS picks up automatically, and a second, luminosity: dark-tagged entry in iOS's Color Set.
npx react-native-bootsplash generate <logo.svg> \
--platforms android,ios --background "#FFECD1" --logo-width 130The brand SVG couldn't be fed to the CLI as-is
The real brand mark's <svg> root element carries literal width="35160" height="30160" attributes alongside its viewBox — a holdover from the original export. Sharp, the CLI's image library, treats those as the intrinsic raster target: roughly 1.06 billion pixels, comfortably past its default pixel limit, no matter what --logo-width actually asks for. The fix wasn't editing the tracked asset — the React component never reads the file's intrinsic size anyway — just a CLI-only copy with the same viewBox and paths but sane width/height (352×302, the original divided by 100).
The background color itself changed once, after the first version was already running: it started as the logo's own navy and white, chosen to be palette-agnostic, but on an actual device it looked disconnected from the rest of the app. It became #FFECD1 / #001524 instead — the same light/dark background tokens the rest of the UI already uses. A small change on paper, but it touches six different files with no automated sync between them: two Android color resources, an iOS Color Set (both appearances) plus the storyboard's cached fallback, and the JS constants file — worth remembering before assuming a one-line color swap is actually a one-line change.
iOS: fixed twice
iOS surfaced two separate bugs, in two separate flavors of wrong, before it read the way it does now. The first: right after the initial implementation, the logo appeared to render twice on launch — a bare instant of blank space, then the mark drawing again. The cause was a timing bug. RNBootSplash.initWithStoryboard(...) was being called from application(didFinishLaunchingWithOptions:), after factory.startReactNative(...) had already run — the pattern shown in an older reference implementation. That let React Native's real, briefly blank, root view become visible for a moment before the splash view re-attached on top of it.
1class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {2 override func customize(_ rootView: RCTRootView) {3 super.customize(rootView)4 RNBootSplash.initWithStoryboard("BootSplash", rootView: rootView)5 }6}Fixed by moving one call
Per the library's own README, initWithStoryboard belongs inside ReactNativeDelegate.customize(_ rootView:) — a hook that fires before the root view is ever attached to the window, not after startReactNative completes. Once moved there, the double-render disappeared for good.
The second bug showed up after the background colors changed to match the app's palette. The background now rendered white for a moment on every launch, then snapped to the correct color — not a light/dark mismatch, a real two-phase flash regardless of appearance setting.
The first hypothesis was iOS's own well-documented launch-screen snapshot caching — a real quirk where UILaunchStoryboardName renders sometimes don't pick up incremental asset-catalog changes without a full delete-and-reinstall. It sounded plausible enough to propose as the fix. It also hadn't actually been verified yet, and a blunt “did u really completed??” was fair — the honest answer was no.
<color key="backgroundColor" name="BootSplashBackground-7452db" red="1.00000000000000" green="0.92549019607843" blue="0.81960784313725" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>The real cause: a stripped fallback
Editing the storyboard earlier to add appearance-aware color support had replaced the CLI's original flat RGB values with a bare name= reference, instead of adding the name alongside the RGB. Without an inline fallback, the earliest OS-rendered launch phase — which has a documented history of not always resolving named asset-catalog colors — had nothing to fall back to, and painted white until the later, fully-capable runtime phase resolved the name correctly. The fix was restoring the RGB attributes on the same element, alongside name= — a named color reference should always carry both.
Android: the bug that was never in Android
Android's version of the same “logo twice” symptom looked identical to iOS's at first glance, but the investigation went somewhere completely different — and wrong — before it found the actual, almost embarrassingly simple cause.
Android 12 introduced its own mandatory OS-level SplashScreen, and it's genuinely true that it clips any icon wider than 108dp into a circular mask — confirmed against Android's own migration docs and a GitHub issue describing exactly this symptom: the OS's cropped icon animates in, then the library's own replica splash takes over showing an uncropped version of the same icon, reading as “appears twice.” The masicn logo renders at 130dp. Every signal pointed at icon clipping.
<item name="android:windowSplashScreenAnimatedIcon">@android:color/transparent</item>The fix flipped the symptom, it didn't remove it
With the OS-level icon hidden, the double-logo went away — replaced by a blank background with no logo at all, followed by the logo appearing late. Not a fix. A different, equally wrong result. And a real signal that the Android 12 icon-clipping research, accurate as it was about the platform in general, was never actually the problem in this codebase.
Two reasonable-sounding workarounds got proposed next: shrink the logo to safely under 108dp everywhere, or build a two-tier icon that starts small and grows into the full mark. Both were sound engineering trade-offs for a real platform constraint. Both got rejected, with a request to keep looking for the actual cause instead of designing around a symptom.
It was in MainActivity.kt, and it was almost insultingly simple: there was no onCreate() override at all. RNBootSplash.init(this, R.style.BootTheme) — the single call that attaches the library's native compat splash view — had never been added, despite being in the library's own README from the start. Without it, that compat view never attaches, on any Android version. .hide() from JS has nothing to hide.
1class MainActivity : ReactActivity() {23 override fun onCreate(savedInstanceState: Bundle?) {4 // Initialize RNBootSplash before super.onCreate so the native splash stays visible5 // until JS is ready -- calling it any later inflates the window with the wrong6 // background first, causing a visible flash.7 RNBootSplash.init(this, R.style.BootTheme)89 supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()10 super.onCreate(savedInstanceState)11 }12}One missing line explained both symptoms
What looked like “the logo rendering twice” was actually: the OS's mandatory SplashScreen phase showing the configured icon, the OS auto-dismissing on the first drawn frame (revealing a plain default Activity background, since the native compat layer never existed), then React Native finally mounting and painting the same logo again via the JS overlay. Two renders of the same logo, separated by a real gap — “twice” when the gap is short, “blank then logo” once the OS-level icon was hidden and there was nothing left to fill that gap at all. Adding the missing onCreate() override fixed both.
What a working reference app caught that code review didn't
Even after that fix, one more pass was worth doing: diffing the Android configuration against a separate, already-working app using the same library on the same machine. Four more real gaps surfaced — none of them “double logo” bugs exactly, each a genuine correctness gap against a proven pattern:
- AppTheme was missing android:windowBackground — without it matching the splash background, there's a flash risk right when RNBootSplash hands control back to the normal app theme.
- BootTheme's parent was the CLI's auto-generated Theme.BootSplash chain — valid, but the working reference app builds it directly on Theme.AppCompat.DayNight.NoActionBar with every attribute explicit instead.
- drawable/bootsplash.xml never existed — a direct consequence of the theme choice above: the explicit pattern references it, but the CLI's Theme.BootSplash path never generates it.
- MainActivity.kt was missing the RNScreensFragmentFactory registration required by react-native-screens >= 4.16.0 — an easy, silent mistake, since nothing crashes immediately without it.
The bug nobody reported
One gap wasn't found by a symptom at all — it turned up during a deliberate after-the-fact review, going back over the implementation looking for anything missed rather than reacting to a bug report. npm test had never been run against the full suite during the original implementation, only tsc and eslint on the files that had actually changed. That gap was real.
Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'RNBootSplash' could not be found.
at Object.require (node_modules/react-native-bootsplash/src/index.ts:14:1)
at Object.require (App.tsx:6:1)
at Object.require (__tests__/App.test.tsx:3:1)'use strict';
module.exports = {
hide: jest.fn().mockResolvedValue(undefined),
isVisible: jest.fn().mockReturnValue(false),
useHideAnimation: jest.fn().mockReturnValue({
container: {},
logo: { source: 0 },
brand: { source: 0 },
}),
};Run the full suite, not just the touched files
react-native-bootsplash is a native TurboModule — importing it anywhere in a Jest environment throws immediately without a mock, and nothing about that failure is subtle once the full suite actually runs. It just has to actually run. After adding the mock: 100 suites, 915 tests, all passing, coverage still above every threshold.
What I'd tell myself starting over
- The Android bug is never where the platform documentation says it should be until you've confirmed the compat layer is attached at all. Check the trivial thing — is init() even being called — before researching the sophisticated one.
- Don't let a CLI's auto-generated theme stand unexamined. Theme.BootSplash is valid, but it silently omits things a hand-built theme needs; rebuilding it explicitly on Theme.AppCompat.DayNight.NoActionBar surfaces what was actually missing.
- A named color reference on iOS needs both the semantic name and an inline RGB fallback, always — never let editing one strip the other.
- When someone pushes back — “did you really finish?”, “look for some other fix” — that's not friction, that's the signal that the current hypothesis hasn't earned its confidence yet.
- A green tsc and a green eslint run on the touched files is not the same as a green test suite. Only one of those actually proves nothing broke.
““The fastest way to fix a bug you don't understand yet is to stop proposing fixes and go read the one file you assumed was already correct.”
All of this shipped in one implementation session for masicn's Playground app, running react-native-bootsplash@7.3.2 against React Native's new architecture — cross-referenced against a separately maintained, already-working sibling implementation of the same library, which is where the four Android gaps above came from. The full step-by-step version, warnings and all, lives in the design system's own docs; this is the story of how it got there.
AI-readable content
Learn more →This content is available via the AI Content API as JSON or token-efficient Markdown. Feed it directly into LLM workflows.
/api/content/blogs/the-boot-splash-bug-that-was-never-in-android