NativeScript Interview Questions and Answers
Last updated:
Check out 35 of the most common NativeScript interview questions, then take an AI-powered practice interview
Q1How does NativeScript execute your JavaScript on a device, and where does that differ from React Native?
BasicRuntime Architecture
Answer
A NativeScript app ships two things into the binary: your bundled JavaScript and a runtime that embeds a JS engine. On Android the engine is V8, on iOS it is JavaScriptCore, and in both cases the runtime is a native library (libNativeScript.so on Android, the NativeScript framework on iOS) linked into the app process. At build time a metadata generator walks the Android classpath and the iOS Objective-C headers and produces a compact binary index of every class, method, property and protocol.
At runtime, when your code touches android.widget.Toast or UIAlertController, the runtime looks the symbol up in that metadata, creates a JS proxy object backed by the real native instance, and dispatches the call directly. There is no JSON message queue and no asynchronous bridge: calls are synchronous function calls across the VM boundary, which is why you can write native code inline in TypeScript with no glue layer or custom module. The UI is real native widgets too, a NativeScript Button is an android.widget.Button or a UIButton, not a re-implementation.
React Native historically serialised everything across an async bridge, and since the new architecture landed it uses JSI plus Fabric to get closer to direct calls, but it still exposes a curated component and TurboModule surface rather than the whole platform SDK. The trade-off is that NativeScript gives you total API reach with zero wrappers, while React Native gives you a much larger community and far more prebuilt components.
Key Points
- V8 on Android, JavaScriptCore on iOS, both embedded in the app process
- Build-time metadata indexes the whole platform SDK for runtime lookup
- Calls are synchronous across the VM boundary, no serialised bridge
- UI elements are real UIView / android.view.View instances
- React Native exposes a curated module surface; NativeScript exposes everything
Q2How do you call a platform API such as Toast or UIAlertController directly from TypeScript?
BasicNative APIs
Answer
You write the native call as normal TypeScript using the platform's own namespaces. Android classes live under their package path (android.widget.Toast, java.util.ArrayList, androidx.core.app.ActivityCompat), and iOS classes are global (UIAlertController, NSUserDefaults, CLLocationManager) with Objective-C selectors flattened into camelCase method names. A selector like alertControllerWithTitle:message:preferredStyle: becomes alertControllerWithTitleMessagePreferredStyle.
For compile-time typing you install @nativescript/types and reference it from a references.d.ts at the project root; that package pulls in @nativescript/types-android and @nativescript/types-ios so the editor knows those globals exist. Because both sets of typings are ambient, TypeScript will happily let you reference UIAlertController inside an Android-only branch, so guard every call with isAndroid or isIOS from @nativescript/core, or split the code into home.android.ts and home.ios.ts. Android APIs also need a Context for most constructors, use Utils.android.getApplicationContext() for application-scoped work and Application.android.foregroundActivity when you need an Activity, for example to show a dialog or request a runtime permission. The failure mode juniors hit constantly is calling an Activity-scoped API during app launch, before any Activity exists, which throws a null-reference error inside the marshalled call.
// references.d.ts at the project root
/// <reference path="./node_modules/@nativescript/types/index.d.ts" />
import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
export function notify(message: string): void {
if (isAndroid) {
const ctx = Utils.android.getApplicationContext();
android.widget.Toast.makeText(ctx, message, android.widget.Toast.LENGTH_SHORT).show();
return;
}
if (isIOS) {
const alert = UIAlertController.alertControllerWithTitleMessagePreferredStyle(
'Notice',
message,
UIAlertControllerStyle.Alert,
);
alert.addAction(
UIAlertAction.actionWithTitleStyleHandler('OK', UIAlertActionStyle.Default, null),
);
Application.ios.rootController.presentViewControllerAnimatedCompletion(alert, true, null);
}
}
Key Points
- Android APIs use full package paths, iOS classes are globals
- Objective-C selectors flatten to camelCase method names
- @nativescript/types plus references.d.ts gives editor typings for both
- Always guard with isAndroid / isIOS or use .android.ts / .ios.ts files
- Utils.android.getApplicationContext() vs Application.android.foregroundActivity
Q3What does nativescript.config.ts control, and which keys actually matter in production?
BasicConfiguration
Answer
nativescript.config.ts is the project manifest the CLI reads on every command. The mandatory keys are id (the application identifier used for the Android applicationId and the iOS bundle identifier), appPath (usually src or app) and appResourcesPath (usually App_Resources). Beyond that it holds per-platform runtime settings.
Under android you get markingMode, which is 'none' by default since NativeScript 7 and changes how JS and Java garbage collection cooperate; v8Flags for tuning the engine, commonly '--expose_gc --max-old-space-size=...' when you are chasing memory issues; codeCache to persist V8 compiled code between launches for faster startup; and maxLogcatObjectSize, which matters because logcat silently truncates large console.log payloads. Under ios, discardUncaughtJsExceptions: true stops an unhandled JS error from hard-crashing the process, which is useful in production but dangerous in development because it hides bugs. You can also set a distinct id per platform, which is how teams ship the same codebase under different bundle identifiers for a white-labelled build.
The cli block pins the package manager, and hooks or webpackConfigPath let you point at custom build wiring. A frequent interview probe is what happens after you change this file: any change here requires a rebuild, hot module replacement will not pick it up, and an id change effectively means uninstalling the app from the device first.
import { NativeScriptConfig } from '@nativescript/core';
export default {
id: 'ai.goodspace.mobile',
appPath: 'src',
appResourcesPath: 'App_Resources',
android: {
markingMode: 'none',
codeCache: true,
v8Flags: '--expose_gc',
maxLogcatObjectSize: 8192,
},
ios: {
discardUncaughtJsExceptions: false,
},
cli: {
packageManager: 'npm',
},
} as NativeScriptConfig;
Q4Which ns CLI commands do you use daily, and what does ns clean actually remove?
BasicTooling
Answer
The CLI is the whole workflow. ns create scaffolds a project from a flavour template, ns run android and ns run ios build, deploy and start a LiveSync session with hot module replacement, and ns debug android opens a Chrome DevTools inspector session against the running app. ns doctor validates your toolchain and is the first command to run when a build fails for no obvious reason: it checks JAVA_HOME, the Android SDK, build tools, Xcode and CocoaPods. ns devices lists attached devices and emulators so you can target one with --device. ns plugin add installs a plugin and runs its native install hooks, ns update moves the project onto a newer NativeScript release, and ns migrate rewrites an older project's configuration and dependencies. For release builds you use ns build android --release --aab with keystore flags, and ns build ios --release --for-device with provisioning. ns clean is the command people reach for when things are inexplicably broken: it deletes the generated platforms directory, hooks, node_modules and the build artefacts, so the next run regenerates the native projects from scratch. Crucially it does not touch App_Resources or your source, but it does throw away any manual edits you made inside platforms/, which is exactly why editing platforms/ directly is an anti-pattern. Add --clean to a run command to force a rebuild without wiping everything.
# Scaffold and run
npm i -g nativescript
ns create goodspace-mobile --ng # or --vue, --ts, --react, --svelte
ns run android --emulator
ns run ios --device "Saksham iPhone"
# Diagnose a broken toolchain
ns doctor android
ns info
# Debug with Chrome DevTools
ns debug ios --debug-brk
# Nuclear option when the native project is corrupt
ns clean && npm install && ns run android --clean
# Release artefacts
ns build android --release --aab \
--key-store-path ./release.keystore --key-store-alias upload
ns build ios --release --for-device --provision "GoodSpace Distribution"
Key Points
- ns run gives LiveSync plus HMR; ns debug attaches Chrome DevTools
- ns doctor is the first stop for toolchain failures
- ns clean deletes platforms, hooks, node_modules and build output
- Never hand-edit platforms/, it is regenerated and disposable
- ns build android --aab is what Play Store accepts
Q5Which layout containers ship with NativeScript, and when do you pick GridLayout over nested StackLayouts?
BasicLayout
Answer
NativeScript ships six layout containers plus a root helper. StackLayout arranges children in one direction and is the simplest but the most abused. GridLayout uses rows and columns declared as a comma-separated string where auto sizes to content, * distributes remaining space, and a fixed number is device-independent pixels; children position themselves with row, col, rowSpan and colSpan.
FlexboxLayout implements the CSS flexbox spec for wrapping and alignment-heavy designs. DockLayout pins children to top, bottom, left or right with an optional stretched last child. AbsoluteLayout positions by explicit left and top.
WrapLayout flows children onto new lines. RootLayout, added in the 8.x line, lets you stack overlays such as bottom sheets and toasts over the whole app. The performance argument is real and it is about measure passes: NativeScript maps these containers onto native layout code, and every nesting level adds another measure and arrange pass over the native view tree.
A row that nests three StackLayouts to place an avatar, two lines of text and a chevron produces a deep native hierarchy that has to be measured on every layout cycle, and inside a scrolling ListView that cost is paid per recycled cell. A single GridLayout with columns 'auto, *, auto' produces one flat container and measures once. Interviewers at teams maintaining large list-heavy apps will often hand you a nested StackLayout snippet and ask you to flatten it.
<!-- Slow: three nested containers per row -->
<StackLayout orientation="horizontal">
<Image src="{{ avatar }}" width="48" height="48" />
<StackLayout>
<Label text="{{ name }}" class="title" />
<Label text="{{ role }}" class="subtitle" />
</StackLayout>
<Label text="" class="chevron" />
</StackLayout>
<!-- Fast: one flat GridLayout, one measure pass -->
<GridLayout columns="auto, *, auto" rows="auto, auto" class="row">
<Image src="{{ avatar }}" row="0" col="0" rowSpan="2" width="48" height="48" />
<Label text="{{ name }}" row="0" col="1" class="title" />
<Label text="{{ role }}" row="1" col="1" class="subtitle" />
<Label text="" row="0" col="2" rowSpan="2" class="chevron" />
</GridLayout>
Q6How do Observable and two-way binding work in the NativeScript Core flavour?
BasicData Binding
Answer
In the Core (XML) flavour there is no change detection loop. The binding system is push-based: a view binds to a property path on the page's bindingContext, and the UI only updates when the model explicitly fires a propertyChange event. Observable, from @nativescript/core, is the base class that provides notifyPropertyChange(name, value) and the set()/get() helpers; Observable.fromObject(plain) wraps a plain object so its top-level properties become observable automatically.
In markup, {{ prop }} is a one-way binding and {{ prop, prop }} is two-way, where the first expression is the source path and the second is the target path written back on user input. That two-way form is what you use on TextField, Switch and Slider. Bindings also accept expressions such as {{ count > 0 ?
'Apply' : 'No results' }} and converters registered on the application resources. For collections you need ObservableArray rather than a plain array, because a plain array mutation cannot notify a ListView; ObservableArray raises granular change events so the list can insert or delete a single row instead of rebuilding. The classic bug is assigning this._items.push(x) on a plain array and wondering why the ListView never updates, and the second classic bug is forgetting to notify a computed getter, since notifying 'email' does not tell the UI that 'canSubmit' also changed. You must fire both.
import { Observable, ObservableArray, EventData, Page } from '@nativescript/core';
export class LoginViewModel extends Observable {
readonly recent = new ObservableArray<string>([]);
private _email = '';
get email(): string {
return this._email;
}
set email(value: string) {
if (this._email === value) return;
this._email = value;
this.notifyPropertyChange('email', value);
// computed values must be notified explicitly
this.notifyPropertyChange('canSubmit', this.canSubmit);
}
get canSubmit(): boolean {
return this._email.includes('@');
}
}
export function onNavigatingTo(args: EventData) {
(args.object as Page).bindingContext = new LoginViewModel();
}
/* login-page.xml
<TextField hint="Email" text="{{ email, email }}" />
<Button text="Continue" isEnabled="{{ canSubmit }}" tap="{{ onSubmit }}" />
*/
Key Points
- {{ prop }} is one-way, {{ prop, prop }} is two-way
- Observable.notifyPropertyChange drives every UI update
- Computed getters need their own explicit notification
- ObservableArray, not Array, for anything bound to a ListView
- Observable.fromObject wraps plain objects for quick view models
Q7What is App_Resources and what belongs inside it?
BasicProject Structure
Answer
App_Resources is the escape hatch into the real native projects, and it is the only native configuration that is version-controlled. The CLI regenerates the platforms/ directory on every clean build, but it copies App_Resources into that generated project, so anything you need to survive an ns clean must live here. On the Android side you get AndroidManifest.xml for permissions, intent filters, deep links and the application node; app.gradle for the applicationId, minSdkVersion, targetSdkVersion, signing config and any Gradle dependency you need; a res tree with drawables, mipmaps for launcher icons, values and values-night for colours and themes, and a network_security_config.xml when you need cleartext exceptions; plus a libs folder where you drop AAR and JAR files.
On the iOS side you get Info.plist for usage-description strings, URL schemes and background modes, build.xcconfig for build settings such as the development team and deployment target, a Podfile for CocoaPods, an Assets.xcassets catalog, and PrivacyInfo.xcprivacy for Apple's privacy manifest requirement. The rule interviewers test is simple: if you found yourself editing something under platforms/android/app/src/main, you did it wrong, that change disappears on the next clean. The correct move is to put the file in App_Resources/Android/src/main and let the build merge it.
App_Resources/
Android/
app.gradle # applicationId, SDK levels, deps, signing
src/main/AndroidManifest.xml
src/main/res/values/colors.xml
src/main/res/values-night/colors.xml
src/main/res/xml/network_security_config.xml
src/main/res/mipmap-*/ic_launcher.png
libs/analytics-sdk.aar
iOS/
Info.plist # NSCameraUsageDescription, URL schemes
build.xcconfig # DEVELOPMENT_TEAM, IPHONEOS_DEPLOYMENT_TARGET
Podfile # pod 'FirebaseCore'
PrivacyInfo.xcprivacy # required-reason API declarations
Assets.xcassets/
Q8How does NativeScript resolve platform-specific files like home.android.ts and home.ios.ts?
BasicProject Structure
Answer
The webpack build injects a platform-aware resolver, so an import of './home' resolves to './home.android.ts' when you are building for Android and './home.ios.ts' when you are building for iOS, falling back to './home.ts' if no platform variant exists. The same convention works for CSS and SCSS (styles.android.scss, styles.ios.scss) and for XML views. Nothing in your import statement changes, you always import the base name without a suffix, which keeps call sites clean.
This matters more than it looks because it is a compile-time split rather than a runtime branch: the Android bundle simply never contains the iOS file, so iOS-only classes cannot leak into the Android build and dead code is genuinely removed. The alternative is runtime branching with isAndroid and isIOS from @nativescript/core, or the __ANDROID__ and __IOS__ compile-time globals that webpack replaces with literals so the dead branch is stripped during minification. In practice teams use runtime guards for three-line differences and file splitting once a module has meaningfully different implementations on each platform, for example a biometrics wrapper that uses BiometricPrompt on Android and LAContext on iOS. Keep a shared interface file so both implementations are type-checked against the same contract, otherwise the two files drift and you get a runtime error on the platform you tested less.
// biometrics.d.ts (shared contract, no implementation)
export interface Biometrics {
authenticate(reason: string): Promise<boolean>;
}
// biometrics.android.ts
export const biometrics: Biometrics = {
authenticate(reason) {
// androidx.biometric.BiometricPrompt ...
return Promise.resolve(true);
},
};
// biometrics.ios.ts
export const biometrics: Biometrics = {
authenticate(reason) {
const ctx = LAContext.new();
return new Promise((resolve) =>
ctx.evaluatePolicyLocalizedReasonReply(
LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
reason,
(ok) => resolve(ok),
),
);
},
};
// call site, no suffix anywhere
import { biometrics } from './biometrics';
await biometrics.authenticate('Confirm payout');
Q9How does navigation work with Frame, Page, navigationContext and showModal?
BasicNavigation
Answer
A Frame is a native navigation container: on Android it wraps a Fragment stack, on iOS a UINavigationController. A Page is a single screen, and Frame.topmost().navigate() pushes a new one. The navigate call takes a NavigationEntry where moduleName points at the page module, context carries arbitrary data available on the target as page.navigationContext, animated and transition control the animation (slide, fade, flip, curl on iOS, or a custom transition class), and clearHistory: true wipes the back stack, which is exactly what you want after a successful login so the hardware back button does not return to the login screen. backstackVisible: false excludes a page from history, useful for a one-off splash or OTP screen.
Frame.topmost().goBack() pops, and you can pass a specific backstack entry to pop several screens at once. Modals are a separate stack: page.showModal(moduleName, { context, closeCallback, fullscreen, animated }) presents a screen over the current one, and the modal calls closeModal(result) which invokes your closeCallback. The Android back button is handled through Application.android.on(AndroidApplication.activityBackPressedEvent), where you set args.cancel = true to intercept it, and forgetting to do so is why so many NativeScript apps exit unexpectedly from a modal or a nested frame. Apps with tabs use BottomNavigation or Tabs, each TabStripItem hosting its own Frame so every tab keeps an independent back stack.
import { Frame, Page, EventData } from '@nativescript/core';
// Push a screen and clear history after login
Frame.topmost().navigate({
moduleName: 'pages/home/home-page',
context: { userId: 4271 },
clearHistory: true,
animated: true,
transition: { name: 'slide', duration: 250, curve: 'easeOut' },
});
// Read the payload on the target page
export function onNavigatingTo(args: EventData) {
const page = args.object as Page;
const { userId } = page.navigationContext ?? {};
page.bindingContext = new HomeViewModel(userId);
}
// Present and close a modal
page.showModal('pages/filters/filters-modal', {
context: { city: 'Noida' },
closeCallback: (selected) => console.log('picked', selected),
fullscreen: false,
animated: true,
});
Key Points
- Frame maps to a Fragment stack on Android and UINavigationController on iOS
- clearHistory: true after login, backstackVisible: false for one-off screens
- context in, page.navigationContext out
- Modals are a separate stack closed via closeModal(result)
- Intercept Android back with activityBackPressedEvent and args.cancel
Q10What CSS does NativeScript actually support, and how do you handle dark mode and per-platform styling?
BasicStyling
Answer
NativeScript implements a subset of CSS mapped onto native styling, not a browser engine. You get type selectors matching the element name (Button, Label), class and id selectors, attribute selectors, descendant and direct-child combinators, and a few pseudo-classes: :highlighted for the pressed state, :disabled, :selected and :focus. What you do not get is anything layout related from the box model: no display: block, no float, no position: absolute, no percentage widths outside a GridLayout star column.
Layout is the job of the layout containers, CSS handles appearance only. Numeric values are device-independent pixels and the unit is optional, so border-radius: 12 is valid. NativeScript stamps a set of classes on the root view that you can key off: ns-android and ns-ios, ns-phone and ns-tablet, ns-portrait and ns-landscape, and ns-dark and ns-light which flip automatically with the system appearance.
That last pair is how you implement dark mode with no JavaScript at all. SASS is supported out of the box, and @nativescript/tailwind brings a Tailwind-style utility workflow with a NativeScript-aware preset that emits only properties the runtime understands. At runtime you can call Application.addCss(cssText) to append rules or Application.setCssFileName() to swap the whole stylesheet, which is the usual approach for a themeable white-label build.
/* app.scss */
.card {
background-color: #ffffff;
border-radius: 12;
padding: 16;
margin: 8 16;
}
/* automatic dark mode, no JS needed */
.ns-dark .card {
background-color: #14161a;
color: #e9edf2;
}
/* per-platform tweaks */
.ns-ios .title { font-size: 17; font-weight: 600; }
.ns-android .title { font-size: 16; font-family: 'Roboto-Medium'; }
/* tablet-only layout tuning */
.ns-tablet .card { margin: 12 48; }
/* pressed state */
#submit:highlighted { opacity: 0.7; }
Q11What are the Core, Angular, Vue, Svelte and React flavours, and how do you choose one?
BasicFlavours
Answer
Every flavour shares the same runtime, the same @nativescript/core UI classes and the same build pipeline. What differs is only the component and templating layer that drives those views. Core is plain TypeScript with XML views and the Observable binding model, it has the smallest dependency footprint and the fastest startup, but no component abstraction, so large apps get repetitive. @nativescript/angular is the most common flavour in Indian enterprise work: you get NgModules or standalone components, dependency injection, RxJS, the Angular router mapped onto Frame through page-router-outlet, and zone.js patched to track native async operations. nativescript-vue 3 gives Vue single-file components with the composition API and a much lighter mental model, popular with smaller teams. svelte-native compiles away the framework for very small bundles, react-nativescript brings JSX and hooks, and a Solid binding exists as well, but all three have thinner community support and fewer maintained examples.
The practical selection rule is organisational, not technical: pick whatever your web team already writes, because the value proposition of NativeScript is code and skill sharing with the web codebase. Switching flavour later is effectively a rewrite of the view layer, though your services, native interop and App_Resources carry over unchanged. In an interview, saying you would choose based on team skill rather than benchmark numbers is the answer that lands.
Key Points
- All flavours share one runtime, one UI library, one build pipeline
- Angular flavour dominates enterprise NativeScript work in India
- Vue 3 via nativescript-vue is the lighter mainstream option
- Core XML is fastest to boot but has no component model
- Choose by existing team skill, not benchmarks; a switch is a view-layer rewrite
Q12How do you make HTTP calls and persist data locally with @nativescript/core?
BasicNetworking and Storage
Answer
@nativescript/core ships Http, which wraps OkHttp on Android and NSURLSession on iOS. Http.request({ url, method, headers, content, timeout }) returns a promise resolving to an HttpResponse whose content exposes toJSON(), toString() and toImage(); there are also convenience helpers Http.getJSON, Http.getString and Http.getImage. A global fetch polyfill exists, so axios and most fetch-based clients work unchanged, which is how teams share an API layer with their web app.
Two production details matter. First, always set an explicit timeout, the platform defaults are long enough that a stalled request looks like a frozen screen on a weak network. Second, cleartext HTTP is blocked by default on both platforms: Android needs a network_security_config.xml in App_Resources and iOS needs an App Transport Security exception in Info.plist, and forgetting this is the classic 'works on my machine, fails on the device' bug when pointing at a local backend.
For storage there are three tiers. ApplicationSettings is a synchronous key-value store backed by SharedPreferences and NSUserDefaults, perfect for flags and small preferences but never for tokens, since neither store is encrypted. Use a Keychain and Keystore backed plugin for credentials.
The file system module gives knownFolders.documents(), Folder and File for JSON caches and downloads. For anything relational, use a SQLite plugin rather than serialising a large JSON blob on every write.
import { Http, ApplicationSettings, Connectivity, knownFolders } from '@nativescript/core';
export async function loadJobs(token: string) {
if (Connectivity.getConnectionType() === Connectivity.connectionType.none) {
return readCache();
}
const res = await Http.request({
url: 'https://api.goodspace.ai/v1/jobs?city=Noida',
method: 'GET',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
timeout: 15000,
});
if (res.statusCode !== 200) throw new Error('jobs failed: ' + res.statusCode);
const jobs = res.content.toJSON();
ApplicationSettings.setNumber('jobs.syncedAt', Date.now());
await knownFolders.documents().getFile('jobs.json').writeText(JSON.stringify(jobs));
return jobs;
}
async function readCache() {
const file = knownFolders.documents().getFile('jobs.json');
return JSON.parse((await file.readText()) || '[]');
}
Q13How do you hook into application lifecycle events, and why does lowMemoryEvent matter?
BasicLifecycle
Answer
The Application module exposes cross-platform lifecycle events you subscribe to before calling Application.run(), usually in app.ts. launchEvent fires once at cold start, resumeEvent and suspendEvent fire on every foreground and background transition, exitEvent fires on termination, displayedEvent fires after the first frame is drawn (the right place to hide a splash or record a time-to-interactive metric), and orientationChangedEvent reports the new orientation. uncaughtErrorEvent is the one every production app must wire up: it hands you the JS error before the runtime tears the app down, which is your last chance to send it to Sentry or Crashlytics. lowMemoryEvent is the platform warning that your process is a candidate for termination, delivered from onTrimMemory on Android and didReceiveMemoryWarning on iOS. Handle it by dropping caches, clearing an ImageCache and releasing anything you can rebuild, because ignoring it is why an app comes back from the background with a blank screen: Android killed the process, and the user perceives it as a crash. Beyond the cross-platform set there are platform-specific hooks, Application.android.on(AndroidApplication.activityBackPressedEvent) for intercepting the hardware back button with args.cancel = true, and activityResultEvent for receiving results from a native Activity you started. Suspend is also where you flush analytics and persist drafts, since iOS gives you only a short window before the process is frozen.
import { Application, AndroidApplication, ImageCache } from '@nativescript/core';
Application.on(Application.displayedEvent, () => hideSplash());
Application.on(Application.suspendEvent, () => {
analytics.flush();
draftStore.persist();
});
Application.on(Application.lowMemoryEvent, () => {
ImageCache.prototype.clear?.call(imageCache);
jobsCache.clear();
});
Application.on(Application.uncaughtErrorEvent, (args) => {
crashReporter.capture(args.error, { fatal: true });
});
if (Application.android) {
Application.android.on(AndroidApplication.activityBackPressedEvent, (args) => {
if (modalIsOpen) {
args.cancel = true; // swallow back, close the modal ourselves
closeModal();
}
});
}
Application.run({ moduleName: 'app-root' });
Key Points
- Subscribe before Application.run(), normally in app.ts
- displayedEvent is the honest time-to-interactive marker
- uncaughtErrorEvent is your last chance to report a fatal JS error
- lowMemoryEvent means drop caches now or get killed in the background
- activityBackPressedEvent with args.cancel handles the Android back button
Q14How do you add a NativeScript plugin, and why do some plugins force a full rebuild?
BasicPlugins
Answer
ns plugin add @nativescript/camera installs the npm package and runs its native hooks. A NativeScript plugin is an ordinary npm package with an optional platforms folder: platforms/android/include.gradle declares Gradle dependencies, repositories and manifest additions that get merged into the generated Android project, and platforms/ios/Podfile declares CocoaPods and build settings merged into the generated Xcode project. Plugins may also ship prebuilt AAR or framework binaries.
That is exactly why some installs require a full rebuild: if the plugin only adds JavaScript, LiveSync pushes the new bundle and hot module replacement applies it in place, but if it changes Gradle dependencies, the Podfile, the manifest or native binaries, the native project has to be regenerated and recompiled, so you stop the run, ns clean if it misbehaves, and start again. The ecosystem reality is the harder part of this question. The official set lives under the @nativescript scope and the community set under @nativescript-community, and plenty of older nativescript-* packages on npm target NativeScript 6 and will not build against 8.x, typically failing with a Gradle resolution error or a missing tns-core-modules import.
Before adopting a plugin, check the last publish date, whether it declares support for the 8.x runtime, and whether its Android dependencies clash with the AndroidX versions you already pull in. Given how easy direct native access is, wrapping the API yourself in fifty lines is often safer than adopting an unmaintained dependency.
# Install and run native hooks
ns plugin add @nativescript/camera
ns plugin add @nativescript-community/ui-collectionview
# After anything that touches native config
ns clean && npm install && ns run android
# A plugin's Android native wiring: platforms/android/include.gradle
# dependencies {
# implementation 'com.google.android.gms:play-services-location:21.3.0'
# }
# A plugin's iOS native wiring: platforms/ios/Podfile
# pod 'FirebaseMessaging', '~> 11.0'
# Building a local plugin in a workspace
ns plugin build
Q15Which thread does your JavaScript run on in NativeScript, and what breaks because of it?
IntermediateConcurrency
Answer
Your JavaScript runs on the main UI thread on both platforms. There is no separate JS thread the way React Native traditionally had one. That is what makes synchronous native calls possible and what makes UI updates immediate, but it also means any long-running JavaScript blocks rendering and input.
On Android, blocking the main thread for roughly five seconds triggers an ANR dialog and Play Console records it against your vitals; on iOS the watchdog kills an app that blocks the main thread during launch. So a JSON.parse of a two megabyte response, a sort over ten thousand records, image resizing in JS, or a synchronous crypto operation will all visibly freeze the app. The framework's async APIs (Http, ImageSource.fromUrl, file reads with the async variants) do their work on native background threads and then resume your callback on the main thread, so they are safe.
The mirror-image problem is calling into JavaScript from a native background thread, which happens whenever you implement a Java interface or an Objective-C completion handler that native code invokes off-thread. Touching UI from there throws, and on Android the runtime will report calling a JS method from the wrong thread. The fix is Utils.dispatchToMainThread, which always queues, or Utils.executeOnMainThread, which runs inline if you are already on the main thread. For genuinely heavy computation the only correct answer is a Worker.
import { Utils } from '@nativescript/core';
// BAD: freezes the UI, ANR risk on Android
function rank(rows: Candidate[]) {
return rows.map(scoreCandidate).sort((a, b) => b.score - a.score);
}
// A Java interface implemented in JS is invoked on OkHttp's background thread
const runnable = new java.lang.Runnable({
run: () => {
const rows = readFromNativeDb();
// must hop back before touching any view or binding context
Utils.dispatchToMainThread(() => {
viewModel.set('rows', rows);
});
},
});
new java.lang.Thread(runnable).start();
// runs inline when already on the main thread, otherwise queues
Utils.executeOnMainThread(() => page.getViewById('list').refresh());
Key Points
- JS executes on the main UI thread on Android and iOS
- Blocking it for ~5s produces an Android ANR; iOS watchdog kills slow launches
- Core async APIs do native work off-thread and resume on main
- Native callbacks can arrive on background threads; marshal back with Utils.dispatchToMainThread
- Heavy CPU work belongs in a Worker, not in a setTimeout
Q16How do Workers work in NativeScript, and what cannot cross postMessage?
IntermediateConcurrency
Answer
NativeScript implements the Web Worker API on top of a second JS runtime instance. new Worker('~/workers/score-worker') spawns a worker with its own V8 or JavaScriptCore isolate, its own global scope and its own copy of any module it imports. Communication is postMessage in both directions with onmessage handlers, and structured cloning applies, so only JSON-serialisable data crosses. You cannot pass a native object, a function, a class instance with methods, or a UI element.
You also cannot touch the UI from a worker at all: there is no Frame, no Page and no bindingContext, and attempting a UI call throws. Workers can, however, use native APIs that are thread-safe, which is the useful part, decoding an image, parsing a large file with the native JSON or XML parsers, or running a crypto routine. The build side matters: @nativescript/webpack detects the string literal in the new Worker(...) call at compile time and emits the worker as a separate bundle, so the path must be a literal, not a computed variable, otherwise the worker file is never bundled and you get a module-not-found error at runtime. Each worker costs memory (a whole isolate) and tens of milliseconds to start, so pool them rather than spawning per operation, and always call terminate() or close() when done, because a leaked worker keeps its isolate alive for the life of the process.
// main thread
const worker = new Worker('~/workers/score-worker');
worker.postMessage({ rows: candidates }); // plain JSON only
worker.onmessage = ({ data }) => {
viewModel.set('ranked', data.ranked);
worker.terminate();
};
worker.onerror = (err) => {
console.log('worker failed', err.message, err.filename, err.lineno);
worker.terminate();
};
// ~/workers/score-worker.ts
import '@nativescript/core/globals';
const ctx: Worker = self as any;
ctx.onmessage = (msg) => {
const rows = msg.data.rows;
const ranked = rows
.map((r) => ({ ...r, score: expensiveScore(r) }))
.sort((a, b) => b.score - a.score);
ctx.postMessage({ ranked }); // structured clone back to main
};
Key Points
- Each worker is a separate JS isolate with no shared memory
- Only structured-cloneable data crosses postMessage, never native objects
- No UI access whatsoever from inside a worker
- The worker path must be a string literal so webpack can bundle it
- Pool workers and terminate them; each isolate costs real memory
Q17Explain marshalling between JavaScript and native types, including the overload traps.
IntermediateNative Interop
Answer
Marshalling is the runtime's conversion layer at the VM boundary. Primitives convert automatically: a JS string becomes java.lang.String or NSString, a boolean becomes boolean or BOOL, and a JS Date maps to NSDate or java.util.Date. Numbers are where candidates get caught.
JavaScript has one numeric type, Java has five, so when a method is overloaded the runtime picks based on the value and the available signatures, and it can pick wrong. Passing 7 to a method overloaded for int and long is ambiguous, and the fix is to construct the box type explicitly with new java.lang.Integer(7) or java.lang.Long.valueOf('...'). Java longs beyond Number.MAX_SAFE_INTEGER also lose precision on the way back, so treat large IDs and epoch-nanosecond timestamps as strings.
Java arrays are not JS arrays and must be created with Array.create(type, length); on iOS a JS array converts to NSArray automatically, and iOS structs such as CGRect and CGPoint marshal to plain objects with nested origin and size. For C pointers and out-parameters on iOS there is the interop namespace with interop.alloc, interop.Reference and interop.handleof. The performance point that senior interviewers care about: every crossing has a cost, so a loop that calls cursor.getString(i) ten thousand times pays ten thousand boundary crossings. Batch the work on the native side, return one serialised payload, and parse it once in JS.
// Java overload resolution: be explicit
const map = new java.util.HashMap<any, any>();
map.put('retries', new java.lang.Integer(3)); // int, not double
const epochNanos = java.lang.Long.valueOf('1754870400000000');
// Java arrays need Array.create, a JS array will not do
const perms = Array.create('java.lang.String', 2);
perms[0] = android.Manifest.permission.CAMERA;
perms[1] = android.Manifest.permission.RECORD_AUDIO;
androidx.core.app.ActivityCompat.requestPermissions(activity, perms, 1001);
// iOS structs arrive as plain objects
const frame = CGRectMake(0, 0, 320, 44);
console.log(frame.size.width); // 320
// iOS out-parameters via interop
const errRef = new interop.Reference<NSError>();
const ok = fileManager.removeItemAtPathError(path, errRef);
if (!ok) console.log(errRef.value.localizedDescription);
// SLOW: 10k boundary crossings
// for (let i = 0; i < cursor.getCount(); i++) rows.push(cursor.getString(0));
Q18What does @NativeClass() do, and why did it become mandatory in NativeScript 7?
IntermediateNative Interop
Answer
Extending a native class from JavaScript relies on the runtime intercepting class creation so it can generate a real Java subclass at build time or a real Objective-C subclass at runtime. That interception used to work because TypeScript downlevelled classes to ES5 constructor functions, which the runtime could call and rewrite freely. Once projects started targeting ES2015 and above, the emitted output became a genuine class, and a native class cannot be invoked without new nor patched the same way, so subclassing silently broke. @NativeClass() is the marker that tells the toolchain and the runtime that this particular class extends a native type and needs the special construction path.
It is globally available in NativeScript 8, so you do not import it, and it is required on every class that extends NSObject or a UIKit type, extends java.lang.Object, or implements an Android interface. Two related details come up in the same interview. On Android you must call super() and then return global.__native(this) from the constructor, which swaps in the runtime-backed instance so native callbacks reach your JS methods.
On iOS you declare static ObjCProtocols to list the protocols you conform to, and static ObjCExposedMethods when a selector must be visible to Objective-C at runtime, for example a target-action handler passed to addTargetActionForControlEvents. Omit ObjCProtocols and the protocol methods simply never fire, with no error, which is one of the most common time sinks for people new to the framework.
// Android: implement an interface and register the JS instance
@NativeClass()
class TapListener extends java.lang.Object implements android.view.View.OnClickListener {
constructor(private onTap: () => void) {
super();
return global.__native(this); // required on Android
}
onClick(view: android.view.View): void {
this.onTap();
}
}
// iOS: target-action needs the selector exposed to the ObjC runtime
@NativeClass()
class ButtonHandler extends NSObject {
static ObjCExposedMethods = {
tapped: { returns: interop.types.void, params: [interop.types.id] },
};
tapped(sender: UIButton): void {
console.log('tapped', sender.titleLabel.text);
}
}
const handler = ButtonHandler.new();
button.addTargetActionForControlEvents(handler, 'tapped', UIControlEvents.TouchUpInside);
Key Points
- ES2015 class emit broke the old subclassing mechanism, hence the decorator
- Globally available in NativeScript 8, no import needed
- Android constructors must return global.__native(this)
- iOS needs static ObjCProtocols for protocol conformance
- iOS needs static ObjCExposedMethods for selectors called by the ObjC runtime
Q19How do you implement an iOS delegate protocol such as CLLocationManagerDelegate from TypeScript?
IntermediateNative Interop
Answer
You declare a class extending NSObject, mark it with @NativeClass(), list the protocol in static ObjCProtocols, and implement the delegate methods using the flattened selector names. Objective-C selectors collapse into camelCase, so locationManager:didUpdateLocations: becomes locationManagerDidUpdateLocations and locationManager:didFailWithError: becomes locationManagerDidFailWithError. Getting a character wrong means the method never fires and nothing is logged, so copy the selector from the Apple headers rather than guessing.
Instantiate with the static new() or alloc().init() rather than a JS constructor when the class is Objective-C backed, and use a static factory that takes a WeakRef to your owning object. The WeakRef is not optional in production. CLLocationManager holds a strong ARC reference to its delegate, and if the delegate holds a strong JS reference back to your page or view model, you have created a cycle spanning two garbage collectors: ARC cannot see the JS reference and JavaScriptCore's collector cannot see the ARC reference, so neither side ever frees and the page leaks with its whole view tree.
Holding the owner in a WeakRef and calling deref() defensively breaks the cycle. The final requirement people forget is Info.plist: without NSLocationWhenInUseUsageDescription in App_Resources/iOS/Info.plist, iOS silently denies the authorisation request, so the delegate never receives an update and the bug looks like broken code rather than missing configuration.
@NativeClass()
class LocationDelegate extends NSObject implements CLLocationManagerDelegate {
static ObjCProtocols = [CLLocationManagerDelegate];
private owner: WeakRef<LocationService>;
static initWithOwner(owner: WeakRef<LocationService>): LocationDelegate {
const d = LocationDelegate.new() as LocationDelegate;
d.owner = owner; // weak, so no retain cycle with CLLocationManager
return d;
}
locationManagerDidUpdateLocations(mgr: CLLocationManager, locations: NSArray<CLLocation>) {
const last = locations.lastObject;
this.owner?.deref()?.onFix(last.coordinate.latitude, last.coordinate.longitude);
}
locationManagerDidFailWithError(mgr: CLLocationManager, error: NSError) {
this.owner?.deref()?.onError(error.localizedDescription);
}
}
export class LocationService {
private manager = CLLocationManager.new();
private delegate = LocationDelegate.initWithOwner(new WeakRef(this));
start() {
this.manager.delegate = this.delegate;
this.manager.requestWhenInUseAuthorization(); // needs Info.plist key
this.manager.startUpdatingLocation();
}
}
Q20How does memory management work across the JS and native heaps, and what changed with markingMode: none?
IntermediateMemory
Answer
There are two independent memory managers in every NativeScript process. The JS engine collects JavaScript objects, while Android uses its own JVM garbage collector and iOS uses ARC reference counting. Whenever you touch a native object from JS, the runtime creates a wrapper object that holds a strong reference to the native instance, so the native object stays alive at least as long as the JS wrapper.
Before NativeScript 7 the Android runtime ran in marking mode: during a V8 garbage collection it walked the object graph and marked every reachable Java object so the JVM would not collect something JavaScript still referenced. That coordination worked but was expensive, producing visible pauses on large heaps and occasional deadlocks between the two collectors. NativeScript 7 made markingMode: none the default.
The runtime no longer performs that graph walk, so garbage collection is faster and pauses disappear, but the guarantee changes: a Java object that is referenced only from native code, with no live JS wrapper, can now be collected on the JS side while native still expects the callback. The practical rule is to keep an explicit JS field reference to anything you hand to long-lived native code, listeners, delegates, broadcast receivers and callbacks, for the entire time native may invoke it. On iOS the equivalent hazard is a retain cycle: native retains your JS-backed object, your JS object retains native, and neither collector can see the other side, so you use WeakRef. For diagnosis, expose gc with the v8Flags key and call Utils.GC() in a test build to force a collection and prove whether an object is genuinely leaked or merely uncollected.
Key Points
- Two collectors: JS engine plus JVM GC on Android, JS engine plus ARC on iOS
- A JS wrapper holds a strong reference to its native counterpart
- markingMode: none is the default since NativeScript 7, faster but less forgiving
- Hold a JS field reference to every listener native code will call later
- Cross-runtime retain cycles on iOS need WeakRef to break
Q21How does ListView recycle item views, and when do you replace it with CollectionView or RadListView?
IntermediatePerformance
Answer
NativeScript's ListView maps onto a native list backed by a custom adapter on Android and a UITableView with a NativeScript cell class on iOS, and both recycle. The framework instantiates a template once per visible slot plus a small buffer, then reuses those view instances as rows scroll by, swapping the bindingContext instead of building new views. That is why the correct model for a list is an ObservableArray: it emits granular add, delete and splice events so the native list animates a single row change instead of reloading everything.
Recycling is also the source of the classic bug. If you imperatively mutate a view inside the item template, for example setting a colour or toggling visibility in an itemLoading handler without an else branch, a recycled row keeps the state of whichever item last used it, so the wrong row appears highlighted after scrolling. Everything visual should come from the binding, not from imperative mutation.
When you need more than a vertical list of uniform rows, ListView runs out quickly. itemTemplateSelector lets you return a template key per item and each key gets its own recycling pool, which handles mixed feeds. Beyond that, @nativescript-community/ui-collectionview wraps RecyclerView and UICollectionView and is the right choice for grids, staggered layouts and horizontal carousels, while RadListView adds swipe actions, grouping, pull-to-refresh and load-on-demand out of the box. Setting a fixed rowHeight when your rows are uniform also removes a per-item measure pass.
<!-- Mixed feed: one recycling pool per template key -->
<ListView items="{{ feed }}"
itemTemplateSelector="item.kind"
rowHeight="96"
loadMoreItems="{{ onLoadMore }}"
itemTap="{{ onItemTap }}">
<ListView.itemTemplates>
<template key="job">
<GridLayout columns="auto, *" class="row">
<Image col="0" src="{{ logo }}" decodeWidth="64" decodeHeight="64" loadMode="async" />
<Label col="1" text="{{ title }}" class="title" />
</GridLayout>
</template>
<template key="promo">
<Label text="{{ copy }}" class="promo" />
</template>
</ListView.itemTemplates>
</ListView>
<!-- Bad: recycled rows keep stale state because there is no else branch -->
<!-- itemLoading: (args) => { if (isNew) args.view.backgroundColor = 'yellow'; } -->
Key Points
- Views are recycled and the bindingContext is swapped, not rebuilt
- ObservableArray gives granular row updates instead of a full reload
- Never mutate a recycled view imperatively without resetting every branch
- itemTemplateSelector creates a separate pool per template key
- Use ui-collectionview (RecyclerView / UICollectionView) for grids and carousels
Q22How do you build a custom native UI component and expose it as a markup tag?
IntermediateCustom Components
Answer
Subclass View (or ContentView, or LayoutBase) from @nativescript/core and implement three lifecycle hooks. createNativeView() constructs and returns the underlying native widget, an android.widget.* instance built with this._context on Android or a UIView subclass on iOS, and is called once per view instance. initNativeView() runs each time the native view is attached and is where you wire listeners and set the owner back-reference. disposeNativeView() runs on detach and must undo exactly what initNativeView did, unregistering listeners and nulling the owner reference; skipping this is a genuine leak, because the native view holds your JS instance and the JS instance holds the view tree. For declarative attributes you define a Property with a name, an optional defaultValue and a valueConverter (markup values always arrive as strings), then implement the generated [myProperty.setNative] symbol method where you push the value onto the native widget. Call myProperty.register(MyView) once at module scope.
CssProperty works the same way for style-driven values. Making the class usable as a tag depends on the flavour: registerElement('StarRating', () => StarRating) from @nativescript/angular or from nativescript-vue, or an xmlns namespace import in Core XML. The detail interviewers probe is the difference between createNativeView and initNativeView, because putting listener registration in createNativeView means a view that is detached and reattached loses its handlers or registers them twice.
import { View, Property } from '@nativescript/core';
export const ratingProperty = new Property<StarRating, number>({
name: 'rating',
defaultValue: 0,
valueConverter: (v) => parseFloat(v), // markup gives you strings
});
export class StarRating extends View {
nativeViewProtected: android.widget.RatingBar;
createNativeView() {
return new android.widget.RatingBar(this._context); // once per instance
}
initNativeView() {
(this.nativeViewProtected as any).owner = this;
this.nativeViewProtected.setOnRatingBarChangeListener(this.listener);
}
disposeNativeView() {
this.nativeViewProtected.setOnRatingBarChangeListener(null);
(this.nativeViewProtected as any).owner = null; // or you leak the page
}
[ratingProperty.setNative](value: number) {
this.nativeViewProtected.setRating(value);
}
}
ratingProperty.register(StarRating);
// Angular flavour
// registerElement('StarRating', () => StarRating);
// <StarRating rating="4"></StarRating>
Q23In the Angular flavour, what is the difference between router-outlet and page-router-outlet?
IntermediateAngular Flavour
Answer
page-router-outlet performs real native navigation. Each route rendered through it creates a new Page and pushes it onto the Frame, so you get the platform back stack, the native transition, the hardware back button on Android and the swipe-back gesture on iOS. router-outlet simply swaps the component's content inside the current page with no navigation and no transition, which is what you want for a segmented view, a wizard step inside one screen, or the detail pane of a tablet layout. Mixing them up produces the two most common Angular-flavour bugs: back does nothing because you used router-outlet, or every tab switch pushes a page because you used page-router-outlet where you wanted in-page swapping.
Navigation itself goes through RouterExtensions rather than the plain Angular Router, because it accepts NativeScript-only options: clearHistory to wipe the back stack after login, transition to pick the animation, and animated: false for instant swaps. In markup, nsRouterLink is the NativeScript equivalent of routerLink and takes pageTransition and clearHistory inputs. The framework-level detail worth knowing is zone.js.
The Angular flavour patches native async operations so change detection runs automatically, but a callback arriving from an unpatched native API, a delegate method or a Java listener, updates your model outside the Angular zone, so the view never refreshes. The fix is to wrap that callback body in NgZone.run(), and recognising that symptom quickly is a strong senior signal.
// app-routing.module.ts
import { NativeScriptRouterModule } from '@nativescript/angular';
const routes = [
{ path: '', redirectTo: '/jobs', pathMatch: 'full' },
{ path: 'jobs', component: JobsComponent },
{ path: 'jobs/:id', component: JobDetailComponent },
];
@NgModule({ imports: [NativeScriptRouterModule.forRoot(routes)] })
export class AppRoutingModule {}
// component
constructor(private router: RouterExtensions, private zone: NgZone) {}
openJob(id: number) {
this.router.navigate(['/jobs', id], {
transition: { name: 'slide', duration: 200 },
});
}
afterLogin() {
this.router.navigate(['/jobs'], { clearHistory: true });
}
onNativeCallback(value: string) {
this.zone.run(() => (this.status = value)); // outside the zone otherwise
}
/* <page-router-outlet></page-router-outlet> native page stack
<router-outlet></router-outlet> in-page content swap
<Button [nsRouterLink]="['/jobs', id]" pageTransition="fade"></Button> */
Key Points
- page-router-outlet pushes a real Page onto the Frame; router-outlet swaps content
- RouterExtensions adds clearHistory, transition and animated options
- nsRouterLink replaces routerLink in markup
- Callbacks from unpatched native APIs run outside the Angular zone
- Wrap those callbacks in NgZone.run() or the view silently never updates
Q24How do you debug a NativeScript app, and how do you read a native crash that has no JS stack?
IntermediateDebugging
Answer
For JavaScript, ns debug android or ns debug ios starts the app with the inspector attached and prints a devtools URL you open in Chrome, giving you breakpoints, stepping, the console and network inspection against the running device. Add --debug-brk to pause before the first line, which is the only way to debug startup code. Source maps come from webpack, so keep the maps produced by the release build if you ever want to symbolicate a minified production stack trace.
Silent binding failures are a separate class of bug with no exception at all, and the tool for those is the Trace module: enable it and add the Binding and Navigation categories to see exactly which property path failed to resolve. Native crashes are the harder half. When the process dies without a JS stack, the answer is not in the DevTools console.
On Android run adb logcat and look for the FATAL EXCEPTION block and its Caused by chain, which tells you the real Java exception; on iOS use the Xcode device console or the crash report from the Devices window and look at the failing thread and the exception type. A message like calling js method onCreate failed means a JS error occurred inside an Android lifecycle callback, and the actual JS error appears immediately below it. Remember logcat truncates large payloads, so raise maxLogcatObjectSize in nativescript.config.ts before blaming a missing log line on a missing code path.
# JS debugging with Chrome DevTools
ns debug android
ns debug ios --debug-brk # pause before the first line
ns run android --no-hmr # rule out HMR when state looks stale
# Native crash on Android
adb logcat --pid=$(adb shell pidof -s ai.goodspace.mobile) *:E
adb logcat | grep -A 40 "FATAL EXCEPTION"
# iOS device logs
# Xcode > Window > Devices and Simulators > View Device Logs
// Find silent binding failures
import { Trace } from '@nativescript/core';
Trace.setCategories(Trace.categories.concat(Trace.categories.Binding, Trace.categories.Navigation));
Trace.enable();
Q25How do you test a NativeScript app: unit tests, on-device tests and end to end?
IntermediateTesting
Answer
ns test init scaffolds the on-device test setup: it asks for Jasmine, Mocha or QUnit, adds @nativescript/unit-test-runner, writes a karma.conf.js and creates a tests folder. ns test android or ns test ios then builds a test flavour of the app, deploys it to a device or emulator and streams results back to Karma in your terminal. The important property is that these tests execute inside the real app process, so you can assert against real native views, real file system behaviour and real device APIs rather than a mock, which is exactly why they are worth the slower feedback loop. Pure logic (view models, reducers, formatters, API clients) can and should also be covered by Jest running in plain Node with @nativescript/core mocked, because that runs in seconds in CI while device tests need an emulator.
Keep native-touching code behind a thin interface so the Jest layer can substitute it. For end to end, Appium is the practical choice with the UiAutomator2 driver on Android and XCUITest on iOS. The selector strategy matters: set accessibilityIdentifier on any element the tests need to find, which NativeScript 8 uses in place of the deprecated automationText, mapping to contentDescription on Android and accessibilityIdentifier on iOS so one selector works everywhere. In CI, run the Jest suite on every pull request, the device suite on an emulator job, and reserve the Appium suite for a nightly run because it is slow and flaky by nature.
# scaffold the on-device runner
ns test init --framework jasmine
ns test android --justlaunch
ns test ios --emulator
// src/tests/job-vm.spec.ts (runs on the device)
import { Label } from '@nativescript/core';
import { JobViewModel } from '../view-models/job-vm';
describe('JobViewModel', () => {
it('marks a job applied and notifies the binding', () => {
const vm = new JobViewModel({ id: 7, title: 'Backend Engineer' });
let fired = '';
vm.on(Observable.propertyChangeEvent, (a: any) => (fired = a.propertyName));
vm.apply();
expect(vm.get('isApplied')).toBe(true);
expect(fired).toBe('isApplied');
});
it('renders into a real native Label', () => {
const label = new Label();
label.text = 'Backend Engineer';
expect(label.text).toBe('Backend Engineer');
});
});
<!-- Appium-friendly markup -->
<!-- <Button text="Apply" accessibilityIdentifier="btn-apply" /> -->
Key Points
- ns test init plus @nativescript/unit-test-runner runs Karma inside the app
- Device tests can assert on real native views and real device APIs
- Jest in Node for pure logic keeps pull-request feedback fast
- Appium with UiAutomator2 and XCUITest for end to end
- accessibilityIdentifier replaced the deprecated automationText in NativeScript 8
Q26What does @nativescript/webpack v5 do, and how do you extend the build configuration?
IntermediateBuild Pipeline
Answer
Since NativeScript 8, @nativescript/webpack ships a preconfigured build rather than a giant config file in your repo. Your webpack.config.js is a few lines: call webpack.init(env), optionally webpack.useConfig('angular') or 'vue' or 'typescript' to pick the flavour base, then return webpack.resolveConfig(). Under the hood it wires the platform-aware resolver that picks up .android.ts and .ios.ts, the loaders for XML, SCSS and Angular templates, worker bundling, the DefinePlugin globals such as __ANDROID__, __IOS__ and __DEV__, hot module replacement for LiveSync, and the CSS pipeline.
You customise it with webpack.chainWebpack(), which hands you a webpack-chain instance so you can add aliases, tap into an existing plugin's arguments or push a new rule, all without forking the base configuration. Everything after --env. on the CLI arrives in the env object, so ns run android --env.apiBase=staging is how you pass build-time configuration, and the built-in flags include --env.production, --env.aot for Angular ahead-of-time compilation, --env.snapshot for the V8 heap snapshot, --env.report to produce a bundle analyzer report and --env.verbose to print the resolved configuration. The verbose flag is the one that saves time in interviews and in real debugging, because when a loader or alias is not doing what you expect, printing the resolved config immediately shows whether your chainWebpack hook actually ran.
const webpack = require('@nativescript/webpack');
const { resolve } = require('path');
module.exports = (env) => {
webpack.init(env);
webpack.useConfig('angular');
webpack.chainWebpack((config) => {
config.resolve.alias.set('@shared', resolve(__dirname, 'src/shared'));
config.plugin('DefinePlugin').tap((args) => {
args[0]['process.env.API_BASE'] = JSON.stringify(env.apiBase || 'https://api.goodspace.ai');
return args;
});
if (env.production) {
config.optimization.minimizer('TerserPlugin').tap((args) => {
args[0].terserOptions.compress.drop_console = true;
return args;
});
}
});
return webpack.resolveConfig();
};
// ns run android --env.apiBase=https://staging.goodspace.ai --env.verbose
// ns build android --release --env.production --env.aot --env.report
Q27How do you consume a native Android AAR or an iOS CocoaPod from a NativeScript app?
IntermediateNative Interop
Answer
On Android you have two routes. For a published artifact, add the dependency to App_Resources/Android/app.gradle inside a dependencies block, along with any custom repository; the CLI merges that file into the generated Gradle project. For a local binary, drop the .aar or .jar into App_Resources/Android/libs and it is picked up automatically.
Either way, after the build the classes are visible in the metadata, so you can call com.yourvendor.sdk.Client directly from TypeScript with no wrapper. On iOS you add pod lines to App_Resources/iOS/Podfile and ns build ios runs pod install for you, and a post_install hook is usually needed to force IPHONEOS_DEPLOYMENT_TARGET on pods that still default to an older version. Newer 8.x releases also accept a Swift Package Manager declaration in nativescript.config.ts for packages distributed that way.
Two failure modes dominate real projects. The first is Android dependency conflicts, where two plugins pull different AndroidX or Play Services versions and Gradle fails with a duplicate class error; you resolve it with an exclude on the offending group or a resolutionStrategy force in app.gradle. The second is Swift visibility: the runtime reads Objective-C metadata, so a pure Swift API is invisible unless its classes and members are marked public and annotated with @objc, and the class inherits from NSObject. If a Swift SDK autocompletes in Xcode but does not exist from TypeScript, that is almost always the reason.
// App_Resources/Android/app.gradle
android {
defaultConfig { minSdkVersion 24 }
}
repositories {
maven { url 'https://maven.vendor.com/releases' }
}
dependencies {
implementation 'com.vendor.sdk:client:3.2.1'
implementation('com.other:lib:1.4.0') {
exclude group: 'com.google.android.gms' // kill the duplicate class error
}
}
# App_Resources/iOS/Podfile
pod 'FirebaseMessaging', '~> 11.0'
post_install do |installer|
installer.pods_project.targets.each do |t|
t.build_configurations.each do |c|
c.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
end
end
end
// Then just call it from TypeScript
const client = new com.vendor.sdk.Client(Utils.android.getApplicationContext());
client.setApiKey('...');
Q28How do you handle Android runtime permissions and iOS privacy prompts correctly?
IntermediatePlatform Integration
Answer
The two platforms work nothing alike, so you need a branch per platform behind one interface. On Android you declare the permission in App_Resources/Android/src/main/AndroidManifest.xml, then at runtime check ContextCompat.checkSelfPermission against PackageManager.PERMISSION_GRANTED and call ActivityCompat.requestPermissions with a Java string array built by Array.create and a request code. The result arrives on Application.android's activityRequestPermissionsEvent, which you must subscribe to before requesting.
Two Android specifics catch people out in 2026: from Android 13 the storage permission split into granular media permissions such as READ_MEDIA_IMAGES, and notifications now require POST_NOTIFICATIONS to be requested at runtime, so an app that only declares the old permissions silently shows nothing. Also, from Android 11 a second denial is treated as permanent, so shouldShowRequestPermissionRationale returning false after a denial means you must send the user to app settings rather than re-prompting. On iOS there is no generic permission API.
Each framework requests its own access, AVCaptureDevice.requestAccessForMediaTypeCompletionHandler for the camera, PHPhotoLibrary.requestAuthorizationForAccessLevelHandler for photos, CLLocationManager.requestWhenInUseAuthorization for location, and every one of them requires a matching usage-description string in App_Resources/iOS/Info.plist. Missing that string does not produce a denial, it terminates the process immediately with a message about accessing privacy-sensitive data without a usage description, which is a crash that only appears on a real device and often escapes review until store submission.
import { Application, AndroidApplication, Utils, isAndroid } from '@nativescript/core';
const REQ_CAMERA = 4101;
export function requestCamera(): Promise<boolean> {
if (isAndroid) {
const activity = Application.android.foregroundActivity;
const perm = android.Manifest.permission.CAMERA;
const granted = android.content.pm.PackageManager.PERMISSION_GRANTED;
if (androidx.core.content.ContextCompat.checkSelfPermission(activity, perm) === granted) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
const handler = (args: any) => {
if (args.requestCode !== REQ_CAMERA) return;
Application.android.off(AndroidApplication.activityRequestPermissionsEvent, handler);
resolve(args.grantResults[0] === granted);
};
Application.android.on(AndroidApplication.activityRequestPermissionsEvent, handler);
const perms = Array.create('java.lang.String', 1);
perms[0] = perm;
androidx.core.app.ActivityCompat.requestPermissions(activity, perms, REQ_CAMERA);
});
}
// iOS: needs NSCameraUsageDescription in Info.plist or the app is killed
return new Promise((resolve) =>
AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(AVMediaTypeVideo, resolve),
);
}
Key Points
- Android: manifest declaration plus a runtime request with activityRequestPermissionsEvent
- Android 13+ needs READ_MEDIA_IMAGES and POST_NOTIFICATIONS explicitly
- Android 11+ treats a second denial as permanent, route users to settings
- iOS has no generic API, each framework requests its own access
- A missing Info.plist usage string crashes the app instead of denying access
Q29A NativeScript app takes four seconds to reach its first screen. How do you cut cold start time?
AdvancedPerformance
Answer
Measure before you touch anything. Application.displayedEvent gives you an honest in-app marker, adb shell am start -W reports TotalTime for the Android launch, and Xcode's launch diagnostics cover iOS. Cold start in NativeScript is four costs: process and runtime initialisation, metadata loading, JavaScript parse and execute, and your own first-screen work.
The biggest Android lever is the V8 heap snapshot, enabled with ns build android --release --env.snapshot. It pre-parses and pre-compiles the vendor bundle at build time and embeds the resulting heap blob in the APK, so the engine skips parse and compile entirely at launch. The constraint is that snapshotted code runs with no platform available, so any module that touches a native API, Application state or the file system at module scope will break the snapshot; keep those in lazily required modules.
Where a snapshot is impractical, codeCache: true in nativescript.config.ts persists V8 compiled code between launches for a smaller but easier win. On the Angular flavour, --env.aot removes runtime template compilation and lazy-loaded route modules keep the initial bundle small. Then attack your own code: everything imported at the top of app.ts executes during startup, so a chatty analytics SDK or a plugin initialised eagerly is paid by every user on every cold launch.
Require plugins on first use, render a shell page immediately and hydrate it after displayedEvent, and never block launch on a network call. On iOS, launch cost scales with metadata size, so filtering metadata is the equivalent lever there.
# 1. Measure
adb shell am start -W -n ai.goodspace.mobile/com.tns.NativeScriptActivity
ns build android --release --env.production --env.report # find bundle bloat
# 2. V8 heap snapshot (Android, release builds)
ns build android --release --env.snapshot --env.aot
// 3. Keep snapshotted modules platform-free
// BAD: runs at module scope, breaks the snapshot
// const ctx = Utils.android.getApplicationContext();
// GOOD: defer until first use
let _client: AnalyticsClient | undefined;
export function analytics(): AnalyticsClient {
if (!_client) {
const { AnalyticsClient } = require('./analytics-client');
_client = new AnalyticsClient();
}
return _client;
}
// 4. Do non-critical work after the first frame
Application.on(Application.displayedEvent, () => {
setTimeout(() => { analytics().start(); warmCaches(); }, 0);
});
Key Points
- Measure with displayedEvent and adb shell am start -W before optimising
- V8 heap snapshot (--env.snapshot) is the largest Android win
- Snapshotted modules must not touch native APIs at module scope
- codeCache: true is the smaller, lower-risk alternative
- Angular AOT plus lazy routes; defer all plugin init past displayedEvent
Q30How does iOS metadata generation work, and when would you filter it?
AdvancedRuntime Internals
Answer
During an iOS build the NativeScript metadata generator parses the Objective-C headers of the iOS SDK plus every framework and pod your app links, and produces a compact binary metadata file that ships inside the app bundle. At launch the runtime maps that file and uses it to resolve any global class, protocol, structure, enum or C function the moment your JavaScript references it. This is what makes UIAlertController simply exist as a global with no import, and it is also a cost: the metadata grows with every framework you link, and the runtime pays to load and index it on every cold start, plus the resident memory it occupies.
Filtering is the lever. You place a whitelist.plist and optionally a blacklist.plist in App_Resources/iOS, listing modules or specific symbols to keep or drop, and the generator emits metadata only for what survives the filter. On an app that links many pods but uses a narrow slice of each, this measurably reduces both binary size and launch time.
The risk is real and asymmetric: filtering out something used on a rare code path produces no build error at all, just an undefined global at runtime on the one screen that needs it, typically surfacing as a TypeError about reading a property of undefined. So filter conservatively, start by whitelisting the frameworks you actually call, and run a full regression pass across every flow before shipping. A related symptom worth knowing: if a class from a newly added pod is undefined in TypeScript, the metadata is usually stale, and ns clean followed by a rebuild fixes it.
<!-- App_Resources/iOS/whitelist.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<array>
<string>Foundation.*</string>
<string>UIKit.*</string>
<string>CoreLocation.CLLocationManager*</string>
<string>AVFoundation.AVCaptureDevice*</string>
<string>FirebaseMessaging.*</string>
</array>
</plist>
<!-- App_Resources/iOS/blacklist.plist: drop what you never touch -->
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<array>
<string>Metal.*</string>
<string>SceneKit.*</string>
</array>
</plist>
# Stale metadata after adding a pod
# ns clean && npm install && ns run ios
Q31Production Android crashes with OutOfMemoryError while users scroll an image-heavy list. Walk through the diagnosis.
AdvancedProduction Debugging
Answer
Start by confirming the shape of the crash. A stack that reads java.lang.OutOfMemoryError: Failed to allocate a N byte allocation with M free tells you a single large allocation failed, and if N is in the tens of megabytes it is almost always a bitmap. Android decodes a JPEG into an uncompressed bitmap, so a 4000 by 3000 photo occupies roughly 48 MB in ARGB_8888 regardless of its file size, and three of those in a scrolling list exhaust the heap on a mid-range device.
The first fix is to stop decoding at full resolution: set decodeWidth and decodeHeight on the Image element so the native decoder downsamples during decode rather than after, and set loadMode to async so decoding happens off the UI thread. Second, put a real image pipeline in front of it, a plugin backed by Fresco on Android and SDWebImage on iOS gives you bounded caches, pooling and correct eviction, which a raw Image element does not. Third, handle lowMemoryEvent by clearing your caches.
If the crash persists, it is a leak rather than a spike, and the tool is Android Studio's memory profiler attached to the running process: take a heap dump after several navigations and look for retained Activity or Page instances. The NativeScript-specific cause is event listeners registered in a page and never removed, subscribing to Application events, Connectivity monitoring or an Observable in onNavigatingTo without unsubscribing in onNavigatingFrom keeps the entire page and its view tree alive, images included, so every navigation permanently adds a copy. Raising largeHeap in the manifest hides the symptom and is not a fix.
<!-- Downsample during decode, off the UI thread -->
<Image src="{{ photoUrl }}"
decodeWidth="320" decodeHeight="320"
loadMode="async"
stretch="aspectFill" />
// The leak that survives every other fix
import { Application, Connectivity, EventData, Page } from '@nativescript/core';
let unsubscribe: () => void;
export function onNavigatingTo(args: EventData) {
const page = args.object as Page;
const onLowMemory = () => imageCache.clear();
Application.on(Application.lowMemoryEvent, onLowMemory);
Connectivity.startMonitoring((t) => page.bindingContext.set('online', t !== 0));
unsubscribe = () => {
Application.off(Application.lowMemoryEvent, onLowMemory);
Connectivity.stopMonitoring();
};
}
export function onNavigatingFrom() {
unsubscribe?.(); // without this the page never dies
}
Key Points
- A multi-megabyte single allocation in the OOM trace means a bitmap
- decodeWidth / decodeHeight downsample during decode, loadMode async moves it off the UI thread
- Use a Fresco / SDWebImage backed pipeline for bounded caching
- Heap-dump in Android Studio to distinguish a spike from a leak
- Unsubscribed page-level listeners retain the whole view tree; largeHeap is not a fix
Q32What store-compliance issues break NativeScript builds today, and how do you get ahead of them?
AdvancedRelease Engineering
Answer
Three categories bite NativeScript teams specifically. First, Android 16 KB memory pages. Google Play now requires apps targeting recent Android versions to support 16 KB page sizes, and a NativeScript app ships native shared libraries: the runtime itself plus a .so from every plugin that bundles native code.
All of them must be built with 16 KB ELF segment alignment. The action is to move to a @nativescript/android runtime release built against a recent NDK, then audit each plugin's prebuilt libraries and replace or rebuild anything stale. Verify the output rather than trusting the changelog, by inspecting segment alignment in the extracted .so files or using the alignment check on the built APK.
Second, the Play target API floor rises every August, so treat targetSdkVersion in App_Resources/Android/app.gradle as an annual maintenance item, and remember it usually drags a minSdkVersion and AndroidX bump along with it that older plugins will not survive. Third, Apple's privacy manifest requirement: your app needs PrivacyInfo.xcprivacy in App_Resources/iOS declaring required-reason API usage and any tracking domains, and third-party SDKs on Apple's list must ship their own signed manifests, so an unmaintained pod can block your submission with a rejection email rather than a build failure. Add the encryption declaration in Info.plist, ship an AAB rather than an APK, and make the whole thing a CI job that produces signed artefacts on every release branch so compliance breaks show up weeks before submission, not on launch day.
# Audit native libraries for 16 KB page alignment
unzip -o app-release.aab -d out/ >/dev/null
for so in $(find out -name '*.so'); do
echo "$so"; llvm-readelf --program-headers "$so" | grep -i 'LOAD' | head -2
done
# App_Resources/Android/app.gradle: treat these as annual maintenance
android {
defaultConfig {
minSdkVersion 24
targetSdkVersion 35 // raise as the Play floor moves each August
}
}
<!-- App_Resources/iOS/Info.plist -->
<key>ITSAppUsesNonExemptEncryption</key><false/>
<key>NSCameraUsageDescription</key>
<string>Used to record your video introduction.</string>
# Release artefacts from CI
ns build android --release --aab --env.production \
--key-store-path "$KEYSTORE" --key-store-alias upload
Q33How do you ship over-the-air JavaScript updates now that App Center CodePush is retired?
AdvancedRelease Engineering
Answer
Microsoft retired App Center in 2025 and CodePush went with it, so the hosted default no longer exists. Three options remain. The simplest is to stop doing OTA and rely on staged store rollouts, which is defensible for most apps in 2026 because Play and App Store review turnaround is measured in hours, not weeks.
The second is a CodePush-compatible alternative such as the nativescript-app-sync ecosystem, where a self-hosted or third-party server hosts bundle diffs and a client library swaps the JavaScript on next launch. The third is rolling your own: download a signed bundle to the documents folder, verify its signature, and point the runtime at it on the next start. Whichever you choose, the boundary is absolute: OTA can replace JavaScript, CSS and markup only.
Anything native, a new plugin, a Gradle dependency, a Podfile change, an App_Resources edit or a runtime upgrade, requires a store release, and pushing a JS bundle that calls a native API the installed binary does not contain crashes every user who receives it. That is why the non-negotiable engineering requirements are a compatibility key pinning each bundle to a native binary version, a staged rollout by percentage, and an automatic rollback: persist a boot counter, and if the app fails to reach displayedEvent twice in a row, revert to the bundle embedded in the binary. On policy, Apple permits executing downloaded interpreted code provided it does not change the app's primary purpose, so a bug-fix bundle is fine while shipping a whole new product through OTA is not.
import { Application, ApplicationSettings, knownFolders } from '@nativescript/core';
const BOOT_KEY = 'ota.pendingBoots';
const NATIVE_BUILD = 412; // bumped only by a store release
// Every bundle declares the native build it was compiled against
type Manifest = { version: string; minNativeBuild: number; url: string; sha256: string };
export function applyIfCompatible(m: Manifest): boolean {
if (m.minNativeBuild > NATIVE_BUILD) return false; // needs a store update
return download(m.url, m.sha256, knownFolders.documents().getFolder('ota'));
}
// Auto-rollback: a bundle that never reaches the first frame is reverted
ApplicationSettings.setNumber(BOOT_KEY, ApplicationSettings.getNumber(BOOT_KEY, 0) + 1);
if (ApplicationSettings.getNumber(BOOT_KEY, 0) >= 2) {
revertToEmbeddedBundle();
ApplicationSettings.setNumber(BOOT_KEY, 0);
}
Application.on(Application.displayedEvent, () => ApplicationSettings.setNumber(BOOT_KEY, 0));
Key Points
- App Center and CodePush are gone; self-hosted or store-only are the options
- OTA can change JS, CSS and markup only, never native code
- Pin each bundle to a minimum native build or you crash every user
- Staged rollout plus an automatic rollback on repeated boot failure
- Apple allows interpreted-code updates that do not change the app's purpose
Q34How would you plan a migration of a NativeScript 6 app to the 8.x line?
AdvancedMigration
Answer
Run ns migrate first and read what it changed, because it does most of the mechanical work: the tns-core-modules package becomes @nativescript/core, tns-platform-declarations becomes @nativescript/types, official nativescript-* plugins move to the @nativescript scope, the nativescript section of package.json moves into nativescript.config.ts, and the old nativescript-dev-webpack config collapses into the few-line @nativescript/webpack v5 form. The CLI binary is ns rather than tns. Then comes the work the tool cannot do.
Deep imports such as tns-core-modules/ui/frame must be rewritten to named imports from @nativescript/core. Every class extending a native type needs @NativeClass() added, and this is the change that produces the most confusing failures because the old code compiles fine and simply stops receiving native callbacks. markingMode defaulting to none means auditing every listener and delegate handed to long-lived native code for a JS-side reference that keeps it alive. automationText becomes accessibilityIdentifier, which breaks your Appium suite if you forget. Runtime and toolchain floors rise too: newer @nativescript/android and @nativescript/ios, a newer Gradle and Android Gradle Plugin, a higher minSdkVersion and a higher iOS deployment target.
The real cost, and the honest answer to give an interviewer, is plugins. A six-year-old codebase carries plugins that were abandoned before NativeScript 7, and each one is a decision: find a maintained replacement, fork and rebuild it, or delete it and call the native API directly, which is often the cheapest path. Sequence it as a branch, migrate, then get Android green before touching iOS, one plugin at a time, with the device test suite running throughout.
# 1. Branch, then let the CLI do the mechanical work
npm i -g nativescript
ns migrate
ns clean && npm install
# 2. Rebuild one platform at a time
ns run android
ns run ios
// 3. Import rewrites the tool will not do for you
// before
// import { topmost } from 'tns-core-modules/ui/frame';
// import * as app from 'tns-core-modules/application';
// after
import { Frame, Application } from '@nativescript/core';
Frame.topmost().navigate({ moduleName: 'pages/home/home-page' });
// 4. Every native subclass now needs the decorator
@NativeClass()
class Receiver extends android.content.BroadcastReceiver {
constructor() { super(); return global.__native(this); }
onReceive(ctx: android.content.Context, intent: android.content.Intent) {}
}
<!-- 5. Test selectors: automationText -> accessibilityIdentifier -->
Key Points
- ns migrate handles package renames, config move and the webpack collapse
- Deep tns-core-modules imports become named @nativescript/core imports
- @NativeClass() is mandatory and its absence fails silently at runtime
- markingMode: none means auditing every listener handed to native code
- Unmaintained plugins are the real cost; replacing one with direct native calls is often cheapest
Q35When would you argue against NativeScript, and how do you defend the choice against Flutter, React Native and Capacitor?
AdvancedArchitecture
Answer
Argue against it whenever ecosystem breadth or hiring speed is the dominant constraint. A greenfield consumer app that needs first-class SDKs for payments, chat, maps, attribution and analytics will find every vendor publishing React Native and Flutter support first, sometimes only. Hiring in India is the second argument: React Native and Flutter developers are available at every level in every city, whereas a NativeScript opening can take months to fill, and that operational risk usually outweighs any technical elegance.
The third honest caveat is maintenance surface. NativeScript is an OpenJS Foundation project maintained by a small core team, so plugin bit-rot is real and support for a brand-new OS release can lag, which you plan for by minimising plugin dependencies and being willing to call native APIs yourself. Against that, the cases where it genuinely wins: a team already fluent in Angular or Vue that wants to share code and skills rather than learn a new component model; an app whose value is deep OS integration, where wrapper availability is the project risk and unrestricted access to the entire platform SDK removes it; and any existing NativeScript codebase where a rewrite has no business case.
On the comparison itself, be precise rather than tribal. Flutter renders its own pixels, giving identical output everywhere at the cost of platform-native text input, accessibility and webview edge cases plus Dart hiring. React Native drives native widgets like NativeScript and, with JSI and Fabric plus the Expo workflow, has the strongest ecosystem and tooling story. Capacitor is a WebView, which is the cheapest path from an existing web app and the most expensive path to a complex, gesture-heavy UI.
Key Points
- Ecosystem breadth and India hiring speed are the strongest arguments against
- Small maintainer team means plugin bit-rot and OS-support lag are planning items
- Wins when the team is Angular or Vue and needs deep OS integration
- Flutter trades native fidelity for pixel-identical rendering
- Capacitor is cheapest from an existing web app, worst for complex native UI
Frequently Asked Questions
What does a NativeScript developer earn in India in 2026?
The band is roughly ₹5-18 LPA. Freshers and one-to-two-year developers on enterprise mobile teams at services firms typically land ₹5-9 LPA, mid-level developers who can debug the runtime and write native interop sit around ₹10-14 LPA, and senior engineers who own release engineering, performance and store compliance for a large NativeScript app reach ₹15-18 LPA and occasionally beyond at product companies. The compensation reality worth understanding is that NativeScript is rarely the whole job description. It usually appears as one item alongside Angular or Vue, and the offer is priced against your overall mobile and TypeScript depth. Engineers who can also do the native side, reading a Gradle error or an Xcode signing failure without help, are scarce and get paid noticeably better than those who only work above the framework line.
How long does it take to prepare for a NativeScript interview?
If you already ship Angular or Vue on the web, expect two to three weeks of focused work to be interview-ready. Week one: build and run a real app on both a physical Android device and an iOS simulator, learn the CLI, layouts, navigation and the Core binding model. Week two: native interop, calling Android and iOS APIs directly, @NativeClass(), delegates, workers, and the memory model. Week three: build tooling and release, webpack config, App_Resources, signing, and reading a crash log from logcat and from Xcode. The part candidates skip and then fail on is the release path. Actually producing a signed AAB and a device build, and understanding what breaks in each, is what separates a credible senior candidate from someone who has only ever run ns run android.
What is the difference between a fresher and an experienced NativeScript interview?
Fresher rounds stay above the framework line: layout containers, binding syntax, navigation, the CLI, calling an HTTP endpoint, and one or two native API calls. You are expected to know that GridLayout beats nested StackLayouts and that ObservableArray is required for lists. Experienced rounds go under the line. You will be asked which thread JavaScript runs on and why that causes ANRs, how marshalling picks a Java overload, what markingMode: none changed, how to break a retain cycle between a delegate and a page, how to cut cold start with a V8 snapshot, and how you handled the Android 16 KB page-size requirement. Senior loops almost always include a debugging exercise with a real stack trace, plus release-engineering questions about signing, store review and over-the-air updates.
Is NativeScript worth learning in 2026?
As a first cross-platform framework, no. React Native and Flutter have far larger job markets in India, bigger ecosystems and more employers hiring at every level. As a second skill, it can be genuinely valuable. A large amount of NativeScript work is maintenance and modernisation of enterprise apps built between 2017 and 2022, and because the talent pool is thin, competition for those roles is much lower than for a React Native opening. Contract and consulting rates for experienced NativeScript engineers are often better than the permanent salary band suggests. The honest framing for a career decision: learn it if you land in a team that already runs it, or if you want strong native interop skills that transfer directly to writing React Native TurboModules and Flutter platform channels later.
How should I compare NativeScript with React Native and Flutter in an interview?
Compare on three axes rather than declaring a winner. API reach: NativeScript exposes the entire platform SDK with no wrapper, React Native needs a TurboModule for anything outside its module surface, Flutter needs a platform channel or a package. Rendering: NativeScript and React Native both drive real native widgets, so you inherit platform look and behaviour, while Flutter draws every pixel itself, giving pixel-identical output across platforms but its own accessibility and text-input edge cases. Ecosystem and hiring: React Native and Flutter win decisively on package count, community answers and job volume in India. A good interview answer picks NativeScript for deep-device-integration apps on a TypeScript team that already knows Angular or Vue, and concedes the ecosystem point rather than pretending it does not exist.
Do I need to know Angular or Vue to get a NativeScript job?
Usually yes, because almost every production NativeScript codebase in India uses one of those flavours rather than plain Core XML. Angular is the more common of the two in enterprise work, since @nativescript/angular gives you the same modules, dependency injection, RxJS and router mental model as the web team, and many organisations chose NativeScript precisely because their web stack was already Angular. Vue 3 with nativescript-vue is the lighter option and appears more often in startups and smaller products. Learn the flavour the target company uses, then learn the layer underneath it: the runtime, marshalling, layouts and the build pipeline are identical across flavours, and that shared layer is where most of the interview questions actually live.
Introduction
NativeScript compiles nothing away: your TypeScript runs inside a JavaScript VM embedded in the app process, and every Android class and every Objective-C class is reflected into that VM as a real, callable object. There is no serialised bridge and no WebView. Writing android.widget.Toast.makeText(...).show() or UIAlertController.alertControllerWithTitleMessagePreferredStyle(...) inside a .ts file is ordinary code, resolved at runtime through generated metadata. In 2026 the framework sits on the 8.x line, with Angular and Vue 3 as the mainstream flavours alongside Core XML, Svelte, React and Solid bindings. That direct-access model is the entire pitch, and it is also the source of most of the framework's production failure modes.
Interviewers for NativeScript roles in India rarely stop at layout containers. They probe the runtime: which thread your JavaScript actually runs on (the UI thread), how marshalling turns a JS number into a Java long, what @NativeClass() and global.__native(this) do, why markingMode: none changed memory behaviour in NativeScript 7, and how you stop a native delegate from retaining a page forever. Tooling questions follow: nativescript.config.ts, the @nativescript/webpack v5 chain API, App_Resources, ns clean, V8 heap snapshots, Podfiles and include.gradle. Then come the store questions: 16 KB page alignment on Android, target API levels, iOS privacy manifests, and how you ship JavaScript updates now that CodePush is retired.
This set covers 35 questions ordered from fundamentals to runtime internals, split 14 basic, 14 intermediate and 7 advanced. Most technical answers carry working TypeScript, XML or CLI snippets you can paste into a project and run. NativeScript roles in India usually sit in the ₹5-18 LPA band and cluster around enterprise mobile teams at services firms and product companies maintaining long-lived apps that need deep device integration. Because the talent pool is small, these interviews tend to go deeper than a typical React Native screen: expect to be handed a crash log and asked to explain it, not merely to describe a layout container.
Ready to practice NativeScript interviews?
Don't just read, practice these NativeScript questions live with an AI interviewer that asks follow-ups and scores your answers.