Ionic Interview Questions and Answers
Last updated:
Check out 35 of the most common Ionic interview questions, then take an AI-powered practice interview
Q1How do Ionic Framework, Capacitor and Cordova divide responsibility in a hybrid app?
BasicFundamentals
Answer
Interviewers want you to split the stack into three separable layers instead of calling all of it 'Ionic'. Ionic Framework is the UI layer: roughly a hundred components (ion-content, ion-button, ion-modal, ion-refresher, ion-datetime) compiled by Stencil into standard custom elements, plus router integrations for Angular, React and Vue and platform-adaptive styling. It renders in a browser engine, never in native UIKit or Android View objects.
Capacitor is the native runtime: it scaffolds a real Xcode project and a real Android Studio project, hosts a WKWebView on iOS and an Android System WebView, loads your compiled web assets from the app bundle, and exposes native capability to JavaScript through a plugin bridge. Cordova is the older runtime Capacitor replaced. The differences that matter: Capacitor treats ios/ and android/ as committed source you are expected to open and edit, has no config.xml and no hook scripts, and can still load most Cordova plugins as a compatibility path.
Nothing forces you to use all three layers. Ionic Framework alone makes a perfectly good PWA with no Capacitor anywhere, and Capacitor happily wraps a React, Vue, Svelte or plain-HTML app that uses zero ion-* components. Candidates who cannot draw this boundary tend to give confused answers later about where a bug lives, whether it is a CSS problem in the WebView, a bridge serialisation problem, or a native permission problem.
// capacitor.config.ts (the contract between web build and native shells)
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'in.example.fieldforce',
appName: 'Field Force',
webDir: 'www', // Angular: dist/<app>/browser on recent builders
android: { allowMixedContent: false },
ios: { contentInset: 'always' },
plugins: {
SplashScreen: { launchAutoHide: false },
},
};
export default config;
Key Points
- Ionic Framework = Stencil-built web components plus router integration
- Capacitor = native runtime, generated ios/ and android/ projects, plugin bridge
- Cordova = predecessor runtime; config.xml and hooks, plugins still usable
- The three layers are independent; Capacitor works without Ionic UI
Q2Why do ordinary CSS selectors fail to style ion-button or ion-content, and what actually works?
BasicStyling
Answer
Most Ionic components are Stencil-generated custom elements that render into shadow DOM. A shadow root is a style boundary: your global stylesheet cannot reach the internal .button-native element inside ion-button, and any rule you write for it is silently dropped. Three mechanisms are supported instead.
First, CSS custom properties, which do pierce shadow boundaries because they inherit. Every Ionic component documents its own set, for example --background, --color, --padding-start, --border-radius on ion-button, and --background plus --padding-top on ion-content. Second, shadow parts: components expose ::part(native), ::part(icon) and similar hooks, so ion-button::part(native) { border-radius: 4px; } reaches inside legitimately.
Third, the global theme variables in src/theme/variables.css, --ion-color-primary and its -shade, -tint, -contrast and RGB companions, which every component reads. There are also utility classes such as ion-padding, ion-text-center and ion-hide that apply to the host element and work normally. The common production failure is a developer using browser DevTools, finding an internal class name, writing a rule against it, and shipping something that breaks on the next Ionic minor upgrade because internal markup is not a public API. Also note that not every component uses shadow DOM: some use scoped encapsulation, where styles are attribute-scoped rather than truly isolated, which is why a selector that works on ion-item might not work on ion-button.
/* theme/variables.css: global palette every component reads */
:root {
--ion-color-primary: #0b6bcb;
--ion-color-primary-rgb: 11, 107, 203;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-shade: #0a5eb3;
--ion-color-primary-tint: #237ad0;
}
/* Supported: custom properties + shadow parts */
ion-button.checkout {
--background: var(--ion-color-primary);
--border-radius: 8px;
--padding-start: 20px;
}
ion-button.checkout::part(native) {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
/* Not supported: reaching into shadow internals */
/* ion-button .button-native { ... } <- silently ignored */
Key Points
- Shadow DOM blocks external selectors from reaching internal markup
- CSS custom properties inherit through shadow boundaries: --background, --color
- ::part(native) is the supported hook for internal elements
- Never target internal class names; they change between releases
Q3What does Ionic's `mode` setting control, and how do you force iOS or Material styling?
BasicPlatform Styling
Answer
Ionic ships two design languages: 'ios', which mimics Apple's Human Interface look, and 'md', which follows Material Design. At runtime Ionic reads the user agent, picks a mode, and stamps a class of ios or md on the html element and on every component host. Mode changes far more than colour: it swaps transition animations (slide-from-right on iOS versus fade-up on Material), back-button icons, header and title alignment, ripple effects, alert and action-sheet layouts, and default spacing.
You can override it in three places. Globally at bootstrap, IonicModule.forRoot({ mode: 'md' }) in Angular, setupIonicReact({ mode: 'md' }) in React, or the mode option passed to IonicVue. Per component, by setting the mode attribute on a single element.
And via the URL query parameter ?ionic:mode=ios during development, which is handy for screenshot comparisons. Product teams in India frequently force md on both platforms so a single design system matches the web app and the QA team stops filing platform-difference bugs. The trade-off is that iOS users lose the familiar edge-swipe-back feel and Apple reviewers occasionally flag apps that look nothing like an iOS app under guideline 4.2. If you do force a mode, do it once at bootstrap rather than sprinkling mode attributes, because a mixed app where the header is md and the alert is ios looks broken to users and is painful to debug.
// Angular bootstrap
import { IonicModule } from '@ionic/angular';
@NgModule({
imports: [IonicModule.forRoot({ mode: 'md', animated: true })],
})
export class AppModule {}
// React bootstrap
import { setupIonicReact } from '@ionic/react';
setupIonicReact({ mode: 'md' });
// Per-component override in a template
// <ion-searchbar mode="ios"></ion-searchbar>
Key Points
- mode = 'ios' or 'md'; controls animation, icons, layout, not just colour
- Set globally via IonicModule.forRoot / setupIonicReact, or per component
- ?ionic:mode=md query param is a quick dev-time override
- Forcing one mode gives design consistency but loses platform-native feel
Q4Explain the Ionic page lifecycle events and how they differ from ngOnInit or useEffect.
BasicLifecycle
Answer
Ionic adds four page-level events on top of the framework's own lifecycle: ionViewWillEnter, ionViewDidEnter, ionViewWillLeave and ionViewDidLeave. Angular also gets ionViewWillUnload. They exist because Ionic's router outlet keeps pages alive in the DOM rather than destroying them.
When you navigate from a list page to a detail page, the list page is not destroyed, it is pushed down the navigation stack and hidden. So ngOnInit fires exactly once, the first time the component is created, and never again when the user comes back. If you refresh data in ngOnInit, the list will be stale on every back navigation. ionViewWillEnter fires every time the page becomes active, which is where refresh logic belongs. ionViewDidEnter fires after the transition animation completes, which is the right place for anything expensive or focus-related, because doing it in WillEnter stutters the animation. ionViewWillLeave is where you pause timers, stop video, unsubscribe from a high-frequency stream or cancel a geolocation watch.
In React and Vue the same hooks exist as useIonViewWillEnter, useIonViewDidEnter and their leave counterparts. A classic interview trap: the candidate is asked why a page shows old data after pressing back and answers 'change detection', when the real reason is page caching in the router outlet plus initialisation logic placed in the wrong hook.
import { Component, OnInit } from '@angular/core';
@Component({ selector: 'app-orders', templateUrl: './orders.page.html' })
export class OrdersPage implements OnInit {
orders: Order[] = [];
private pollId?: ReturnType<typeof setInterval>;
ngOnInit() {
// Runs once for the lifetime of the cached page
this.setupColumns();
}
ionViewWillEnter() {
// Runs every time the page is shown, including on back navigation
this.loadOrders();
}
ionViewDidEnter() {
this.pollId = setInterval(() => this.loadOrders(), 30_000);
}
ionViewWillLeave() {
clearInterval(this.pollId); // otherwise it polls forever in the background
}
}
Key Points
- Pages are cached, not destroyed, so ngOnInit runs only once
- ionViewWillEnter runs on every entry: put refresh logic here
- ionViewDidEnter runs after the animation: put expensive work here
- ionViewWillLeave is the place to pause timers, video and watchers
Q5What do IonPage and IonRouterOutlet do, and what breaks if a routed view is not wrapped in IonPage?
BasicNavigation
Answer
IonRouterOutlet is the stack-aware replacement for a plain router outlet. Instead of swapping one view for another, it keeps a stack of pages in the DOM, animates the incoming page over the outgoing one, and preserves the previous page's scroll position and component state for the back transition. IonPage (or the ion-page class Angular applies automatically to routed components) is the element the outlet manipulates.
It carries absolute positioning, full-viewport sizing and the transform the animation drives. If a React or Vue route renders a bare div instead of an IonPage, the symptoms are immediate and confusing: transitions do not animate, the previous page stays visible underneath, headers overlap, ion-content does not size correctly and scroll breaks. In Angular, IonRouterOutlet also drives the back button in ion-back-button by inspecting the stack, so a view outside the outlet renders a back button that goes nowhere.
The stack model also explains behaviour candidates find surprising, such as ngOnDestroy not firing on forward navigation, and memory growing as the user drills deeper. Ionic keeps a bounded number of pages, and popping back to a page removes the pages above it. In tabs, each tab gets its own outlet and therefore its own independent stack, which is why switching tabs preserves where you were in each one.
// React: every routed component must render an IonPage at its root
import { IonPage, IonHeader, IonToolbar, IonTitle, IonContent } from '@ionic/react';
export const OrderDetail: React.FC = () => (
<IonPage>
<IonHeader>
<IonToolbar>
<IonTitle>Order</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent className="ion-padding">...</IonContent>
</IonPage>
);
// Router wiring
// <IonReactRouter>
// <IonRouterOutlet>
// <Route path="/orders/:id" component={OrderDetail} exact />
// </IonRouterOutlet>
// </IonReactRouter>
Key Points
- IonRouterOutlet maintains a stack of pages, not a single swapped view
- IonPage supplies the positioning and transform the transition animates
- Missing IonPage causes overlapping headers, dead transitions, broken scroll
- Each tab owns a separate outlet and therefore a separate navigation stack
Q6How does routerDirection work in Ionic Angular, and when do you use NavController instead of routerLink?
BasicNavigation
Answer
Ionic Angular reuses the standard Angular Router, then layers stack semantics on top through a routerDirection input. routerLink alone tells the router where to go; routerDirection tells IonRouterOutlet how to animate and how to mutate the stack. 'forward' pushes a page and animates it in from the trailing edge. 'back' pops, animating the current page out and reusing the cached page underneath. 'root' clears the stack entirely, which is what you want after a login redirect so the hardware back button does not return the user to the login screen. NavController is the imperative equivalent for code paths where you are not clicking a link: navigateForward, navigateBack and navigateRoot, each accepting the same URL forms Angular's router accepts plus animation options. Use routerLink in templates because it renders a real anchor and keeps accessibility intact, and use NavController inside guards, effects, interceptors and after async work.
The bug this question is really testing is the post-login back button. A team calls router.navigate(['/home']) after login, the stack still contains /login, the user presses back on Android and lands on the login form of an already-authenticated session. navigateRoot fixes it. The same reasoning applies after completing a checkout or a multi-step form: collapse the stack so back does not replay a completed flow.
import { NavController } from '@ionic/angular';
@Injectable({ providedIn: 'root' })
export class AuthFlow {
constructor(private nav: NavController) {}
async onLoginSuccess() {
// Clears /login off the stack so hardware back cannot return to it
await this.nav.navigateRoot('/tabs/home', { animated: true });
}
async openInvoice(id: string) {
await this.nav.navigateForward(['/invoices', id]);
}
}
// Template equivalent
// <ion-button [routerLink]="['/invoices', id]" routerDirection="forward">Open</ion-button>
Key Points
- routerDirection: 'forward' | 'back' | 'root' controls animation and stack
- NavController.navigateRoot clears the stack after login or checkout
- Prefer routerLink in templates, NavController in guards and async code
- Wrong direction is the usual cause of back-button-returns-to-login bugs
Q7What is the difference between npx cap copy, npx cap update and npx cap sync, and when must each run?
BasicCapacitor CLI
Answer
These three commands cover different halves of the same job. npx cap copy takes your already-built web assets from webDir and copies them into the native projects, iOS App/public and Android app/src/main/assets/public, and also regenerates the native config from capacitor.config.ts. It does not touch dependencies. npx cap update reads package.json, discovers installed Capacitor and Cordova plugins, and updates the native dependency lists: Podfile and pod install on iOS, Gradle module includes on Android. npx cap sync is simply copy followed by update, and is the command most teams alias in a build script. The rule that matters in practice: web code changed means build then copy; a plugin was installed or removed, or capacitor.config.ts changed, means sync.
Two failure modes come up constantly. First, running cap copy without running the web build first, so the native app ships whatever was in webDir from the previous build; symptom is 'my fix is not in the APK'. Second, installing a plugin and only running copy, so JavaScript calls a bridge method with no native implementation and you get an 'not implemented on android' error at runtime.
Wire it into npm scripts so nobody has to remember. Also remember npx cap open ios and npx cap open android to launch the native IDEs, and npx cap doctor when a project has drifted.
// package.json
{
"scripts": {
"build:mobile": "ionic build --prod && npx cap sync",
"android": "npm run build:mobile && npx cap open android",
"ios": "npm run build:mobile && npx cap open ios",
"live": "ionic cap run android -l --external"
}
}
// Diagnose a drifted project
// npx cap doctor
// npx cap ls // lists plugins discovered per platform
Key Points
- copy = web assets plus config into native projects
- update = native dependencies for installed plugins (pods, Gradle)
- sync = copy + update; run it after any plugin or config change
- Always run the web build first; copy does not build anything
Q8How do you implement dark mode in Ionic 8, and what changed from the older .dark class approach?
BasicTheming
Answer
Ionic 8 reorganised dark mode into explicit palette stylesheets instead of the loose .dark class convention older projects used. You now import one of three palette files depending on the behaviour you want. dark.system.css follows the OS setting using prefers-color-scheme with no code. dark.class.css activates only when the ion-palette-dark class is present on the html element, which is what you use when the app has its own theme toggle. dark.always.css forces dark unconditionally. The class name itself changed from .dark to .ion-palette-dark, which is the most common breakage when upgrading a v7 project: the import path changes, the class changes, and dark mode silently stops applying.
Under the hood these palettes simply redefine the --ion-color-* and --ion-background-color / --ion-text-color variables, so anything you themed with custom properties adapts automatically, while anything hardcoded to #fff does not. A pattern that works well in production is the class palette plus a stored preference: read the saved choice at startup, fall back to matchMedia('(prefers-color-scheme: dark)'), toggle the class, and persist changes with Capacitor Preferences. On native you should also update the status bar style, otherwise dark text sits on a dark bar. If the interviewer asks about a stubborn white flash on launch, the answer is usually the native splash background and the WebView background colour, not CSS.
/* global.scss */
@import '@ionic/angular/css/palettes/dark.class.css';
// theme.service.ts
import { Preferences } from '@capacitor/preferences';
import { StatusBar, Style } from '@capacitor/status-bar';
export async function applyTheme() {
const { value } = await Preferences.get({ key: 'theme' });
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = value ? value === 'dark' : prefersDark;
document.documentElement.classList.toggle('ion-palette-dark', dark);
try {
await StatusBar.setStyle({ style: dark ? Style.Dark : Style.Light });
} catch {
/* web build: plugin unavailable, ignore */
}
}
Key Points
- Ionic 8 palettes: dark.system.css, dark.class.css, dark.always.css
- Class renamed from .dark to .ion-palette-dark
- Palettes only redefine CSS variables; hardcoded hex values will not adapt
- Update StatusBar style alongside the theme on native builds
Q9How do ion-refresher and ion-infinite-scroll work, and what is the correct way to signal completion?
BasicComponents
Answer
Both components hand you an event whose target exposes a complete() method, and forgetting to call it is the single most common bug with them. ion-refresher sits as the first child of ion-content and renders the pull-to-refresh spinner. On ionRefresh you reload data and then call event.target.complete(), which retracts the spinner. If your reload throws, and you only call complete() in the success path, the spinner stays stuck on screen forever, so complete() belongs in a finally block. ion-infinite-scroll goes at the end of ion-content, fires ionInfinite when the user scrolls within a threshold of the bottom (default '15%'), and again needs complete() so it can re-arm.
When there is nothing left to load you set disabled = true, otherwise it fires repeatedly against an empty result set and hammers your API. Two details interviewers like: on iOS, pull-to-refresh needs ion-content to be scrollable and can conflict with a nested scroll container, and on Android the native overscroll glow may need suppressing. Also note that ion-refresher's pullFactor, pullMin and pullMax let you tune feel, and that infinite scroll fires based on scroll position, so if your first page does not fill the viewport it may never trigger; the fix is to load until the content overflows or to use a page size large enough to fill a tall screen.
async doRefresh(ev: CustomEvent) {
try {
this.items = await this.api.fetchPage(0);
this.page = 0;
this.infinite.disabled = false;
} finally {
(ev.target as HTMLIonRefresherElement).complete(); // never skip this
}
}
async loadMore(ev: CustomEvent) {
const next = await this.api.fetchPage(++this.page);
this.items = [...this.items, ...next];
const el = ev.target as HTMLIonInfiniteScrollElement;
await el.complete();
if (next.length === 0) el.disabled = true; // stop firing on an empty tail
}
Key Points
- Always call event.target.complete(), in a finally block
- Set infinite scroll disabled = true when the data source is exhausted
- ion-refresher must be the first child of ion-content
- A first page that does not fill the viewport never triggers ionInfinite
Q10What is @capacitor/preferences backed by, and when is it the wrong storage choice?
BasicStorage
Answer
Capacitor Preferences is a tiny key-value API backed by UserDefaults on iOS and SharedPreferences on Android, with localStorage as the web fallback. Values are strings, so anything structured has to be JSON.stringify'd on the way in and parsed on the way out. It is the right tool for a handful of small, non-sensitive settings: theme choice, onboarding-completed flag, last selected branch, a feature-flag override.
It is the wrong tool in four cases interviewers like to probe. First, secrets. Preferences is not encrypted; on a rooted or jailbroken device the plist and XML files are readable, so refresh tokens belong in Keychain and Keystore through a secure-storage plugin.
Second, volume. It is designed for kilobytes, not megabytes; large blobs bloat memory because both platform stores load into memory, and iOS UserDefaults is genuinely slow past a few hundred keys. Third, structured queries.
If you need filtering, sorting or joins, use SQLite through a plugin, or IndexedDB. Fourth, files. Images, PDFs and exports belong in the Filesystem plugin, and you reference them by path.
One more practical point: on web the localStorage fallback can be cleared by the browser, and on iOS a WebView's localStorage can be evicted under storage pressure, which is another reason not to treat Preferences as durable state. Also remember that every method is async and returns a promise even though the underlying platform APIs are synchronous.
import { Preferences } from '@capacitor/preferences';
export const settings = {
async set<T>(key: string, value: T) {
await Preferences.set({ key, value: JSON.stringify(value) });
},
async get<T>(key: string, fallback: T): Promise<T> {
const { value } = await Preferences.get({ key });
if (value == null) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback; // corrupted or legacy plain-string value
}
},
remove: (key: string) => Preferences.remove({ key }),
};
Key Points
- UserDefaults on iOS, SharedPreferences on Android, localStorage on web
- Strings only; JSON.stringify structured values yourself
- Not encrypted: tokens belong in Keychain / Keystore via secure storage
- Use SQLite or IndexedDB for volume and queries, Filesystem for blobs
Q11When should you present an overlay with a controller versus the inline isOpen pattern?
BasicOverlays
Answer
Ionic offers two ways to show modals, popovers, alerts, toasts, loading indicators and action sheets. The controller API, ModalController.create() followed by present(), builds the component imperatively, returns a handle, and lets you await onDidDismiss() to receive data back. The inline API renders <ion-modal [isOpen]='flag'> in the template and lets state drive visibility.
Controllers suit flows: a confirmation triggered inside a service, a modal whose result feeds the next step, a loading spinner wrapped around an HTTP call. Inline suits declarative UI where a signal or a piece of component state naturally owns the open state, and it plays better with React and Vue idioms. The failure modes differ.
With controllers, the classic leak is a loading indicator created inside a try block whose dismiss() lives only in the success path, so a network error leaves the app frozen behind a spinner; always dismiss in finally, and consider a timeout. Also, calling present() twice without dismissing stacks overlays, and users report a 'ghost dialog' they cannot escape. With inline modals, the common bug is forgetting to reset isOpen in the ionModalDidDismiss handler, so a swipe-down dismissal leaves your state saying open while the DOM says closed, and the next open does nothing. For iOS card-style modals you also need presentingElement set to the routing outlet element, otherwise the sheet does not stack over the page correctly.
import { ModalController, LoadingController } from '@ionic/angular';
async editProfile() {
const modal = await this.modalCtrl.create({
component: ProfileEditComponent,
componentProps: { userId: this.userId },
presentingElement: await this.routerOutlet.parentOutlet?.nativeEl,
breakpoints: [0, 0.5, 1],
initialBreakpoint: 0.5,
});
await modal.present();
const { data, role } = await modal.onDidDismiss();
if (role === 'confirm') this.profile = data;
}
async save() {
const loading = await this.loadingCtrl.create({ message: 'Saving...' });
await loading.present();
try {
await this.api.save(this.profile);
} finally {
await loading.dismiss(); // finally, or a failed request freezes the UI
}
}
Key Points
- Controller API returns a handle and onDidDismiss data; good for flows
- Inline isOpen is state-driven; good for declarative React and Vue code
- Dismiss loading overlays in finally, never only on success
- Reset isOpen in the dismiss handler or the modal will not reopen
Q12What changed between ionChange and ionInput in Ionic 7, and how does it affect existing forms?
BasicForms
Answer
Before Ionic 7, ion-input and ion-textarea fired ionChange on every keystroke, which most developers used as a keypress hook. Ionic 7 aligned the components with native HTML semantics: ionChange now fires when the value is committed, on blur or when the user presses Enter, while ionInput fires on every character. This is a breaking behaviour change that does not break the build, so it slips through code review and shows up as a bug report instead.
Typical symptoms: a search box that only searches after the field loses focus, a character counter frozen until blur, a live-validation message that appears one interaction late, or an autosave that never fires because the user taps a button without blurring the field first. The fix is mechanical: anything that should react per keystroke moves to ionInput, and anything that should react to a settled value (analytics, a network call, form dirty tracking) stays on ionChange. Angular reactive forms and ngModel continue to work because Ionic's ControlValueAccessor was updated alongside, so valueChanges still emits as you type.
Ionic 8 continued this direction on the form components generally: labels moved onto the input via the label and labelPlacement properties instead of a separate ion-label inside ion-item, and the older helper-text markup was replaced with helperText and errorText properties. When migrating, grep the codebase for ionChange on inputs and textareas before you upgrade, not after QA finds it.
<!-- Ionic 8 form field: label lives on the input, not a separate ion-label -->
<ion-input
label="Mobile number"
labelPlacement="floating"
type="tel"
inputmode="numeric"
maxlength="10"
helperText="10-digit number without +91"
errorText="Enter a valid mobile number"
(ionInput)="onType($event)"
(ionChange)="onCommitted($event)">
</ion-input>
<!-- onType -> runs on every keystroke (live validation, counters)
onCommitted -> runs on blur or Enter (analytics, autosave) -->
Key Points
- Ionic 7: ionChange on inputs fires on commit (blur or Enter), not per keystroke
- ionInput is the per-keystroke event now
- Silent behaviour change: search-as-you-type and counters break without errors
- Ionic 8 moved labels onto the input via label / labelPlacement props
Q13How do you debug an Ionic app running on a physical Android or iOS device?
BasicDebugging
Answer
The WebView is a real browser engine, so you get real DevTools. On Android, enable USB debugging on the device, connect it, open chrome://inspect#devices in desktop Chrome and click inspect next to your app's WebView; you get the full console, network panel, DOM inspector and performance profiler. This requires the app to be debuggable, which is true for debug builds and for release builds only if WebView.setWebContentsDebuggingEnabled was left on, something you must not ship.
On iOS, enable Web Inspector in Settings, Safari, Advanced on the device, then use Safari's Develop menu on the Mac to attach; on recent iOS versions the app must be a development build. For faster iteration use live reload, ionic cap run android -l --external, which builds, injects a dev server URL into the native config and reloads on save; the same works for iOS with a device on the same network. Do not forget that a live-reload build has server.url pointing at your laptop, so never ship one.
Beyond DevTools, native-side problems need native tools: logcat for Android crashes and plugin exceptions, Xcode's console and Instruments for iOS. Bridge errors typically surface as a rejected promise with a message from the plugin, so wrap plugin calls in try/catch during development and log the error rather than letting it disappear into an unhandled rejection. Add a remote logger for production because you cannot attach DevTools to a user's phone.
# Live reload on a real device (same Wi-Fi network)
ionic cap run android -l --external
ionic cap run ios -l --external
# Native logs while the app runs
adb logcat | grep -i -E 'Capacitor|chromium|AndroidRuntime'
# Reset a wedged native build
npx cap sync android && cd android && ./gradlew clean
Key Points
- Android: chrome://inspect#devices attaches full Chrome DevTools
- iOS: enable Web Inspector on device, attach via Safari Develop menu
- ionic cap run <platform> -l --external gives live reload on device
- Native crashes need logcat or Xcode console, not the JS console
Q14When is Ionic the right choice against React Native or Flutter, and when is it clearly the wrong one?
BasicTechnology Choice
Answer
Ionic wins when the UI is fundamentally forms, lists, dashboards and content, when you already have a web team and possibly a web app to share code with, and when you need the same codebase to also run as a PWA or inside a desktop shell. Internal tools, field-force and sales apps, healthcare data capture, insurance and BFSI onboarding flows, catalogue and ordering apps: these are where Indian services teams ship Ionic every week, because one Angular or React developer covers web, Android and iOS and the delivery cost drops sharply. The bundle is web code, so hot reload, browser DevTools and existing npm libraries all work, and hiring is easier since the skill floor is web development rather than Swift or Kotlin.
Ionic is the wrong choice when the product's value is in the rendering itself: 60fps games, camera-heavy or AR experiences, real-time video editing, complex gesture-driven canvases, or anything needing sustained heavy computation on the main thread. React Native and Flutter render with native or Skia-backed widgets and do not pay WebView costs on scroll, animation and long lists. Ionic is also weaker when the app must feel indistinguishable from a native app to a discerning consumer audience, and when you need deep platform integrations the moment Apple or Google ship them, because you wait for a plugin or write one yourself. The honest interview answer names both sides rather than defending Ionic reflexively.
Key Points
- Ionic fits form, list and content apps with an existing web team
- One codebase covers Android, iOS, PWA and desktop shells
- Weak for games, AR, heavy canvas work and sustained main-thread compute
- Bleeding-edge OS features need a plugin, which may not exist yet
Q15How does the standalone component setup in @ionic/angular/standalone differ from IonicModule.forRoot, and why does it change bundle size?
IntermediateAngular Integration
Answer
Classic Ionic Angular apps import IonicModule into every feature module. That single import declares the entire component set, so the bundler sees a live reference to all hundred-odd components and cannot drop the ones you never render. Ionic 7 introduced a second entry point, @ionic/angular/standalone, which exports each component as its own standalone Angular component: IonHeader, IonToolbar, IonContent, IonButton and so on.
You list only the components a page actually uses in its imports array, and the build tree-shakes the rest. On a small app the saving is modest; on a large one it is a meaningful chunk of the initial bundle, which matters because Ionic apps also ship as PWAs. Bootstrapping changes too: bootstrapApplication with provideIonicAngular({ mode: 'md' }) replaces IonicModule.forRoot, and the config object accepts the same keys.
Two rules trip people up. First, you cannot mix the two entry points in one application; importing both '@ionic/angular' and '@ionic/angular/standalone' registers the custom elements twice and you get a CustomElementRegistry redefinition error at runtime. Second, icons are no longer resolved automatically.
With standalone you must call addIcons({ cartOutline, personCircleOutline }) from 'ionicons', usually once per component or once at bootstrap, or every ion-icon renders as an empty box with no console error. Interviewers ask this to see whether you have actually migrated a project rather than only scaffolded a new one, because the migration is mechanical but the icon and double-registration failures are silent.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideIonicAngular } from '@ionic/angular/standalone';
import { routes } from './app/app.routes';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideIonicAngular({ mode: 'md' }), provideRouter(routes)],
});
// orders.page.ts
import { IonContent, IonHeader, IonTitle, IonToolbar, IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { cartOutline } from 'ionicons/icons';
@Component({
standalone: true,
imports: [IonContent, IonHeader, IonTitle, IonToolbar, IonIcon],
templateUrl: './orders.page.html',
})
export class OrdersPage {
constructor() {
addIcons({ cartOutline }); // without this the icon slot stays empty
}
}
Key Points
- IonicModule pulls in every component and blocks tree shaking
- @ionic/angular/standalone exports each component individually
- provideIonicAngular replaces IonicModule.forRoot in bootstrapApplication
- addIcons() is mandatory with standalone or ion-icon renders blank
Q16ion-virtual-scroll is gone. How do you render a 5,000-row list in Ionic without dropping frames?
IntermediatePerformance
Answer
ion-virtual-scroll was deprecated in Ionic 6 and removed in Ionic 7, so the framework no longer ships a windowing component. You bring your own. In Angular the standard answer is cdk-virtual-scroll-viewport from @angular/cdk/scrolling; in React, react-window or @tanstack/react-virtual; in Vue, vue-virtual-scroller.
The detail interviewers look for is how you wire the viewport inside ion-content, because both want to be the scroll container. The working pattern is to disable ion-content scrolling with [scrollY]="false" and let the CDK viewport own the scroll, giving it an explicit height of 100%. If you skip that, you get a nested scroller, broken pull-to-refresh and an infinite scroll that never fires.
Windowing alone is not enough. Every ion-item is a custom element with its own shadow root, so a naive 5,000-row list is tens of thousands of nodes plus thousands of style recalculations, which is why the list stutters long before memory becomes a problem. Reduce per-row cost: use trackBy or stable React keys so rows are recycled rather than recreated, run the list component OnPush, avoid ion-item-sliding on every row (each one instantiates gesture handlers), and prefer ion-img over img because it defers loading until the row is near the viewport.
Also remember ion-content does not emit scroll events unless you set scrollEvents="true", and turning it on for a big list adds work on every frame, so only enable it if you genuinely need scroll position. Measure with Chrome DevTools attached over chrome://inspect rather than guessing.
<!-- Angular: CDK viewport owns scrolling, ion-content steps aside -->
<ion-content [scrollY]="false">
<cdk-virtual-scroll-viewport itemSize="64" style="height: 100%">
<ion-item *cdkVirtualFor="let c of candidates; trackBy: trackById" lines="full">
<ion-avatar slot="start">
<ion-img [src]="c.photoUrl"></ion-img>
</ion-avatar>
<ion-label>
<h2>{{ c.name }}</h2>
<p>{{ c.title }}</p>
</ion-label>
</ion-item>
</cdk-virtual-scroll-viewport>
</ion-content>
<!-- component -->
<!-- trackById(_: number, c: Candidate) { return c.id; } -->
Key Points
- ion-virtual-scroll removed in Ionic 7; use CDK virtual scroll or react-window
- Set ion-content [scrollY]="false" so the viewport owns the scroll
- Each ion-item carries a shadow root: cut per-row component cost
- scrollEvents is off by default and costs frame time when enabled
Q17Walk through what happens when JavaScript calls a Capacitor plugin method, and what cannot cross the bridge.
IntermediateCapacitor Bridge
Answer
registerPlugin() returns a proxy object. Calling a method on it serialises the arguments to JSON and posts a message to native: on iOS through a WKScriptMessageHandler registered on the WKWebView's user content controller, on Android through an @JavascriptInterface object exposed to the WebView. Native looks up the plugin by name, finds the method by its @PluginMethod (Kotlin) or @objc CAPPluginMethod (Swift) registration, runs it (by default off the UI thread on Android, and you must hop back for anything touching UI), then resolves or rejects a PluginCall whose result is posted back into the WebView and matched to the original promise by call id.
Three consequences matter in production. Everything crossing the bridge is JSON, so functions, class instances, Blob, File and ArrayBuffer do not survive; binary has to be base64, which doubles size and costs a main-thread JSON parse, and that is exactly why CameraResultType.Uri exists instead of always handing you Base64. Every call is asynchronous even when the native API is synchronous, so you cannot read a plugin value during a synchronous render pass.
Errors arrive as rejected promises carrying a message and often a code such as UNIMPLEMENTED, so wrap calls in try/catch rather than letting them become unhandled rejections. Listeners are the usual leak: addListener returns a promise for a handle, so you must await it before calling remove(), and pages that subscribe in ionViewWillEnter without removing in ionViewWillLeave accumulate duplicate handlers, which shows up as an event firing three times after the user has visited a page three times.
import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { Network } from '@capacitor/network';
let sub: PluginListenerHandle | undefined;
async ionViewWillEnter() {
if (!Capacitor.isNativePlatform()) return; // web build has no native impl
sub = await Network.addListener('networkStatusChange', (s) => {
this.online = s.connected;
});
}
ionViewWillLeave() {
sub?.remove(); // duplicate listeners are the classic Capacitor leak
sub = undefined;
}
// Typed handle to a custom plugin
interface UpiPlugin { pay(o: { vpa: string; amount: number }): Promise<{ txnId: string }>; }
const Upi = registerPlugin<UpiPlugin>('Upi');
Key Points
- JS proxy to WKScriptMessageHandler / @JavascriptInterface, matched by call id
- Only JSON crosses: no Blob, no ArrayBuffer, binary must be base64
- Every call is async; native errors become rejected promises with a code
- await addListener() to get the handle, and remove it on page leave
Q18What origin does an Ionic app run under on iOS and Android, and how does that break CORS, cookies and localStorage?
IntermediateWebView
Answer
Capacitor does not load your app from file://. On iOS it serves the bundled assets under capacitor://localhost through a custom WKURLSchemeHandler; on Android it serves them under https://localhost using WebViewAssetLoader. Both are configurable through server.iosScheme, server.androidScheme and server.hostname in capacitor.config.ts.
Three things follow. First, every request to your API is cross-origin, so the backend must send Access-Control-Allow-Origin values that include capacitor://localhost and https://localhost, and a wildcard will not work once you send credentials. Second, cookie-based sessions are painful: the cookie is third-party relative to the app origin, so it needs SameSite=None; Secure, and iOS Intelligent Tracking Prevention can still drop it.
Token in an Authorization header, stored in secure storage, is the setup that survives both platforms. Third, and this is the one that becomes an incident: web storage is keyed by origin, so changing androidScheme or hostname in a released app moves every user to a fresh origin and their localStorage, IndexedDB and cookies vanish. Users get logged out and lose offline data on update, and there is no migration path other than reading the old origin before you switch.
Related config worth naming: server.cleartext for plain http during development, server.url which points the WebView at a remote dev server and must never ship, and android allowMixedContent. If CORS cannot be fixed on the backend, enabling CapacitorHttp routes requests through the native HTTP stack, where the same-origin policy does not apply at all.
// capacitor.config.ts
const config: CapacitorConfig = {
appId: 'in.example.app',
appName: 'Example',
webDir: 'www',
server: {
androidScheme: 'https', // changing this later resets user storage
iosScheme: 'capacitor',
hostname: 'localhost',
cleartext: false,
allowNavigation: ['api.example.in', '*.razorpay.com'],
},
};
// Express side
app.use(cors({
origin: ['capacitor://localhost', 'https://localhost', 'http://localhost:8100'],
credentials: true,
}));
Key Points
- iOS: capacitor://localhost, Android: https://localhost, both configurable
- API calls are cross-origin; backend needs explicit allowed origins
- Cookies need SameSite=None; Secure, and iOS may still block them
- Changing androidScheme post-release wipes localStorage and IndexedDB
Q19When would you enable CapacitorHttp, and what stops working once you do?
IntermediateNetworking
Answer
Setting plugins.CapacitorHttp.enabled to true in capacitor.config.ts makes Capacitor patch window.fetch and XMLHttpRequest on native platforms so that requests leave through URLSession on iOS and OkHttp on Android instead of the WebView. The wins are real: the same-origin policy does not apply, so a backend you cannot get CORS headers added to suddenly works; you get the native cookie jar; and requests are visible to native networking configuration such as certificate pinning and proxy settings. Teams in services companies reach for it constantly because the client's API team will not add capacitor://localhost to an allowlist.
The costs are equally real and this is what a good interviewer probes. Streaming is gone: response.body is not a usable ReadableStream, so server-sent events, chunked transfer and progressive JSON break. Upload and download progress events on XHR no longer fire the way library code expects, which breaks some file-upload widgets.
Binary responses come back base64-encoded and must be decoded in JS. Header casing and multi-value headers can differ from the WebView. And because requests never touch the WebView, they disappear from the Chrome DevTools network panel, so debugging shifts to native logs.
The pragmatic pattern is not to patch globally. Leave the global flag off and call CapacitorHttp.request explicitly from the one service that needs it, keeping normal fetch for everything else, or gate it behind Capacitor.isNativePlatform() so the web build and your test suite keep the standard fetch semantics.
// Global patch (blunt instrument)
// capacitor.config.ts
// plugins: { CapacitorHttp: { enabled: true } }
// Targeted use, keeps normal fetch everywhere else
import { CapacitorHttp } from '@capacitor/core';
import { Capacitor } from '@capacitor/core';
export async function getReport(id: string, token: string) {
if (!Capacitor.isNativePlatform()) {
const r = await fetch(`/api/reports/${id}`, { headers: { Authorization: token } });
return r.json();
}
const res = await CapacitorHttp.request({
method: 'GET',
url: `https://api.example.in/reports/${id}`,
headers: { Authorization: token },
readTimeout: 15000,
connectTimeout: 10000,
});
if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
return res.data; // already parsed when content-type is JSON
}
Key Points
- Patches fetch and XHR to native URLSession / OkHttp; bypasses CORS
- Breaks streaming, SSE and XHR progress events
- Binary arrives base64; requests vanish from DevTools network panel
- Prefer explicit CapacitorHttp.request over the global patch
Q20The keyboard hides the focused input on Android but not iOS. How do you diagnose and fix it?
IntermediateKeyboard
Answer
Keyboard behaviour is one of the few areas where the two platforms genuinely differ, so the fix is per-platform. On Android the WebView only shrinks if the hosting activity is configured to resize, which means android:windowSoftInputMode="adjustResize" on the MainActivity in AndroidManifest.xml. If the activity is fullscreen or drawing edge-to-edge, resizing may not happen at all and you also need the Keyboard plugin's resizeOnFullScreen option.
On iOS the WKWebView resizes by default, controlled by @capacitor/keyboard's resize mode: KeyboardResize.Native shrinks the WebView (default), Body resizes the body element, Ionic resizes only ion-content which avoids reflowing the whole document and keeps a fixed footer where it belongs, and None leaves layout untouched so you handle it yourself. Ionic's built-in scroll assist scrolls a focused input into view, but only when the input is inside a scrollable ion-content; inputs in a fixed footer or in a modal with scrollY disabled are exactly the ones that end up under the keyboard. Debug it by logging the keyboardWillShow event's keyboardHeight and checking whether ion-content's height actually changed in DevTools.
Common fixes: switch resize mode to Ionic, add bottom padding equal to keyboardHeight on the container, and on iOS decide whether the accessory bar with Done and the arrows should be visible with setAccessoryBarVisible. Do not fake it with a hard-coded 300px offset, because keyboard height varies with the user's IME, and Indian users frequently run Gboard with a language toolbar or a third-party Indic keyboard that is noticeably taller.
import { Keyboard, KeyboardResize } from '@capacitor/keyboard';
await Keyboard.setResizeMode({ mode: KeyboardResize.Ionic });
await Keyboard.setAccessoryBarVisible({ isVisible: false }); // iOS only
const show = await Keyboard.addListener('keyboardWillShow', (info) => {
document.documentElement.style.setProperty('--kb-height', `${info.keyboardHeight}px`);
});
const hide = await Keyboard.addListener('keyboardWillHide', () => {
document.documentElement.style.setProperty('--kb-height', '0px');
});
/* CSS
.sticky-actions { padding-bottom: calc(var(--kb-height, 0px) + env(safe-area-inset-bottom)); }
*/
// AndroidManifest.xml
// <activity android:name=".MainActivity" android:windowSoftInputMode="adjustResize" ... />
Key Points
- Android needs adjustResize on the activity, plus resizeOnFullScreen edge cases
- iOS resize modes: Native, Body, Ionic, None (Ionic resizes only ion-content)
- Scroll assist only works for inputs inside a scrollable ion-content
- Never hard-code keyboard height; read it from keyboardWillShow
Q21How do safe-area insets work in Ionic, and what did Android 15 edge-to-edge change for Capacitor apps?
IntermediateLayout
Answer
Safe areas start with the viewport meta tag: the Ionic starter ships viewport-fit=cover, which is what makes the CSS env(safe-area-inset-top) family return non-zero values on notched and dynamic-island devices. Ionic maps those to its own variables, --ion-safe-area-top, --ion-safe-area-bottom, --ion-safe-area-left and --ion-safe-area-right, and applies them inside ion-header, ion-footer and ion-content automatically. Anything you position yourself does not get that for free, so a custom floating action bar or a fixed bottom CTA will sit under the home indicator until you add padding-bottom: env(safe-area-inset-bottom).
Note that the old constant() fallback syntax is obsolete and can be dropped. Android is where this got more interesting. From Android 15, apps that target API 35 are drawn edge-to-edge by default, so the system status bar and gesture navigation bar no longer reserve space: your WebView starts at pixel zero and your header slides under the clock.
Once your Capacitor project bumps its target SDK to satisfy the Play Store deadline, you have to handle it deliberately, either by opting the app back into insets on the native side or by adopting an edge-to-edge setup and consuming the insets in CSS. The related plugin call is StatusBar.setOverlaysWebView, which controls whether the WebView draws behind the status bar; pair it with a matching background colour or you get white text on a white bar. Test on a gesture-navigation device and a three-button-navigation device, because the bottom inset differs and QA teams usually only have one of them.
<!-- index.html -->
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
<style>
/* Ionic handles ion-header / ion-footer; your own chrome does not get it free */
.floating-cta {
position: fixed;
inset-inline: 16px;
bottom: calc(16px + env(safe-area-inset-bottom));
}
.custom-hero {
padding-top: var(--ion-safe-area-top, env(safe-area-inset-top));
}
</style>
<script type="module">
import { StatusBar, Style } from '@capacitor/status-bar';
await StatusBar.setOverlaysWebView({ overlay: true });
await StatusBar.setStyle({ style: Style.Light });
</script>
Key Points
- viewport-fit=cover enables env(safe-area-inset-*)
- Ionic exposes --ion-safe-area-* and applies them in header, footer, content
- Custom fixed elements need the insets applied manually
- Targeting Android API 35 forces edge-to-edge: content draws under system bars
Q22A universal link opens the browser instead of the app on Android production builds but works in debug. What is wrong?
IntermediateDeep Linking
Answer
Almost always the digital asset links file lists the wrong signing certificate. Android verifies an autoVerify intent filter by fetching https://yourdomain/.well-known/assetlinks.json and comparing the SHA-256 fingerprint of the APK's signing certificate against the list. Debug builds are signed with your debug keystore, and if that fingerprint happens to be in the file, testing passes.
Production builds distributed through Play are re-signed by Play App Signing, so the fingerprint that reaches the device is the Play app signing key, not your upload key. The fix is to include both, copying the app signing certificate fingerprint from the Play Console under Setup, App integrity. Other causes worth naming: the file must be served over https with content-type application/json, with no redirect and no authentication, and the domain must not be behind a Cloudflare rule that returns a challenge to the Android verifier.
On iOS the equivalent file is apple-app-site-association at /.well-known/, served as application/json without a .json extension requirement, plus the Associated Domains capability with applinks:yourdomain in Xcode. Once the link opens the app, Capacitor delivers it through App.addListener('appUrlOpen'), and you strip the origin and hand the path to the router. Handle cold start separately: if the app was not running, your listener may register after the event fires, so also read App.getLaunchUrl() during startup. Verify on device with adb shell pm get-app-links your.package.id, which prints the verification state per domain and saves hours of guessing.
import { App } from '@capacitor/app';
import { Router } from '@angular/router';
async initDeepLinks(router: Router) {
const launch = await App.getLaunchUrl(); // cold start
if (launch?.url) this.route(router, launch.url);
await App.addListener('appUrlOpen', ({ url }) => this.route(router, url));
}
private route(router: Router, url: string) {
const slug = new URL(url).pathname + new URL(url).search;
router.navigateByUrl(slug || '/');
}
// Verify on a device:
// adb shell pm get-app-links in.example.app
// adb shell am start -a android.intent.action.VIEW -d "https://example.in/jobs/42"
Key Points
- assetlinks.json must contain the Play App Signing fingerprint, not just upload key
- Served over https, content-type application/json, no redirect, no auth
- iOS needs apple-app-site-association plus the Associated Domains capability
- Handle cold start with App.getLaunchUrl() as well as the appUrlOpen listener
Q23Take me through wiring @capacitor/push-notifications end to end, including Android 13 and foreground behaviour.
IntermediatePush Notifications
Answer
Native setup first: Android needs a Firebase project and google-services.json dropped into android/app, iOS needs an APNs key or certificate in the Firebase or your own backend, the Push Notifications capability plus Background Modes remote notifications in Xcode, and a physical device because the simulator cannot register with APNs. Then the JS flow is checkPermissions, requestPermissions if the state is prompt, register(), and a 'registration' listener that hands you the token you POST to your backend. From Android 13 the OS added the POST_NOTIFICATIONS runtime permission, so on a device running 13 or later an app that never calls requestPermissions simply never shows a notification and reports no error, which is a very common silent failure.
Foreground behaviour differs by platform and is the second thing interviewers ask. On iOS you control it with the presentationOptions array in capacitor.config; on Android a notification-type FCM payload is drawn by the system only when the app is backgrounded, so if you want something visible while the user is in the app you render it yourself with @capacitor/local-notifications. That is also why data-only payloads behave differently: with a notification payload the system handles the backgrounded case and pushNotificationReceived does not fire, whereas a data-only message reaches your handler both ways.
Android 8 and later require notification channels with an importance level, and importance is fixed once a channel is created, so shipping a channel with default importance and later wanting heads-up alerts means creating a new channel id. Taps arrive on pushNotificationActionPerformed, which is where you read the payload and route the user.
import { PushNotifications } from '@capacitor/push-notifications';
export async function initPush(api: Api) {
let perm = await PushNotifications.checkPermissions();
if (perm.receive === 'prompt') perm = await PushNotifications.requestPermissions();
if (perm.receive !== 'granted') return; // Android 13+: user said no
await PushNotifications.createChannel({
id: 'interviews',
name: 'Interview alerts',
importance: 5, // HIGH; cannot be raised later
visibility: 1,
});
await PushNotifications.addListener('registration', (t) => api.saveToken(t.value));
await PushNotifications.addListener('registrationError', (e) => console.error(e));
await PushNotifications.addListener('pushNotificationReceived', (n) => inAppToast(n));
await PushNotifications.addListener('pushNotificationActionPerformed', ({ notification }) => {
router.navigateByUrl(notification.data?.route ?? '/');
});
await PushNotifications.register();
}
Key Points
- checkPermissions, requestPermissions, register, then the registration listener
- Android 13 POST_NOTIFICATIONS: no prompt means silent failure
- Foreground display: presentationOptions on iOS, local notifications on Android
- Channel importance is immutable after creation; ship the right one first
Q24Why does CameraResultType.Base64 crash mid-range Android devices, and what is the correct photo pipeline?
IntermediateMedia
Answer
A 12 megapixel JPEG is several megabytes. Base64 inflates it by about a third, then that string crosses the bridge as JSON, gets parsed on the main thread, and lives in the WebView's JavaScript heap alongside whatever your app already holds. Do that two or three times in a row on a 4GB Android device and the WebView renderer is killed, which the user experiences as the app going blank or restarting.
CameraResultType.Uri avoids all of it: the native side writes the file and hands you a path plus a webPath, and no image bytes cross the bridge at all. The catch is that the WebView cannot load a raw file:// URL under the capacitor:// or https://localhost origin, so you pass it through Capacitor.convertFileSrc() which rewrites it to a URL the local asset server can serve. Also downscale at capture time with the width, height and quality options rather than in JavaScript, because resizing a huge bitmap in a canvas costs the same memory you were trying to avoid.
Two more production details. The URI the camera returns often points at a cache directory the OS is free to purge, so if the photo must survive an app restart, copy it into Directory.Data with the Filesystem plugin and store that path. And when it is time to upload, do not base64 the whole file into a JSON body: fetch the converted URL to get a Blob and send multipart form data, or use a native upload plugin so the transfer survives the app being backgrounded on a patchy mobile connection.
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { Capacitor } from '@capacitor/core';
async function capture() {
const photo = await Camera.getPhoto({
resultType: CameraResultType.Uri, // never Base64 for full-size photos
source: CameraSource.Camera,
quality: 70,
width: 1600,
correctOrientation: true,
});
const display = Capacitor.convertFileSrc(photo.webPath!); // usable in <img src>
// Persist out of the OS cache directory
const saved = await Filesystem.copy({
from: photo.path!,
to: `receipts/${Date.now()}.jpg`,
toDirectory: Directory.Data,
});
return { display, path: saved.uri };
}
// Upload without base64
// const blob = await (await fetch(display)).blob();
// form.append('file', blob, 'receipt.jpg');
Key Points
- Base64 photos cross the bridge as JSON and blow up the WebView heap
- CameraResultType.Uri plus Capacitor.convertFileSrc is the safe path
- Downscale with width/height/quality at capture, not with a canvas
- Camera URIs live in cache; copy to Directory.Data for durable storage
Q25How do Capacitor permissions work across platforms, and what happens when a user permanently denies one?
IntermediatePermissions
Answer
Capacitor plugins that touch protected capability expose two methods, checkPermissions() and requestPermissions(), both returning a PermissionStatus whose per-alias values are 'granted', 'denied', 'prompt' or 'prompt-with-rationale'. The plugin only handles the runtime prompt; the declaration is your job in the native projects. On iOS every capability needs a usage description string in Info.plist: NSCameraUsageDescription, NSLocationWhenInUseUsageDescription, NSPhotoLibraryAddUsageDescription and so on.
A missing string is not a rejected promise, it is an immediate crash the first time the API is touched, which is why it usually escapes QA and lands in a store build. App Review also rejects strings that are vague, so 'This app needs camera access' is worse than naming the actual feature. On Android the permission goes in AndroidManifest.xml and dangerous permissions additionally require the runtime prompt.
The state interviewers care about is permanent denial: after the user refuses twice, or taps 'Don't allow' on newer versions, requestPermissions returns 'denied' without ever showing a dialog, and there is no API to force it back. The only path is to detect that state, explain what the user loses, and deep link them into the app's own settings screen. Plan for Play Console policy too, because ACCESS_BACKGROUND_LOCATION, QUERY_ALL_PACKAGES and similar sensitive permissions need a declaration form and a prominent in-app disclosure, and field-force apps built by Indian services teams get rejected on exactly that step.
import { Geolocation } from '@capacitor/geolocation';
export async function ensureLocation(): Promise<'ok' | 'blocked' | 'refused'> {
let status = await Geolocation.checkPermissions();
if (status.location === 'prompt' || status.location === 'prompt-with-rationale') {
status = await Geolocation.requestPermissions({ permissions: ['location'] });
}
if (status.location === 'granted') return 'ok';
// Denied without a dialog means the OS will not ask again
return status.location === 'denied' ? 'blocked' : 'refused';
}
// Info.plist
// <key>NSLocationWhenInUseUsageDescription</key>
// <string>We tag your attendance check-in with the store location.</string>
// AndroidManifest.xml
// <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
Key Points
- checkPermissions / requestPermissions return granted, denied, prompt states
- Missing Info.plist usage strings crash instantly, they do not throw
- Permanent denial cannot be re-prompted: deep link to app settings
- Sensitive Android permissions need a Play Console declaration
Q26You inherit a Cordova app on Ionic 5. What does migrating to Capacitor actually involve?
IntermediateMigration
Answer
The web code is usually the easy part. The work sits in three places. First, project ownership flips.
Cordova regenerates platforms/ from config.xml, so teams treat native folders as disposable; Capacitor generates ios/ and android/ once and expects you to commit and edit them. Every <preference>, <edit-config> and icon or splash entry from config.xml has to be re-expressed in capacitor.config.ts, Info.plist, AndroidManifest.xml or Gradle files by hand. Second, hooks are gone.
Any before_prepare or after_build script has to become an npm script or a native build phase. Third, plugins. Capacitor can consume many cordova-plugin-* packages directly, npx cap sync discovers them from package.json and wires them into Podfile and Gradle, but plugins that depended on config.xml preferences, on hooks, or that rewrote AppDelegate and MainActivity heavily will misbehave.
Swap the obvious ones for Capacitor equivalents first: splashscreen, statusbar, device, network, camera, filesystem and storage all have official plugins, and cordova-plugin-whitelist has no counterpart because navigation is controlled by server.allowNavigation. Code-level changes are small but real: there is no deviceready event to wait for, cordova.file.* directory constants map to Filesystem Directory values, and window.cordova only exists if Cordova plugins remain installed. Assets move from cordova-res to @capacitor/assets. Sequence the work: add Capacitor alongside Cordova, get Android building and shipping to an internal track, then iOS, then remove Cordova plugins one at a time so a regression is traceable to a single plugin rather than to a big-bang cutover.
# Add Capacitor to an existing Cordova/Ionic project
npm install @capacitor/core @capacitor/cli
npx cap init "Field Force" in.example.fieldforce --web-dir=www
ionic build --prod
npx cap add android
npx cap add ios
# See which Cordova plugins Capacitor picked up per platform
npx cap ls
# Replace the usual suspects
npm uninstall cordova-plugin-splashscreen cordova-plugin-statusbar cordova-plugin-device
npm install @capacitor/splash-screen @capacitor/status-bar @capacitor/device
npx cap sync
# Icons and splash screens (replaces cordova-res)
npx @capacitor/assets generate --iconBackgroundColor '#0b6bcb'
Key Points
- ios/ and android/ become committed source, not regenerated output
- config.xml preferences and hooks must be re-expressed by hand
- Cordova plugins mostly still work via cap sync, hook-dependent ones do not
- No deviceready; migrate splashscreen, statusbar and storage to official plugins
Q27The app must work offline with 50,000 records. Why reach for @capacitor-community/sqlite over IndexedDB, and what breaks on web?
IntermediateStorage
Answer
IndexedDB inside the WebView is fine for a few thousand documents with simple key lookups, and Dexie makes it pleasant. It stops being fine when you need real queries: joins across tables, aggregate reporting, full-text style filtering, or a preloaded dataset shipped with the app. @capacitor-community/sqlite gives you native SQLite on both platforms, so those queries run in C rather than in JavaScript, the data lives in a file you can copy or back up, and you can ship a prepopulated .db as an asset. It also supports encryption at rest through SQLCipher, which is the answer when a BFSI or healthcare client asks how offline records are protected.
The web story is the part candidates forget. There is no native SQLite in a browser, so the plugin falls back to a WASM build backed by IndexedDB, and that path needs extra setup: the jeep-sqlite web component must be defined and appended to the document, you call initWebStore() before opening a connection, and you must call saveToStore() after writes or the data never persists. Development on ionic serve silently does nothing without that. Other operational details worth naming: always close connections and run checkConnectionsConsistency on startup, because a stale connection after a hot reload throws on the next open; wrap bulk inserts in a single transaction with executeSet since 50,000 individual statements will take minutes; and on iOS a database in Documents is backed up to iCloud by default, which you may need to exclude for compliance reasons.
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite';
import { Capacitor } from '@capacitor/core';
const sqlite = new SQLiteConnection(CapacitorSQLite);
export async function openDb() {
if (Capacitor.getPlatform() === 'web') {
await customElements.whenDefined('jeep-sqlite');
await sqlite.initWebStore(); // required, or writes vanish on reload
}
await sqlite.checkConnectionsConsistency();
const db = await sqlite.createConnection('field', false, 'no-encryption', 1, false);
await db.open();
await db.execute('CREATE TABLE IF NOT EXISTS visits (id TEXT PRIMARY KEY, ts INTEGER, payload TEXT)');
return db;
}
export async function bulkInsert(db, rows: Visit[]) {
await db.executeSet(
rows.map((r) => ({ statement: 'INSERT OR REPLACE INTO visits VALUES (?,?,?)', values: [r.id, r.ts, JSON.stringify(r)] })),
true, // single transaction
);
if (Capacitor.getPlatform() === 'web') await sqlite.saveToStore('field');
}
Key Points
- Native SQLite for joins, aggregates and preloaded datasets; SQLCipher for encryption
- Web fallback is WASM plus IndexedDB: jeep-sqlite, initWebStore, saveToStore
- checkConnectionsConsistency on startup, always closeConnection
- Bulk writes must run inside one transaction or they crawl
Q28What does a realistic test pyramid look like for an Ionic app, and how do you test code that calls Capacitor plugins?
IntermediateTesting
Answer
Unit tests run in Node with jsdom, where no native bridge exists, so any direct plugin call throws or resolves as unimplemented. The fix is to keep plugin calls behind a thin service and mock the plugin module in tests, which most teams do with vi.mock or jest.mock. That also gives you somewhere to branch on Capacitor.isNativePlatform() so the web build has a sane fallback.
For component tests, remember Ionic components are custom elements: in Angular you either import the standalone components into TestBed or add CUSTOM_ELEMENTS_SCHEMA, and overlay controllers like ModalController and LoadingController must be provided as spies or your test hangs waiting on a present() that never resolves. React teams use @testing-library/react and usually need transformIgnorePatterns adjusted because @ionic/react and ionicons ship ESM. The middle layer is browser end-to-end: run ionic serve and drive it with Cypress or Playwright.
This is fast, runs in CI on every pull request, and catches routing, forms and state bugs, but it tests Chrome on a laptop, not a WebView on a phone. The top layer is device end-to-end with Appium or WebdriverIO. Because your app is a WebView, the driver starts in the native context and you must switch to the WEBVIEW context before CSS selectors work, and Android needs a matching chromedriver for the device's System WebView version.
Keep that layer small, a smoke path covering login, permission prompts, camera and one deep link, because it is slow and brittle. The failure mode worth naming: Android System WebView updates independently of the OS, so a device on an older WebView can fail on syntax your CI Chrome accepts.
// geo.service.spec.ts (Vitest)
import { vi, describe, it, expect } from 'vitest';
vi.mock('@capacitor/geolocation', () => ({
Geolocation: {
checkPermissions: vi.fn().mockResolvedValue({ location: 'granted' }),
getCurrentPosition: vi.fn().mockResolvedValue({ coords: { latitude: 28.53, longitude: 77.29 } }),
},
}));
import { GeoService } from './geo.service';
describe('GeoService', () => {
it('returns a check-in point when permission is granted', async () => {
const point = await new GeoService().checkIn();
expect(point).toEqual({ lat: 28.53, lng: 77.29 });
});
});
// WebdriverIO: selectors only work after switching context
// const contexts = await driver.getContexts();
// await driver.switchContext(contexts.find((c) => c.startsWith('WEBVIEW')));
Key Points
- Wrap plugin calls in a service and mock the plugin module in unit tests
- Angular tests need standalone imports or CUSTOM_ELEMENTS_SCHEMA plus controller spies
- Cypress or Playwright against ionic serve for the fast layer
- Appium must switch to the WEBVIEW context; keep device tests to a smoke path
Q29Write a custom Capacitor plugin. What are the moving parts on the TypeScript, Swift and Kotlin sides?
AdvancedPlugin Development
Answer
Four pieces. A TypeScript definitions interface describing the methods and their JSON-serialisable shapes. A registerPlugin call that returns the proxy and optionally lazy-loads a web implementation extending WebPlugin, which should throw this.unimplemented() for anything the browser cannot do.
An Android class annotated @CapacitorPlugin(name = "Upi") extending Plugin, with each exposed method annotated @PluginMethod and taking a PluginCall you resolve with a JSObject or reject with a message and code. An iOS class extending CAPPlugin; recent Capacitor versions let you conform to CAPBridgedPlugin and declare the method table in Swift, so the old Objective-C macro file is no longer needed. Threading is where custom plugins go wrong.
Android plugin methods do not run on the UI thread, so anything touching an activity, a dialog or a view must be wrapped in activity.runOnUiThread; on iOS, UIKit work belongs on DispatchQueue.main. If your method launches another activity for a result, you cannot resolve inline: annotate a handler with @ActivityCallback and start it with startActivityForResult(call, intent, "callbackName"), and Capacitor holds the call for you. For long-lived work, notifyListeners pushes events into JavaScript, and setKeepAlive(true) keeps a call alive so you can resolve it repeatedly.
Register permissions in the annotation so checkPermissions and requestPermissions work for free. Finally, ship it as its own npm package generated with the plugin template rather than dropping Swift files into the app project, otherwise every developer who runs cap sync on a fresh clone loses your code.
// src/definitions.ts
export interface UpiPlugin {
pay(options: { vpa: string; amount: number; note?: string }): Promise<{ txnId: string; status: string }>;
}
export const Upi = registerPlugin<UpiPlugin>('Upi', {
web: () => import('./web').then((m) => new m.UpiWeb()),
});
// android/src/main/java/in/example/upi/UpiPlugin.kt
@CapacitorPlugin(name = "Upi")
class UpiPlugin : Plugin() {
@PluginMethod
fun pay(call: PluginCall) {
val vpa = call.getString("vpa") ?: return call.reject("vpa is required", "BAD_ARGS")
val intent = Intent(Intent.ACTION_VIEW, buildUpiUri(vpa, call.getDouble("amount")!!))
startActivityForResult(call, intent, "onPayResult")
}
@ActivityCallback
private fun onPayResult(call: PluginCall?, result: ActivityResult) {
if (call == null) return
val raw = result.data?.getStringExtra("response")
?: return call.reject("No response from UPI app", "NO_RESPONSE")
call.resolve(JSObject().put("txnId", parseTxn(raw)).put("status", parseStatus(raw)))
}
}
Key Points
- registerPlugin + definitions interface + WebPlugin fallback with unimplemented()
- @CapacitorPlugin / @PluginMethod on Android, CAPPlugin (CAPBridgedPlugin) on iOS
- Android methods run off the UI thread; wrap UI work in runOnUiThread
- @ActivityCallback for startActivityForResult; notifyListeners for events
Q30An Ionic screen animates at 40fps on a mid-range Android phone. How do you find and fix the cause?
AdvancedPerformance
Answer
Start by accepting the constraint: layout, paint, JavaScript and your framework's change detection all share one main thread in the WebView, and you have roughly 16ms per frame. Attach Chrome DevTools over chrome://inspect on the real device, never the emulator, record a performance trace while reproducing the jank, and read the Main track. Long yellow scripting blocks mean JavaScript is the problem; purple layout and green paint blocks mean CSS is.
Common causes in that order. Animating anything other than transform and opacity forces layout on every frame, so a page transition that also animates height or top will never be smooth; move to transform: translate3d and let the compositor own it. Layout thrash from reading getBoundingClientRect or offsetHeight inside a scroll or gesture handler, which forces a synchronous reflow per read; batch reads, then writes.
Change detection storms: in Angular, ion-content scroll events and gesture callbacks run inside the zone, so wrap them in ngZone.runOutsideAngular and re-enter only when you need a render. Paint-heavy CSS on scrolling content, especially box-shadow, border-radius plus overflow, and backdrop-filter, which mid-range Adreno and Mali GPUs handle poorly. Long tasks blocking the frame: parsing a 3MB API response takes hundreds of milliseconds, so parse in a Web Worker or chunk the work. will-change and translate3d promote elements to their own compositor layer, which helps for a handful of elements and hurts when you promote a whole list, because layer memory is finite. Verify the fix by re-recording the trace and comparing frame times rather than by feel.
import { NgZone, ElementRef, inject } from '@angular/core';
import { createGesture } from '@ionic/angular/standalone';
const zone = inject(NgZone);
const host = inject(ElementRef<HTMLElement>);
// Gesture callbacks fire per frame: keep them out of change detection
zone.runOutsideAngular(() => {
const gesture = createGesture({
el: host.nativeElement,
gestureName: 'card-swipe',
onMove: (d) => {
// transform only: stays on the compositor, no layout
host.nativeElement.style.transform = `translate3d(${d.deltaX}px, 0, 0)`;
},
onEnd: (d) => {
host.nativeElement.style.transform = '';
if (Math.abs(d.deltaX) > 120) zone.run(() => this.dismissCard());
},
});
gesture.enable();
});
// Heavy parse off the main thread
// const worker = new Worker(new URL('./parse.worker', import.meta.url), { type: 'module' });
Key Points
- One main thread: JS, layout, paint and change detection compete
- Animate transform and opacity only; everything else triggers layout
- runOutsideAngular for scroll and gesture handlers to stop CD storms
- Offload heavy parsing to a Web Worker; promote layers sparingly
Q31Users report the app going blank after twenty minutes of use. How do you find the leak in an Ionic app?
AdvancedMemory
Answer
A blank screen after prolonged use on Android usually means the WebView renderer process was killed for memory, which shows in logcat as a render process gone message, and the app appears to restart itself. Treat it as two separate hunts, JavaScript heap and native memory. For the JS heap, attach Chrome DevTools to the device WebView, go to the Memory panel, and take a heap snapshot.
Navigate a loop of five or six pages, return to the start, force garbage collection, snapshot again, and compare. Ionic's router outlet caches pages by design, so some growth is expected; what you are looking for is growth that scales with the number of loops. The usual culprits, in the order they show up: RxJS subscriptions started in ionViewWillEnter with no teardown in ionViewWillLeave, so each visit adds another live stream; Capacitor listeners never removed, which also duplicates event handling; setInterval or setTimeout chains created per visit; object URLs from URL.createObjectURL never revoked, each pinning a blob; overlays created through a controller and never dismissed, each holding a whole component tree; and detached DOM retained by a listener added to document or window.
Look specifically at the Detached counts in the snapshot's summary. Native memory does not appear in the JS heap at all, so large bitmaps from the camera, video players and map tiles need Android Studio's memory profiler or Xcode Instruments. The structural fixes are boring and effective: one teardown point per page, takeUntilDestroyed or a destroy subject in Angular, cleanup functions in React effects, and a rule that anything created in an enter hook is destroyed in the matching leave hook.
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class TrackingPage {
private destroyRef = inject(DestroyRef);
private watchId?: string;
private preview?: string;
ionViewWillEnter() {
this.socket.updates$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(...);
Geolocation.watchPosition({}, (p) => this.onFix(p)).then((id) => (this.watchId = id));
}
ionViewWillLeave() {
if (this.watchId) Geolocation.clearWatch({ id: this.watchId });
if (this.preview) {
URL.revokeObjectURL(this.preview); // each unrevoked URL pins a blob
this.preview = undefined;
}
}
}
// adb logcat | grep -i 'render process gone'
Key Points
- Blank screen on Android is usually the renderer killed for memory
- Compare heap snapshots across a navigation loop, watch Detached node counts
- Top causes: unremoved subscriptions, plugin listeners, timers, object URLs, overlays
- Native bitmaps need Android Studio or Instruments, not the JS heap panel
Q32Background sync stops running a few minutes after the user leaves the app. What is actually happening and what are the options?
AdvancedBackground Execution
Answer
Nothing is broken. Your JavaScript lives in a WebView owned by an app process, and both platforms suspend that process shortly after backgrounding. On iOS the app is suspended within seconds and timers simply stop; on Android, Doze and App Standby throttle background work aggressively, and the OS is free to kill the process for memory.
So a setInterval sync loop is guaranteed to stop, and any design that assumes otherwise will pass testing with the app in the foreground and fail in the field. The options, from lightest to heaviest. Sync on resume: listen to App.appStateChange and reconcile when isActive turns true, which covers most business apps and costs nothing.
Scheduled background work: @capacitor/background-runner runs a small separate JavaScript context on top of BGTaskScheduler on iOS and WorkManager on Android, with no DOM and no access to your app's runtime, on a schedule the OS decides rather than one you demand. Silent push: your server wakes the app when there is genuinely something to fetch, which is the most reliable way to get timely data. Continuous tracking: an Android foreground service with a persistent notification, which is what attendance and field-force apps in India actually use, and it needs a Play Console declaration plus prominent disclosure; on iOS the equivalent is a background mode such as location, and App Review rejects the mode if the app does not visibly need it. Also handle the ugly case: iOS can terminate a suspended app without notice, so persist any in-flight queue to disk on pause rather than holding it in memory.
import { App } from '@capacitor/app';
// 1. Cheap and reliable: reconcile when the user comes back
await App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) await syncQueue();
else await persistQueueToDisk(); // iOS may kill a suspended app silently
});
// 2. capacitor.config.ts for @capacitor/background-runner
// plugins: {
// BackgroundRunner: {
// label: 'in.example.sync',
// src: 'runners/sync.js',
// event: 'syncQueue',
// repeat: true,
// interval: 15, // minutes, treated as a hint by the OS
// autoStart: true,
// },
// }
// runners/sync.js runs in its own JS context: no DOM, no app state
addEventListener('syncQueue', async (resolve, reject) => {
try {
const pending = JSON.parse(CapacitorKV.get('pending').value || '[]');
for (const item of pending) await CapacitorNotifications.schedule([...]);
resolve();
} catch (e) {
reject(e);
}
});
Key Points
- The WebView process is suspended or throttled: setInterval sync always dies
- Sync on App.appStateChange resume covers most business apps
- background-runner uses BGTaskScheduler and WorkManager, on the OS schedule
- Continuous location needs a foreground service plus Play declarations
Q33How do live updates work in a Capacitor app, and what are you not allowed to change without a store review?
AdvancedRelease Engineering
Answer
Because the app is a folder of web assets served locally, a live-update plugin can download a new bundle as a zip, unpack it into the app's data directory, and repoint the WebView at that folder on the next cold start. Users get a fix in hours instead of waiting on review, which is the single biggest operational advantage Ionic has over fully native stacks. The boundary is strict: only web assets can change.
Native code, plugin versions, permissions, entitlements, the app icon and the version shown in the store all require a new binary. That boundary is also the compliance line. Apple's license agreement permits interpreted code that is downloaded and run by the built-in WebKit engine provided it does not materially change the app's documented purpose, and guideline 2.5.2 targets downloading executable code that adds features.
Shipping a checkout bug fix is fine; turning a catalogue app into a gambling app after review is not, and that is what the rule exists to stop. Engineering it safely comes down to four habits. Pin every web bundle to a minimum native build number, because a bundle calling a plugin method that only exists in the next binary will crash every older install.
Apply updates on next launch, never mid-session, or the user loses in-flight state. Keep automatic rollback: the runtime should revert to the previous bundle if the new one fails to call its ready signal, which protects you from a white-screen update. And roll out by channel, internal testers first, then a percentage. Options range from the commercial Appflow live updates service to the self-hostable community updater plugin or your own endpoint.
import { CapacitorUpdater } from '@capgo/capacitor-updater';
import { App } from '@capacitor/app';
// Must be called once the UI is proven to render, or rollback kicks in
await CapacitorUpdater.notifyAppReady();
async function checkForBundle(nativeBuild: number) {
const latest = await fetch('https://updates.example.in/latest').then((r) => r.json());
// Never install a bundle that needs a newer native shell
if (latest.minNativeBuild > nativeBuild) return;
const bundle = await CapacitorUpdater.download({ url: latest.url, version: latest.version });
await CapacitorUpdater.next({ id: bundle.id }); // applied on next cold start
}
App.addListener('appStateChange', ({ isActive }) => {
if (isActive) checkForBundle(CURRENT_NATIVE_BUILD);
});
Key Points
- A new web bundle is downloaded and swapped in at next cold start
- Native code, plugins, permissions and store version need a real release
- Apple allows web-asset updates that do not change the app's purpose
- Gate bundles on a minimum native build and keep automatic rollback
Q34What store-compliance work is specific to a Capacitor app, beyond writing the code?
AdvancedStore Compliance
Answer
Two categories: things Apple requires, and things Google requires, and in both cases your plugins are the hidden liability because they drag in native SDKs you never audited. On the Apple side you need a privacy manifest, PrivacyInfo.xcprivacy, declaring the data types your app collects and the reason codes for required-reason APIs. This matters directly for Capacitor because UserDefaults is on that list, and @capacitor/preferences uses UserDefaults, so the manifest needs the corresponding reason entry.
Third-party SDKs on Apple's list must ship their own manifest and code signature, so an old analytics or payments plugin can block your upload; App Store Connect emails you about the missing entries after the build is processed, which is a slow feedback loop. You also need ITSAppUsesNonExemptEncryption in Info.plist or every TestFlight build sits waiting on export compliance, plus honest, feature-specific usage strings. On the Google side, Play enforces a target API level deadline every August, so a Capacitor project that is a major version behind will eventually be blocked from updates, and bumping target SDK is exactly what pulls in behaviour changes like mandatory edge-to-edge layout.
The Data safety form must match what your app and its plugins actually transmit, the advertising ID permission needs declaring, and sensitive permissions such as background location need a declaration plus in-app disclosure. Both stores require an in-app account deletion path if you allow account creation, and both restrict digital-goods payments to their own billing, which is a live issue for Indian apps that reach for Razorpay by default and get rejected for selling subscriptions outside store billing.
<!-- ios/App/App/PrivacyInfo.xcprivacy -->
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array><string>CA92.1</string></array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
</dict>
<!-- Info.plist: otherwise every TestFlight build waits on export compliance -->
<!-- <key>ITSAppUsesNonExemptEncryption</key><false/> -->
// android/variables.gradle
// ext { minSdkVersion = 23; compileSdkVersion = 35; targetSdkVersion = 35 }
Key Points
- PrivacyInfo.xcprivacy: required-reason API codes, including UserDefaults via Preferences
- Third-party SDK manifests and signatures can block an App Store upload
- Play's annual target API deadline drags in behaviour changes like edge-to-edge
- Account deletion is mandatory; digital goods must use store billing
Q35Someone unzips your APK and reads the JavaScript. What should your security posture be?
AdvancedSecurity
Answer
Start from the fact rather than arguing with it: the entire web bundle sits in the APK or IPA and anyone can extract and read it in under a minute. Minification is not encryption, and JavaScript obfuscation raises the cost slightly without changing the outcome. So the first rule is that nothing secret goes in the bundle: no API keys with real privileges, no signing secrets, no hardcoded admin endpoints, no client-side gate that is the only thing standing between a user and privileged data.
Authorisation decisions belong on the server, always, because a determined user can call your API directly with a rewritten bundle. Second, store credentials properly. Tokens in localStorage or Capacitor Preferences are readable on a rooted or jailbroken device, so refresh tokens belong in Keychain and Keystore through a secure-storage plugin, ideally biometric-gated for high-value apps.
Third, lock the WebView down. Keep an explicit server.allowNavigation allowlist so a compromised page cannot navigate the WebView somewhere hostile, never ship a config with server.url pointing at a dev machine (it is both a broken build and a man-in-the-middle surface), disable mixed content, and tighten the Content-Security-Policy meta tag in index.html because the starter template is permissive. Fourth, protect data in transit with certificate pinning configured natively, using Android's network security config and an iOS URLSession delegate, which also works when requests go through CapacitorHttp.
Root and jailbreak detection is a speed bump worth adding for BFSI clients who ask for it, not a control you should rely on. Finally, validate deep-link parameters before routing on them.
<!-- index.html: the starter CSP is permissive, tighten it -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self' gap: capacitor: https://localhost;
connect-src 'self' https://api.example.in;
img-src 'self' data: https: capacitor:;
script-src 'self';" />
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.example.in</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">base64PrimaryPin=</pin>
<pin digest="SHA-256">base64BackupPin=</pin>
</pin-set>
</domain-config>
</network-security-config>
// Verify before every release build:
// unzip -p app-release.apk assets/public/main.js | grep -iE 'secret|api[_-]?key'
Key Points
- The bundle is readable: no secrets, no client-side-only authorisation
- Tokens belong in Keychain and Keystore, not Preferences or localStorage
- allowNavigation allowlist, no server.url in release, tighten the CSP
- Pin certificates natively; treat root detection as a speed bump
Frequently Asked Questions
How much does an Ionic developer earn in India in 2026?
Roughly ₹5-18 LPA depending on level and employer. Freshers in services firms and digital agencies typically start at ₹3.5-6 LPA, developers with two to four years who can ship to both stores independently sit around ₹8-13 LPA, and leads who own release engineering, custom plugins and store compliance reach ₹15-18 LPA and occasionally beyond at product companies. Ionic on its own pays less than native Android or iOS, so the people at the top of the band are rarely 'Ionic developers': they are strong Angular or React engineers who also handle Xcode, Gradle, Firebase and the App Store process. Adding Capacitor plugin authoring in Swift or Kotlin is the single change that moves an offer up a band, because it is the skill most hybrid teams are missing.
How long does it take to prepare for an Ionic interview?
If you already write Angular or React daily, two to three weeks of focused work is enough. Spend the first week on the UI layer and routing: components, shadow DOM styling, the page lifecycle, IonRouterOutlet stack behaviour and overlay patterns. Spend the second week on Capacitor: the CLI commands, the plugin bridge, permissions, camera, push notifications and deep links, all tested on a real Android device rather than in the browser. Use the third week on the things that separate offers, WebView performance, memory, store compliance and a Cordova migration story. Build and actually publish one small app to an internal testing track. Candidates who have gone through Play Console signing, an assetlinks file and a rejected review answer these questions differently from candidates who have only run ionic serve, and interviewers can tell within two questions.
What is the difference between what freshers and experienced candidates are asked?
Freshers get the UI and framework layer: component usage, mode, theming variables, lifecycle hooks, routing, forms, and the difference between Ionic, Capacitor and Cordova. Writing a clean page with ion-content, a refresher and an infinite scroll, and explaining why data is stale after back navigation, will clear most fresher rounds. From roughly three years onwards the questions move to the native seam: what happens on the bridge, why an APK behaves differently from the browser build, keyboard and safe-area handling, deep-link verification failures, memory in a long session, and how you got a build past App Review. Senior rounds add ownership questions: release strategy, live updates and rollback, plugin authoring, target SDK deadlines, and choosing Ionic against React Native honestly rather than defending it. The pivot is roughly 'can you build screens' versus 'can you ship and maintain a store app'.
Is Ionic still worth learning in 2026?
Yes, with a clear eye on where it is used. Ionic is not the choice for a consumer app competing on animation polish, and it has lost mindshare to Flutter and React Native for greenfield consumer products. It remains a very strong choice for internal tools, field-force and sales apps, healthcare and insurance data capture, BFSI onboarding, and any product that needs the same codebase to run as a web app, an installable PWA and two store apps. Indian IT services firms staff these projects constantly because one web developer covers three platforms, and there is a large installed base of Ionic apps from 2018 onwards that needs maintenance, Cordova-to-Capacitor migration and target SDK upkeep. Learn it as a second skill on top of solid Angular or React rather than as your only skill, and learn Capacitor properly, since that is where the hiring gap actually sits.
Should I learn Ionic or React Native for a mobile career in India?
React Native has more product-company demand and generally higher salary ceilings, particularly in Bengaluru and Gurugram consumer startups, because it renders with native views and suits apps where feel matters. Ionic has broader demand in services companies and enterprise projects, and it is easier to enter from a web background because there is no new rendering model to learn: it is HTML, CSS and your existing framework inside a WebView. A practical path is to go deep on one framework, Angular or React, then add Capacitor. Capacitor is the transferable piece, since it wraps any web app and the native concepts you learn (permissions, signing, deep links, store review, push) carry across to React Native and even to native work. If your target is product companies, lead with React Native; if your target is enterprise delivery or you already work in Angular, Ionic pays back faster.
Do I need to know Angular to get an Ionic job in India?
Not strictly, but it helps a great deal. Ionic supports Angular, React and Vue equally at the component level, and Capacitor does not care what you use. In practice the Indian job market skews Angular for Ionic roles, partly because Ionic's early years were Angular-only and most maintenance work is on those codebases, and partly because enterprise and services teams standardise on Angular anyway. If you come from React, you will find plenty of Ionic React roles at product companies and agencies, and the Ionic-specific knowledge transfers directly. What no employer will overlook is native fluency: being able to open Xcode and Android Studio, read a Gradle error, add a capability, and diagnose a crash from logcat. Candidates who can only work inside the JavaScript layer get stuck the first time a build fails, and interviewers screen for exactly that.
Introduction
Ionic in 2026 is best understood as two products that ship together but solve different problems. Ionic Framework is a library of about a hundred platform-adaptive UI components published as standard web components, usable from Angular, React, Vue or plain HTML. Capacitor is the native runtime that wraps your compiled web build inside a WKWebView on iOS and an Android System WebView, generates real Xcode and Android Studio projects you commit to git, and bridges JavaScript calls to Swift and Kotlin. Every serious Ionic interview eventually splits along that seam: UI questions on one side, native runtime and store-compliance questions on the other.
Indian hiring for Ionic sits mostly with IT services and product-engineering firms delivering client apps on tight budgets: TCS, Infosys, Wipro, HCLTech, Tech Mahindra, LTIMindtree, Capgemini and Accenture all staff hybrid-mobile pods, alongside a long tail of digital agencies building field-force, healthcare and BFSI apps. Interviewers there care less about trivia and more about whether you have actually shipped to both stores. Expect probing on WebView origin and CORS behaviour, keyboard and safe-area handling, list performance without ion-virtual-scroll, memory retained by cached pages in IonRouterOutlet, Cordova-to-Capacitor migration scars, and why a build got rejected by App Review.
This guide works through 35 Ionic interview questions asked in 2026, ordered basic first and grouped by difficulty. Each answer explains the actual runtime behaviour, the production gotcha that follows from it, and what the interviewer is really testing, with a runnable code example wherever the code says it faster than prose. Use the basic section to lock down components, lifecycle and the Capacitor CLI, then push into the intermediate and advanced sections that decide senior offers: plugin authoring, WebView jank, live updates, privacy manifests, target-SDK deadlines and embedding Ionic inside an existing native app.
Ready to practice Ionic interviews?
Don't just read, practice these Ionic questions live with an AI interviewer that asks follow-ups and scores your answers.