1. The old startup
1. The old startup
Section titled “1. The old startup”Before reading about the fix, it is worth seeing exactly what was happening — because “the app is slow to start” turned out to be four separate problems stacked on top of each other, and only one of them was about the number of API calls.
The shape of it
Section titled “The shape of it” TAP ICON │ ├─ [native] OS shows launch_background.xml ── WHITE on light-mode Android │ WHITE on iOS (stock template) │ ▼ main() ── lib/main.dart │ │ init() DI registration (cheap, all lazy) │ await initial() ─────────────────────────────────────────┐ │ │ │ await getVersion() PackageInfo channel │ │ Firebase.initializeApp().then() ← the ONLY parallel │ up to │ await getApplicationDocumentsDirectory() │ ~21 s │ Hive.init + 3 registerAdapter │ of │ await _runDownloadsMigrationIfNeeded() full recursive │ timeout │ docs dir scan │ budget │ await _initHive() 2 boxes, one │ │ after the other │ │ await _initBackgroundDownloader() .timeout(6s) │ │ await DownloadManager.initialize() .timeout(15s) │ │ └─ requests Android 13 notification permission ───┤ │ → an OS DIALOG before the first frame exists │ └───────────────────────────────────────────────────────────┘ │ ▼ runApp(MyApp()) ← first frame finally possible │ ▼ SplashScreen ── 2.9 MB / 512×512 / 250-frame GIF starts looping │ │ ...and does nothing at all until ConnectivityCubit emits. ▼ SplashScreenBloc.SplashScreenInitialEventThe chain
Section titled “The chain”Everything below ran in series. Each arrow is a full round trip on a
brand-new TCP + TLS connection, because DioApiProvider constructed
final dio = Dio() inside every verb (doc 2 covers why that matters).
await settingsDataSource.getCurrentUser() SharedPreferences │ ▼ await initialFunctions() │ ├── checkGeoLocation() ← NOT awaited, but every URL below │ │ interpolates its result (country) │ ├── getFCMtoken() ← NOT awaited │ ├── getCacheSettings() ← NOT awaited, and guarded so tightly │ │ it almost never restored anything │ │ │ ├──▶ GET /v2/settings ─────────────────────── ① BLOCKS │ │ (app config + the bottom-nav tabs) │ │ │ └──▶ InternetConnectionChecker().hasConnection ── ② BLOCKS │ multi-address DNS probe, purely to decide │ whether to fire a one-time analytics event ▼ await initDeepLinks(context) app_links channel │ ▼ await profileManagementUseCase() ──▶ GET /user/profiles ── ③ BLOCKS │ │ getCurrentDeviceCount() ← NOT awaited … │ getUser.getUser() ← NOT awaited … │ …but hasReachedDeviceLimit() │ is read a few lines later. A race. ▼ await getHomePage() ─────────────▶ GET /v2/ ────────────── ④ BLOCKS │ the entire home feed ▼ await getContinueWatchHistory() ─▶ GET /continue-watching ── ⑤ BLOCKS │ ▼ checkUpdate() → router.replaceNamed("/main_scaffold") │ ▼ HomeScreenBloc.GetHomePageEvent │ └──▶ await getPopupNotification() ──────────────────── ⑥ BLOCKS …and only THEN emit(HomeScreenLoaded).
The home feed was already sitting in memory (homePageListGlobal, set at step ④). The user stared at a blank black screen for one more round trip that nothing on that frame needed.Six blocking round trips before a single poster appears. Five of them strictly serial.
The four separate problems
Section titled “The four separate problems”1. The transport defeated itself
Section titled “1. The transport defeated itself”lib/core/network/dio_api_provider.dart built a new Dio — and therefore
a new HttpClient, and therefore a new connection — for every call. Six
requests meant six DNS lookups, six TCP handshakes, six TLS handshakes.
On a high-latency mobile network the handshakes cost more than the
payloads.
Worse, connectTimeout was never set on any verb. receiveTimeout
was, but that clock only starts once a connection exists. On a network
that accepts packets and never answers — a captive portal, a congested
cell, a firewalled Wi-Fi — the socket connect falls back to the OS
default, which on iOS can exceed 75 seconds. Five times in a row, in
series. That is the “stuck on the splash forever” bug, and it was one
missing line.
2. Nothing was cached
Section titled “2. Nothing was cached”dio_cache_interceptor was a declared dependency. A full CacheOptions
block was written out. And the single line that would have used it was
commented out:
// dio.interceptors.add(DioCacheInterceptor(options: options));Even had it been enabled, its store was MemCacheStore() — in memory,
per-datasource, gone the moment the process died. Useless for a cold
start by construction.
The one thing that was persisted, the /v2/settings payload, was read
back only when settings.value.analytics_matomo_site_id == null — a
guard narrow enough that a normal launch always went to the network
anyway.
So every cold start re-fetched everything, every time, forever.
3. There was no deadline and no way out
Section titled “3. There was no deadline and no way out”No .timeout() anywhere on the splash path. No watchdog. (Timer? timer
was declared at the top of the bloc and never used.) If a request hung,
the splash hung.
And the escape hatch was broken. checkUpdate() was a void … async
method that awaited PackageInfo.fromPlatform(), so by the time it
reached:
if (navItemListGlobal.isEmpty || homePageListGlobal.isEmpty) { emit(SplashScreenFailed()); // ← handler already completed return;}…the bloc’s event handler had already returned. emit threw
StateError: emit was called after an event handler completed normally.
The “Something went wrong / Try again” screen was unreachable on the
main failure path. The user got the looping GIF instead — which is
exactly why it read as a hang rather than an error.
4. Landing on home started four screens at once
Section titled “4. Landing on home started four screens at once”MainScaffold used a plain IndexedStack, and IndexedStack builds
every child — offstage ones included:
IndexedStack(index: 0, children: [ HomeScreen ← the one the user asked for ShortsScreen ← initState: 100 × VideoPlayerController.file(File("")) then cacheVideo(5) → FIVE full video downloads CategoryScreen ← GET the music tab ProfileSettings ← GET /user/profiles (the second time this launch) ])So at the precise moment home should have been painting, the app was allocating a hundred native player instances and pulling five videos over the same congested connection it was trying to load posters on.
And on top of all of it, the GIF
Section titled “And on top of all of it, the GIF”assets/client-assets/images/App-Intro.gif is 2,899,298 bytes,
512 × 512, 250 frames, 20 ms apart, looping forever. It was drawn
at 220 logical pixels through a bare Image.asset with no cacheWidth,
so every one of those 250 frames was decoded at full resolution — roughly
1 MB of RGBA each — continuously, on the same threads doing all of the
above.
Next: The network layer — fixing the transport, which is where most of the raw latency was hiding.