+1 (415) 843-4662

Performance tuning Ionic + Capacitor apps: cold start, bundle size and 60fps lists

Every Ionic app we are asked to rescue has the same symptom list: three seconds of white screen before the first paint, a list that stutters when you flick it, and a bundle that grew 40% in a year because nobody was watching. None of that is inherent to Ionic — it is what happens when a web app ships to a device without a performance budget.

This tutorial is the checklist we run on client apps: how to measure cold start on a real device, how to cut the JavaScript that runs before first paint, and how to keep long lists at 60fps. Examples are Angular and React; the Capacitor parts apply to every framework.

1. Measure on a device, not on your laptop

Chrome DevTools on a desktop browser lies to you: your dev machine is ten times faster than the mid-range Android phone most of your users carry. Get real numbers first.

Android. Build a release-ish app and attach DevTools over USB:

npx cap run android --release
# then open chrome://inspect#devices and profile the WebView

For cold start including native startup, use am start with the wait flag:

adb shell pm clear com.example.app
adb shell am start -W -n com.example.app/.MainActivity
# Status: ok
# TotalTime: 1832      <- native process + WebView + your first paint

Run it five times and take the median. That number, not a desktop Lighthouse score, is your cold-start metric.

iOS. Run from Xcode, then Safari → Develop → your device → the WebView and record a timeline. For the native side, Instruments' "App Launch" template shows where the pre-WebView time goes.

In production. The Web Vitals that matter still work inside a WebView. Report them so you see the field distribution, not just your own phone:

import { onLCP, onINP, onCLS } from 'web-vitals';

const send = (m: { name: string; value: number }) =>
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: m.name, value: Math.round(m.value), build: __APP_VERSION__,
  }));

onLCP(send); onINP(send); onCLS(send);

Track the p75 per release. A regression you can see in a chart gets fixed; one you only feel does not.

2. Kill the white screen

Cold start in a Capacitor app is four phases: native process start, WebView creation, loading and parsing your JS, then first meaningful paint. You control the last two.

Hide the splash screen yourself. The default auto-hide fires on a timer, which either flashes an empty app or wastes half a second. Turn it off and hide it when the first screen actually has data:

// capacitor.config.json
{
  "plugins": {
    "SplashScreen": { "launchAutoHide": false, "backgroundColor": "#ffffff" }
  }
}
import { SplashScreen } from '@capacitor/splash-screen';

// after the first route's data has resolved
await SplashScreen.hide({ fadeOutDuration: 150 });

Do not block the first paint on the network. Render the shell from cached data (Preferences or SQLite), then revalidate. In an offline-capable app this is the single biggest perceived-speed win.

Defer everything that is not the first screen. Analytics SDKs, push registration, in-app purchase initialisation, remote config — none of them need to run before paint:

import { defineCustomElements } from '@ionic/pwa-elements/loader';

requestIdleCallback(() => {
  void initAnalytics();
  void registerPush();
  void defineCustomElements(window);
});

Lazy-load routes. In Angular, standalone components with loadComponent keep the initial chunk to the shell:

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home/home.page').then(m => m.HomePage) },
  { path: 'orders', loadChildren: () => import('./orders/routes').then(m => m.ORDER_ROUTES) },
];

In React, lazy() plus a Suspense boundary inside IonRouterOutlet does the same job.

3. Put the bundle on a diet

Look at what you actually ship before you optimise anything:

# Vite
npx vite-bundle-visualizer
# Angular
ng build --configuration production --stats-json \
  && npx esbuild-visualizer --metadata dist/stats.json

The usual offenders, in the order we normally find them:

  • moment / date-fns imported wholesale. Swap to Intl.DateTimeFormat or import single functions. Often 60–200 KB.
  • A whole icon set. Import the icons you use: import { chevronForward, trash } from 'ionicons/icons';
  • Lodash without per-method imports. import debounce from 'lodash-es/debounce'.
  • A chart or PDF library on the initial route. Dynamic-import it inside the component that needs it.
  • Duplicate polyfills, because one dependency drags in its own core-js.

Then lock the win in with a budget that fails the build:

// angular.json
"budgets": [
  { "type": "initial", "maximumWarning": "500kb", "maximumError": "700kb" }
]

For Vite projects a small CI script comparing gzip sizes against main does the same thing. A budget in CI is the only reason a bundle stays small after month three.

4. Lists that do not stutter

Rendering 500 ion-items means 500 web components, each with shadow DOM and its own listeners. Below a hundred rows nobody notices. Above that, virtualise.

Angular:

<ion-content>
  <cdk-virtual-scroll-viewport itemSize="72" minBufferPx="900" maxBufferPx="1350">
    <ion-item *cdkVirtualFor="let o of orders; trackBy: trackById" [detail]="true">
      <ion-label>
        <h2>{{ o.customer }}</h2>
        <p>{{ o.total | currency }}</p>
      </ion-label>
    </ion-item>
  </cdk-virtual-scroll-viewport>
</ion-content>

Two rules for a viewport inside ion-content: give it a real height (height: 100%) so it is the scroll container, and give itemSize the true row height — a wrong value causes the jumpy scrolling people blame on Ionic.

React: @tanstack/react-virtual or react-window inside IonContent, with scrollY={false} on the content so you are not nesting two scrollers.

Other list rules that matter more than framework choice:

  • trackBy / stable key. Without it, every refresh recreates every row.
  • Fixed-size images with loading="lazy" and explicit width/height, so the list does not reflow as thumbnails arrive.
  • No expensive pipes or selectors in the row template. Format once when the data arrives, not on every change-detection pass.
  • ChangeDetectionStrategy.OnPush on row components in Angular; memo() in React.

Angular teams on v17+ should also move hot state to signals: signal-driven templates skip whole-tree change detection, which is exactly the cost you feel while scrolling.

5. Animations and scroll jank

Animate transform and opacity only — those run on the compositor. Animating height, top or width forces layout every frame and is the main reason something is "smooth on iOS and terrible on Android".

/* good */
.sheet { transition: transform 220ms ease-out; will-change: transform; }
/* bad  */
.sheet { transition: height 220ms ease-out; }

Use will-change sparingly; a permanently promoted layer costs memory. If a scroll listener reads layout, wrap the reads in requestAnimationFrame and register the listener with { passive: true }.

6. Native-side wins people forget

  • Ship an Android App Bundle, not a fat APK. Per-ABI and per-density splits routinely cut 30% off download size.
  • Compress and resize images at build time. A 2 MB hero PNG is also 2 MB of decode work on a mid-range device.
  • Audit your live-update strategy. If you fetch an update before first paint you have added a network round trip to cold start. Download in the background and apply on the next launch.
  • Measure before adding complexity. WebView warm-up tricks trade RAM for a few hundred milliseconds; prove you need it.

7. Put a gate in CI so it stays fixed

Performance work rots without a guard rail. The minimum viable gate:

# .github/workflows/perf.yml
- run: npm ci && npm run build
- run: node scripts/check-bundle-size.mjs      # fails over budget
- run: npx lhci autorun --collect.staticDistDir=dist \
        --assert.assertions.categories:performance=0.85

Headless Lighthouse is not a device, but it catches the regression class that matters most: a dependency that suddenly lands in the initial chunk. Pair it with the field p75 from step 1 and you have both sides of the picture.

A realistic target

For a business app on a mid-range Android device, the numbers we aim for:

MetricTarget
Cold start to first meaningful paint< 1.5 s
Initial JS (gzip)< 350 KB
INP (p75, field)< 200 ms
Long-list scrollno frame over 16 ms while flicking

Most apps get most of the way there with three changes: lazy routes, deferred third-party SDKs, and a virtual list on the one screen with thousands of rows. Do those first, then measure again before you reach for anything clever.


HybridMob is an Ionic Trusted Gold Partner. If your Ionic app is slow to start or janky on Android, get in touch — a performance audit is usually a short, fixed-scope engagement.