Skip to content

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.

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.SplashScreenInitialEvent

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.

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.

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.

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.

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.