Angular Interview Questions and Answers
Last updated:
Check out 60 of the most common Angular interview questions, then take an AI-powered practice interview
Q1What is a standalone component, and why did Angular move away from NgModules?
BasicFundamentals
Answer
A standalone component declares its own template dependencies through an imports array in the @Component decorator instead of relying on an NgModule to provide them. Since Angular 19, standalone is the default, so you no longer even write standalone: true; a component is standalone unless you explicitly opt out with standalone: false. The application bootstraps with bootstrapApplication(AppComponent, appConfig) rather than platformBrowserDynamic().bootstrapModule(AppModule), and cross-cutting concerns like routing and HTTP move into provider functions (provideRouter, provideHttpClient) inside app.config.ts.
NgModules were dropped as the default because they forced an indirection nobody benefited from: to know what a component could use in its template, you had to trace which module declared it, which modules that module imported, and what those exported. That made refactoring risky and tree-shaking imprecise. With standalone components the dependency graph is explicit at the component level, lazy loading becomes a one-liner with loadComponent, and the compiler can prove exactly what is reachable. Interviewers frequently ask how you would migrate an old codebase: the answer is the official schematic, ng generate @angular/core:standalone, which runs in three passes (convert components, remove unnecessary NgModules, switch bootstrap) and can be applied incrementally because standalone and NgModule-based code interoperate freely in both directions.
import { Component } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
@Component({
selector: 'app-price-tag',
imports: [CurrencyPipe], // dependencies are local and explicit
template: `<span>{{ amount | currency: 'INR' }}</span>`,
})
export class PriceTagComponent {
amount = 4999;
}
// main.ts: no NgModule anywhere
bootstrapApplication(PriceTagComponent, {
providers: [provideRouter([]), provideHttpClient()],
});
Key Points
- standalone is the default since v19; standalone: false opts out
- Component-level imports array replaces NgModule declarations
- bootstrapApplication + app.config.ts replace AppModule
- ng generate @angular/core:standalone migrates old code in three passes
Q2How do components, attribute directives, and structural behaviour differ in modern Angular?
BasicFundamentals
Answer
A component (@Component) owns a template and a piece of the DOM tree; a directive (@Directive) attaches behaviour to an existing element without a template of its own. Attribute directives change appearance or behaviour of their host, think of a highlight-on-hover directive or Angular's own NgClass. Structural directives historically added or removed DOM subtrees (*ngIf, *ngFor), but in modern code that job moved into the template language itself with the built-in @if and @for blocks, so new structural directives are rarely written.
What remains very relevant is the attribute directive pattern: you inject ElementRef or better, use the host metadata property to bind classes, styles and listeners declaratively, and you receive configuration through signal inputs. The host object is preferred over @HostBinding and @HostListener in current style guidance because everything is visible in one place in the decorator. Interviewers use this topic to check whether you reach for a directive or a component appropriately: if you need markup, it is a component; if you need reusable behaviour applied to arbitrary elements (autofocus, permission-based hiding, tooltip triggers, analytics click tracking), it is a directive. A follow-up worth preparing for: directives can be composed onto components with the hostDirectives feature, which lets a component acquire directive behaviour without inheritance.
import { Directive, input, signal } from '@angular/core';
@Directive({
selector: '[appHighlight]',
host: {
'[style.background]': `hovered() ? colour() : 'transparent'`,
'(mouseenter)': 'hovered.set(true)',
'(mouseleave)': 'hovered.set(false)',
},
})
export class HighlightDirective {
colour = input('#fff3cd', { alias: 'appHighlight' });
hovered = signal(false);
}
// usage: <tr [appHighlight]="'#ffe08a'">...</tr>
Q3Explain the built-in control flow: how do @if, @for and @switch differ from *ngIf and *ngFor?
BasicTemplates
Answer
The block syntax (@if, @for, @switch, @defer) became stable in Angular 18 and is now the default the CLI generates. Unlike *ngIf and *ngFor, the blocks are part of the template language, so you import nothing: no CommonModule, no NgIf, no NgFor in the imports array. @if supports @else if and @else branches directly, and can alias the tested value with 'as' for use inside the block. @for makes track mandatory, which is the biggest practical difference: with *ngFor, forgetting trackBy silently caused Angular to destroy and recreate every DOM node when the array reference changed, a classic cause of janky lists; @for refuses to compile without a track expression, so the performance foot-gun is gone by construction. @for also ships an @empty block that renders when the collection has no items, removing the awkward *ngIf wrapper pattern, and exposes implicit variables like $index, $first, $last, $even and $count. Under the hood @for uses a new diffing algorithm that benchmarks meaningfully faster than NgForOf on large list updates.
Migration from the old syntax is mechanical: ng generate @angular/core:control-flow rewrites templates automatically. Interviewers commonly ask what track should return: a stable unique identity (an id field), never the object reference itself for data refetched from a server, and $index only for lists that never reorder.
@if (orders().length > 0) {
<ul>
@for (order of orders(); track order.id) {
<li [class.first]="$first">
#{{ order.id }} - {{ order.total | currency: 'INR' }}
</li>
} @empty {
<li>No orders yet</li>
}
</ul>
} @else {
<app-empty-state kind="orders" />
}
@switch (status()) {
@case ('paid') { <app-badge tone="success" /> }
@case ('failed') { <app-badge tone="danger" /> }
@default { <app-badge tone="neutral" /> }
}
Key Points
- Stable since v18; no CommonModule import needed
- track is mandatory in @for, killing the missing-trackBy perf bug
- @empty block replaces the *ngIf-around-*ngFor pattern
- ng generate @angular/core:control-flow migrates old templates
Q4How do signal(), computed() and update() work together in a component?
BasicSignals
Answer
A signal is a wrapper around a value that notifies interested consumers when the value changes. You create writable signals with signal(initialValue), read them by calling them as functions (count()), and change them with set(newValue) or update(fn), where update receives the current value and returns the next one. computed(fn) creates a derived, read-only signal that re-evaluates lazily when any signal it read during its last evaluation changes; results are memoized, so reading a computed twice without dependency changes does no work. The killer feature for Angular specifically is that templates reading signals register themselves as consumers: when the signal changes, Angular knows exactly which views are dirty and schedules change detection for them, which is the mechanism that makes OnPush components and fully zoneless applications practical without manual markForCheck calls.
Two gotchas interviewers probe: first, signals use referential equality by default, so setting the same object reference after mutating it will not notify anyone; you either replace the reference (spread into a new object or array) or supply a custom equal function. Second, computed signals must stay pure: writing to another signal inside a computed throws NG0600 in development. Signals shipped as developer preview in v16 and have been the recommended reactivity primitive for component state since v17; new interview questions assume them by default.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart',
template: `
<p>{{ itemCount() }} items, total {{ total() | currency: 'INR' }}</p>
<button (click)="add(499)">Add item</button>
`,
})
export class CartComponent {
prices = signal<number[]>([]);
itemCount = computed(() => this.prices().length);
total = computed(() => this.prices().reduce((sum, p) => sum + p, 0));
add(price: number) {
// replace the array; mutating with push() would not notify consumers
this.prices.update((list) => [...list, price]);
}
}
Q5How do input(), input.required() and output() replace @Input and @Output?
BasicSignals
Answer
Signal inputs declare component inputs as signals instead of mutable class fields. input('fallback') creates an optional input with a default, input.required<T>() creates one the compiler forces every parent to bind (missing it is a template type-check error, not a runtime surprise), and both return an InputSignal you read by calling it. Because the input is a signal, you derive state with computed() instead of re-computing things in ngOnChanges, and the derived values update automatically when the parent rebinds. Options include alias (rename the public attribute) and transform (coerce the bound value, for example booleanAttribute or numberAttribute from @angular/core, so <app-panel collapsed /> works without writing [collapsed]="true"). output() replaces @Output plus EventEmitter: it returns an OutputEmitterRef with an emit() method, has no RxJS surface to misuse (people used to pipe operators onto EventEmitter, which was never supported), and unsubscribes listeners automatically when the component is destroyed.
The old decorators still work and coexist with the new APIs in the same class during migration, and there is a schematic, ng generate @angular/core:signal-input-migration, to convert them mechanically. In interviews, the crisp comparison is: @Input gives you a value that changes behind your back with a side-channel notification (ngOnChanges); input() gives you a reactive value the rest of your signal graph can depend on directly.
import { Component, input, output, computed, booleanAttribute } from '@angular/core';
@Component({
selector: 'app-job-card',
template: `
<h3>{{ title() }}</h3>
<p>{{ salaryLabel() }}</p>
@if (!applied()) {
<button (click)="apply.emit(jobId())">Apply</button>
}
`,
})
export class JobCardComponent {
jobId = input.required<number>();
title = input.required<string>();
salaryLpa = input(0);
applied = input(false, { transform: booleanAttribute });
apply = output<number>();
salaryLabel = computed(() =>
this.salaryLpa() > 0 ? `Up to ₹${this.salaryLpa()} LPA` : 'Not disclosed',
);
}
Key Points
- input.required<T>() makes missing bindings a compile-time error
- transform option coerces values (booleanAttribute, numberAttribute)
- output() has no EventEmitter/RxJS surface and auto-cleans listeners
- Derive from inputs with computed() instead of ngOnChanges
Q6What is the inject() function, and when must you use it instead of constructor injection?
BasicDependency Injection
Answer
inject(Token) resolves a dependency from the current injector and is the modern alternative to constructor parameter injection. As a field initializer (private http = inject(HttpClient)) it reads better with inheritance (no super() parameter threading), works cleanly with class field initialization order, and lets you extract reusable injection helpers: a plain function like injectCurrentUser() can call inject() internally and be shared across components. The critical rule is that inject() only works inside an injection context: field initializers and constructors of classes the DI system instantiates, provider factory functions, and functional router guards, resolvers and HTTP interceptors.
Call it later, inside ngOnInit, a click handler, or a setTimeout callback, and you get the runtime error NG0203: inject() must be called from an injection context. When you genuinely need to resolve dependencies later, capture an Injector or EnvironmentInjector during construction and use runInInjectionContext(injector, () => inject(Thing)). Functional guards and interceptors made inject() unavoidable: a CanActivateFn is a plain function with no constructor, so inject(Router) inside its body is the only way to get dependencies. Interviewers often ask whether constructor injection is deprecated: it is not, both styles are fully supported, but new Angular documentation and the CLI schematics generate inject(), and functional APIs (guards, interceptors, resolvers) require it.
import { Component, inject, Injector, runInInjectionContext } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute } from '@angular/router';
@Component({ selector: 'app-profile', template: '...' })
export class ProfileComponent {
private http = inject(HttpClient);
private route = inject(ActivatedRoute);
private injector = inject(Injector);
loadLater() {
// outside an injection context, so wrap it:
runInInjectionContext(this.injector, () => {
const client = inject(HttpClient); // fine here
});
}
}
Q7Walk through the component lifecycle: constructor vs ngOnInit vs ngOnChanges vs ngOnDestroy, and where afterNextRender fits.
BasicLifecycle
Answer
The constructor runs when the DI system instantiates the class; only injection and trivial field setup belong there, because inputs are not populated yet and no DOM exists. ngOnChanges fires before ngOnInit and again whenever a decorator-style @Input changes, receiving a SimpleChanges map with previousValue, currentValue and firstChange; with signal inputs you rarely need it, since computed() and effect() react to input changes directly. ngOnInit runs once after the first ngOnChanges, when inputs are available: this is the traditional place to trigger initial data loads. ngAfterViewInit runs after the component's view and its @ViewChild queries are ready. ngOnDestroy runs on teardown and is where subscriptions, intervals and DOM listeners get cleaned up; the modern alternative is injecting DestroyRef and registering callbacks with destroyRef.onDestroy(), which works in plain services and functions too, not just classes implementing an interface. afterNextRender and afterEveryRender (from @angular/core) are newer hooks that run after the browser actually paints, and, critically, they never run on the server: they are the sanctioned place for DOM measurement, charting library initialisation, or anything touching window, which makes them essential in SSR applications where ngAfterViewInit still executes on the server and would crash on browser globals. A frequent probe: why not fetch data in the constructor? Because inputs are undefined, server-side rendering may instantiate the class in ways you do not expect, and testability suffers since instantiation alone triggers side effects.
Key Points
- constructor: DI only; inputs not yet set
- ngOnChanges receives SimpleChanges; largely replaced by signal inputs + computed
- DestroyRef.onDestroy() is the composable modern cleanup hook
- afterNextRender never runs on the server: safe home for DOM and window access
Q8How do you configure routing with provideRouter, and how does withComponentInputBinding change how you read route params?
BasicRouting
Answer
Routing is configured in app.config.ts by passing a Routes array to provideRouter(routes), with optional feature functions layered on: withComponentInputBinding(), withPreloading(...), withViewTransitions(), withInMemoryScrolling(). Each route maps a path to a component, and <router-outlet /> in a template marks where the matched component renders. Navigation happens declaratively with the routerLink directive (never a raw href, which reloads the page and destroys application state) or imperatively with inject(Router).navigate(['/jobs', id]).
Active-link styling uses routerLinkActive. The traditional way to read parameters was injecting ActivatedRoute and subscribing to paramMap or queryParamMap. With withComponentInputBinding() enabled, Angular binds path parameters, query parameters, resolved data and route data directly to component inputs of the same name: a route path 'jobs/:jobId' populates a jobId = input<string>() on the routed component, no ActivatedRoute involved.
That removes boilerplate and makes routed components trivially testable, since you set inputs instead of mocking ActivatedRoute snapshots. Two details interviewers check: parameters always arrive as strings, so convert with a transform (numberAttribute) if you need numbers; and when navigating from /jobs/1 to /jobs/2 Angular reuses the component instance rather than recreating it, so any logic keyed on the parameter must be reactive (an effect or computed on the input), not a one-shot read in ngOnInit.
// app.config.ts
import { provideRouter, withComponentInputBinding } from '@angular/router';
export const appConfig = {
providers: [
provideRouter(
[
{ path: 'jobs', component: JobListComponent },
{ path: 'jobs/:jobId', component: JobDetailComponent },
{ path: '', redirectTo: 'jobs', pathMatch: 'full' },
],
withComponentInputBinding(),
),
],
};
// job-detail.component.ts
import { Component, input, numberAttribute } from '@angular/core';
@Component({ selector: 'app-job-detail', template: 'Job #{{ jobId() }}' })
export class JobDetailComponent {
// 'jobs/:jobId' binds here automatically; no ActivatedRoute needed
jobId = input.required<number, string>({ transform: numberAttribute });
}
Q9How does lazy loading work with loadComponent and loadChildren, and what actually ends up in separate bundles?
BasicRouting
Answer
Lazy loading defers downloading a route's code until the user navigates to it. With standalone components there are two levers: loadComponent, which lazily loads a single routed component via a dynamic import (loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent)), and loadChildren, which lazily loads an entire child Routes array (loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES)), giving a whole feature area its own bundle with nested paths, guards and providers. The bundler (esbuild in the modern CLI) sees each dynamic import as a split point and emits a separate chunk; running ng build prints the chunk list so you can verify the split actually happened.
The classic mistake that silently defeats lazy loading is importing something from the lazy feature statically elsewhere, for example importing a shared constant from the admin folder into a header component; the bundler then hoists that code into the initial bundle. Keeping genuinely shared code in a shared folder and checking the build output (or source-map-explorer) catches this. Route-level providers add another dimension: a providers array on a lazy route creates an EnvironmentInjector scoped to that subtree, so feature-specific services (say, an AdminAuditService) never load or instantiate for users who never visit admin. Combine lazy routes with a preloading strategy so chunks download in idle time rather than on click, which removes the perceived latency without bloating first load.
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'report/:id',
loadComponent: () =>
import('./report/report.component').then((m) => m.ReportComponent),
},
{
path: 'admin',
canMatch: [adminGuard], // guard runs before the chunk downloads
loadChildren: () =>
import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
];
// admin/admin.routes.ts
export const ADMIN_ROUTES: Routes = [
{
path: '',
providers: [AdminAuditService], // scoped to this lazy subtree
children: [
{ path: 'users', component: AdminUsersComponent },
{ path: 'billing', component: AdminBillingComponent },
],
},
];
Q10What does the async pipe do, and why is it still the safest way to consume an Observable in a template?
BasicRxJS
Answer
The async pipe (from @angular/common) subscribes to an Observable or Promise in the template, emits its latest value into the expression, and, crucially, unsubscribes automatically when the view is destroyed. That single behaviour eliminates the most common Angular memory leak: a component subscribing manually in ngOnInit and forgetting the teardown in ngOnDestroy, leaving HTTP polling or store subscriptions alive after navigation. The async pipe also cooperates with change detection: in an OnPush component, each emission marks the view for check, so you get correct updates without manual ChangeDetectorRef.markForCheck() calls, and in zoneless applications this notification is one of the sanctioned ways a view signals it needs re-rendering.
Practical patterns matter in interviews: bind once and reuse the value with @if (user$ | async; as user) { ... } instead of repeating user$ | async five times, because each pipe instance is its own subscription, which means five HTTP calls if the source is cold and unshared (fix the source side with shareReplay({ bufferSize: 1, refCount: true }) when sharing is intended). Know its limits too: the pipe renders null before the first emission, so design templates to tolerate the initial null or provide a startWith value. In 2026 the direction of travel is converting streams to signals with toSignal() and reading them directly, but the async pipe remains everywhere in existing codebases and remains completely correct.
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { shareReplay } from 'rxjs';
@Component({
selector: 'app-dashboard',
imports: [AsyncPipe],
template: `
@if (stats$ | async; as stats) {
<p>{{ stats.activeJobs }} active jobs</p>
<p>{{ stats.applicants }} applicants</p>
} @else {
<app-skeleton />
}
`,
})
export class DashboardComponent {
private http = inject(HttpClient);
// shareReplay prevents one subscription per async pipe usage
stats$ = this.http
.get<{ activeJobs: number; applicants: number }>('/api/stats')
.pipe(shareReplay({ bufferSize: 1, refCount: true }));
}
Q11How do pipes work, what does pure vs impure mean, and when do you write a custom pipe?
BasicTemplates
Answer
A pipe transforms a value in a template expression: {{ createdAt | date: 'dd MMM yyyy' }}. Angular ships DatePipe, CurrencyPipe, DecimalPipe, PercentPipe, UpperCasePipe, TitleCasePipe, SlicePipe, JsonPipe, KeyValuePipe and AsyncPipe, and in standalone components each one you use must appear in the imports array. A pure pipe (the default) is only re-executed when its input reference or primitive value changes, which makes it effectively a memoized function call in the template and very cheap.
An impure pipe (pure: false) runs on every change detection cycle, which on a busy page can mean hundreds of executions per second; the classic interview trap is a filter pipe over an array that never updates because the array is mutated in place (pure pipe, same reference, no re-run), and the naive fix of marking it impure that then tanks performance. The correct answer is to keep the pipe pure and either replace the array reference on change or, better in 2026, precompute the filtered list as a computed() signal in the class and drop the pipe entirely. Custom pipes are still the right tool for stateless, reusable formatting: LPA salary formatting, relative time ("3 days ago"), truncation with ellipsis.
Implement PipeTransform, keep transform() free of side effects, and remember pipes are injectable classes, so you can inject LOCALE_ID or a service for locale-aware output. CurrencyPipe with 'INR' correctly renders the lakh/crore grouping under the en-IN locale, a nice detail to mention for Indian product work.
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'lpa' }) // pure by default
export class LpaPipe implements PipeTransform {
transform(annualInr: number | null | undefined): string {
if (annualInr == null) return 'Not disclosed';
const lakhs = annualInr / 100000;
return `₹${lakhs % 1 === 0 ? lakhs : lakhs.toFixed(1)} LPA`;
}
}
// usage in a standalone component:
// imports: [LpaPipe]
// template: {{ job.ctc | lpa }} -> ₹12.5 LPA
Key Points
- Pure pipes memoize on reference change; impure pipes run every CD cycle
- Mutating an array in place hides changes from pure pipes
- Prefer computed() signals over impure filter pipes
- Pipes are injectable: LOCALE_ID and services are available
Q12How does two-way binding work with model(), and how does it compare to [(ngModel)]?
BasicSignals
Answer
model() creates a model input: a writable signal that is simultaneously an input and an output. Declaring value = model(0) in a child gives the parent two options: bind one-way [value]="x" or two-way [(value)]="count", and when the child calls value.set() or value.update(), the change propagates up to the parent's bound property automatically. Under the hood Angular pairs the input with an implicit output named valueChange, which is exactly the naming convention [(ngModel)] always relied on, but model() removes all the ceremony: no separate @Input plus @Output plus manual emit calls, and the child can treat the model as ordinary local state.
This is the modern answer for custom two-way bindings like a rating widget, a quantity stepper, or a collapsible panel's open state. [(ngModel)], from FormsModule, remains the two-way binding for native form controls in template-driven forms; it works through the ControlValueAccessor machinery rather than a signal pair. In interviews, be ready to desugar both: [(value)]="count" expands to [value]="count" (valueChange)="count = $event", and knowing that expansion explains why you can also bind the event side manually when you need to intercept updates. Also worth stating: model.required<T>() exists, mirroring input.required, and reading a model in the child template is just value(), like any signal.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-stepper',
template: `
<button (click)="qty.update((q) => Math.max(1, q - 1))">-</button>
<span>{{ qty() }}</span>
<button (click)="qty.update((q) => q + 1)">+</button>
`,
})
export class StepperComponent {
qty = model(1); // input + output pair in one declaration
}
// parent template:
// <app-stepper [(qty)]="seats" />
// <p>Booking {{ seats() }} seats</p>
Q13How does content projection with ng-content work, including multiple named slots?
BasicTemplates
Answer
Content projection lets a component accept markup from its parent and render it at designated slots, which is how you build reusable shells: cards, modals, page layouts, accordions. A bare <ng-content /> projects everything the parent placed between the component's tags. Multiple slots use the select attribute with a CSS selector: <ng-content select="[card-title]" /> captures elements carrying the card-title attribute, select="footer" captures a <footer> element, and a selector-less <ng-content> acts as the catch-all for whatever matched nothing else.
Since Angular 18 you can declare fallback content inside the ng-content tags, shown only when the parent projected nothing into that slot, which previously required awkward @ContentChild checks. Key behaviours interviewers probe: projection is not transclusion-by-copy, the projected nodes remain part of the parent's template, so bindings inside them evaluate against the parent component's context, not the shell's; a slot that appears inside an @if of the shell does not destroy and recreate projected content's state unpredictably, but content only instantiates where a matching ng-content actually renders; and ngProjectAs lets a wrapper element masquerade as a different selector when the real element cannot carry the expected attribute. To introspect what was projected, use contentChild()/contentChildren() signal queries, the content-side siblings of viewChild. Distinguish clearly in your answer: view children live in the component's own template, content children arrive from outside via projection.
import { Component } from '@angular/core';
@Component({
selector: 'app-card',
template: `
<div class="card">
<header><ng-content select="[card-title]">Untitled</ng-content></header>
<section><ng-content /></section>
<footer><ng-content select="[card-actions]" /></footer>
</div>
`,
})
export class CardComponent {}
// parent usage:
// <app-card>
// <h3 card-title>Frontend Engineer, Gurugram</h3>
// <p>3-5 years, Angular + RxJS</p>
// <div card-actions><button>Apply</button></div>
// </app-card>
Q14What is the difference between ng-template and ng-container, and when do you reach for each?
BasicTemplates
Answer
ng-container is a grouping element that renders no DOM node of its own: its children render directly into the parent. You use it when you need somewhere to hang a directive or a control-flow block without introducing a wrapper <div> that would break CSS (flex/grid children, table structure, list semantics). With the modern block syntax, many old ng-container uses disappeared, since @if and @for are blocks rather than attribute-hosted directives, but it remains necessary inside tables (<tr> must be a direct child of <tbody>) and anywhere a wrapper element is illegal. ng-template is fundamentally different: its content is not rendered at all by default.
It defines a template fragment, a TemplateRef, that something else instantiates later: NgTemplateOutlet stamps it out with an optional context object, structural directives receive it implicitly, and libraries accept TemplateRefs as customisation points (column templates in data grids are the canonical example). The context mechanism matters: *ngTemplateOutlet="rowTpl; context: { $implicit: row, index: i }" makes row available via let-row and index via let-i="index" inside the template. Interviewers often ask how you would let a parent customise part of a child's rendering: the clean answer is the child accepts a TemplateRef input (or queries it with contentChild(TemplateRef)) and renders it with NgTemplateOutlet, passing per-item context. That pattern, templates as component API, is what separates candidates who have built reusable component libraries from those who have only consumed them.
import { Component, contentChild, input, TemplateRef } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
@Component({
selector: 'app-list',
imports: [NgTemplateOutlet],
template: `
@for (item of items(); track item.id) {
<ng-container
[ngTemplateOutlet]="rowTpl() ?? null"
[ngTemplateOutletContext]="{ $implicit: item }"
/>
}
`,
})
export class ListComponent {
items = input.required<{ id: number; name: string }[]>();
rowTpl = contentChild(TemplateRef);
}
// parent:
// <app-list [items]="jobs">
// <ng-template let-job><strong>{{ job.name }}</strong></ng-template>
// </app-list>
Q15How do you set up HttpClient in a standalone app, and what does withFetch() change?
BasicHTTP
Answer
You register HttpClient in app.config.ts with provideHttpClient(), then inject it wherever needed with inject(HttpClient). Requests are typed via the generic parameter: this.http.get<Job[]>('/api/jobs') returns Observable<Job[]>, and the observable is cold and single-shot, nothing happens until subscription, and each subscription fires a fresh request, which is why accidentally subscribing twice (once in code, once via an async pipe) duplicates network calls. Query parameters and headers go through HttpParams and HttpHeaders, both immutable: params.set() returns a new instance, so chaining without reassigning is a classic silent bug. provideHttpClient accepts feature functions: withInterceptors([...]) for functional interceptors, withFetch() to switch the backend from XMLHttpRequest to the fetch API, and withInterceptorsFromDi() for legacy class-based interceptors. withFetch() matters more than it sounds: fetch is required for proper streaming responses, behaves better on modern edge runtimes, and is the recommended backend for SSR since Node has native fetch; its one trade-off is no upload progress events, which XHR provided, so apps with progress bars on large uploads keep XHR. Error handling arrives as HttpErrorResponse in the error channel; a non-2xx status is an error notification, not an emission, and error.status of 0 means the request never reached a server (network down, CORS blocked, DNS failure), a distinction worth naming in interviews because production dashboards treat status 0 very differently from a 500.
// app.config.ts
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
export const appConfig = {
providers: [provideHttpClient(withFetch(), withInterceptors([authInterceptor]))],
};
// jobs.service.ts
import { inject, Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
export interface Job { id: number; title: string; lpa: number }
@Injectable({ providedIn: 'root' })
export class JobsService {
private http = inject(HttpClient);
search(city: string, minLpa: number) {
const params = new HttpParams().set('city', city).set('minLpa', minLpa);
return this.http.get<Job[]>('/api/jobs', { params });
}
}
Q16Template-driven vs reactive forms: what actually differs, and which do you choose when?
BasicForms
Answer
Template-driven forms (FormsModule) declare the form in the template: ngModel on each control, ngForm wrapping them, validation via HTML attributes like required and minlength, and Angular builds the form model implicitly and asynchronously behind the scenes. Reactive forms (ReactiveFormsModule) build the model explicitly in the class: FormControl, FormGroup, FormArray, bound to the template with formControlName and [formGroup]. The differences that matter in production: reactive forms are synchronous and fully typed since Angular 14 (FormControl<string> knows its value type; the untyped escape hatch is UntypedFormControl), which makes them testable without touching the DOM, composable (a reusable AddressFormGroup factory), and dynamic (push controls into a FormArray at runtime for repeatable rows like multiple work experiences).
Validation logic lives in code where it can be unit tested, and valueChanges/statusChanges expose the form as an Observable stream you can debounce, combine, and pipe into autosave. Template-driven forms are genuinely fine for small, static forms, a login box, a newsletter signup, and involve less ceremony. The honest interview answer: teams standardise on reactive forms for anything non-trivial because complex cross-field validation, conditional controls, and testing all get harder in the template-driven model. Mention the direction of travel as well: Angular has been building signal-based forms as the eventual successor, but typed reactive forms are the safe, stable default for interviews and production in 2026.
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, NonNullableFormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-apply',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input formControlName="email" type="email" />
<input formControlName="noticePeriodDays" type="number" />
<button [disabled]="form.invalid">Apply</button>
</form>
`,
})
export class ApplyComponent {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
noticePeriodDays: [30, [Validators.min(0), Validators.max(90)]],
});
submit() {
if (this.form.valid) console.log(this.form.getRawValue());
}
}
Q17How do class and style bindings work, and why are they preferred over ngClass and ngStyle now?
BasicTemplates
Answer
Angular binds classes and styles natively in the template without any directive. [class.active]="isActive()" toggles one class on a boolean; [class]="classExpr" accepts a string, an array, or an object map of class names to booleans; [style.width.px]="width()" binds a single style with automatic unit suffixing (the .px, .em, .% suffixes are part of the binding syntax); [style]="styleObj" binds a map. These bindings are handled directly by the template compiler and runtime, compose predictably with static class attributes (static classes stay, bound ones toggle), and cooperate when multiple directives touch the same element. NgClass and NgStyle are directives from @angular/common that predate the richer native syntax; official style guidance now recommends native bindings, and the practical reasons are concrete: native bindings need no import in a standalone component, they are more performant since there is no directive instance and no object diffing on every change detection pass, and precedence is well defined when combined with host bindings.
Where people still reach for ngClass, an object with many conditional classes, the cleaner modern pattern is computing the class string or map once as a computed() signal and binding [class] to it, which also moves the logic out of the template where it can be tested. Knowing the precedence order is a nice interviewer flex: the most specific binding wins, and bindings later in a template do not simply overwrite earlier ones the way plain DOM attributes would.
import { Component, computed, signal } from '@angular/core';
@Component({
selector: 'app-status-chip',
template: `
<span
class="chip"
[class]="chipClasses()"
[style.opacity]="disabled() ? 0.5 : 1"
[style.max-width.px]="180"
>
{{ label() }}
</span>
`,
})
export class StatusChipComponent {
label = signal('Shortlisted');
tone = signal<'success' | 'danger' | 'neutral'>('success');
disabled = signal(false);
chipClasses = computed(() => ({
'chip-success': this.tone() === 'success',
'chip-danger': this.tone() === 'danger',
}));
}
Q18Which Angular CLI commands do you actually use day to day, and what changed in the build system?
BasicTooling
Answer
The commands that matter: ng new app-name scaffolds a project (flags like --style=scss and --ssr decide styling and server-side rendering up front); ng generate component jobs/job-card (ng g c for short) scaffolds a component with its test file, and the same pattern covers services, directives, pipes, and guards; ng serve runs the dev server with hot module replacement; ng build produces a production build by default (optimisation, hashing, budgets enforced); ng test runs unit tests; ng update @angular/core @angular/cli performs version upgrades including automated code migrations; and ng add installs and configures libraries that ship schematics, like ng add @angular/material. The build system is the part many candidates have stale knowledge of: the webpack-based browser builder was replaced by the esbuild-powered application builder (@angular/build:application... previously @angular-devkit/build-angular:application), with Vite serving the dev server. Production builds that took minutes on webpack routinely run several times faster, and the application builder also handles SSR and prerendering natively via the outputMode and server options in angular.json, replacing a pile of separate SSR tooling.
Useful flags worth naming: ng build --stats-json emits data for bundle analysis, ng serve --open launches the browser, and ng g c --dry-run previews file changes without writing. Mentioning that new projects default to the application builder and that older projects migrate with ng update signals you have actually maintained an Angular app recently rather than only started fresh ones.
# scaffold with SSR and SCSS
ng new goodspace-web --style=scss --ssr
# generate pieces (tests included)
ng g c jobs/job-card
ng g service core/auth
ng g guard core/admin --implements CanMatch
# dev loop
ng serve --open
# production build + bundle analysis input
ng build --stats-json
# upgrade Angular with automated migrations
ng update @angular/core @angular/cli
Q19How do viewChild() signal queries and template reference variables work together?
BasicTemplates
Answer
A template reference variable (#box on an element) names a node inside the template so other template expressions can use it: #inputEl on an <input> exposes the HTMLInputElement, #child on a component exposes the component instance, and you can pass either into event handlers ((click)="focus(inputEl)"). To reach the same nodes from the component class, you query them: the modern API is viewChild() and viewChildren() from @angular/core, which return signals. box = viewChild<ElementRef>('box') resolves the element by its reference variable name; viewChild(ChildComponent) resolves by type; viewChild.required() makes the query non-nullable and throws if nothing matches. Because the result is a signal, timing headaches from the decorator era largely disappear: instead of remembering that @ViewChild populates only in ngAfterViewInit (and needing { static: true } to get it earlier), you read the signal wherever you need it and derive from it with computed() or react in an effect(), which simply re-runs when the query resolves or changes, including when the target sits inside an @if and appears later. viewChildren() returns a signal of an array, replacing QueryList and its changes observable.
The read option still exists for disambiguation: viewChild('box', { read: ElementRef }) when the element also hosts a directive and you want the DOM node rather than the directive instance. Interviewers like asking why direct DOM access via ElementRef.nativeElement should stay rare: it bypasses Angular's rendering abstractions and breaks on the server during SSR, so wrap such access in afterNextRender.
import { AfterViewInit, Component, ElementRef, viewChild } from '@angular/core';
@Component({
selector: 'app-search-box',
template: `
<input #queryInput placeholder="Search jobs" />
<button (click)="clear()">Clear</button>
`,
})
export class SearchBoxComponent implements AfterViewInit {
private queryInput =
viewChild.required<ElementRef<HTMLInputElement>>('queryInput');
ngAfterViewInit() {
this.queryInput().nativeElement.focus();
}
clear() {
this.queryInput().nativeElement.value = '';
}
}
Q20What do the ViewEncapsulation modes actually do, and why is ::ng-deep a code smell?
BasicStyling
Answer
Component styles are scoped by ViewEncapsulation, set per component in the decorator. The default, ViewEncapsulation.Emulated, rewrites your selectors at build time by attaching generated attributes like _ngcontent-xyz to the component's elements and suffixing every rule, so .title becomes .title[_ngcontent-xyz]; styles then cannot leak out, and outside styles (except global ones) cannot leak in, all without real Shadow DOM. ViewEncapsulation.None disables scoping entirely, the styles become global, which is occasionally deliberate for theme files but usually an accident that causes cross-component bleed.
ViewEncapsulation.ShadowDom uses the browser's native shadow root: true isolation, but global stylesheets stop applying inside, which breaks most design systems, so it stays rare outside web-components use cases. Within emulated encapsulation, :host targets the component's own element, :host(.compact) targets it conditionally, and :host-context(.dark-theme) matches an ancestor condition, useful for theming. ::ng-deep pierces the encapsulation boundary to style children, and it is a smell because it has been deprecated for years, its output is effectively a global rule once it escapes the host scope, and it couples your component to the private DOM structure of a child (often a third-party library) that can change on any minor update. The maintainable alternatives: expose CSS custom properties from the child and set them from the parent (custom properties cross encapsulation boundaries by design), use the library's official theming API (Angular Material exposes design tokens for exactly this), or pass a class as an input the child applies itself.
Key Points
- Emulated rewrites selectors with _ngcontent attributes at build time
- None makes styles global; ShadowDom blocks global styles from entering
- :host, :host(), :host-context() for host and theme-aware styling
- Prefer CSS custom properties over ::ng-deep for styling children
Q21How do host bindings and host listeners work in the host metadata object?
BasicComponents
Answer
Every component and directive has a host element, and the host metadata property in the decorator binds properties, attributes, classes, styles and events on it declaratively. '[class.open]': 'isOpen()' toggles a class from a signal, '[attr.aria-expanded]': 'isOpen()' keeps accessibility state in sync, '[style.height.px]': 'height()' binds a style, and '(keydown.escape)': 'close()' listens for events with Angular's key-event syntax (keydown.enter, keydown.control.s and similar modifier chains all work). This replaces the older @HostBinding and @HostListener decorators; both still function, but current style guidance prefers the host object because the entire host contract is visible in one block instead of scattered across class members, and it plays better with signals since the expressions are ordinary template expressions re-evaluated by change detection. Special listener targets exist too: '(document:click)': 'onDocClick($event)' and '(window:resize)': 'onResize()' attach listeners outside the host element with automatic cleanup on destroy, which is the memory-safe way to do click-outside-to-close dropdowns.
Interviewers use this topic to test component-design instincts: a well-built collapsible or menu component manages its own classes, ARIA attributes and keyboard handling on the host, so consumers get correct behaviour by just using the tag. A follow-up worth anticipating: in zoneless applications, host listeners still trigger change detection correctly because event handlers notify the scheduler directly, no zone.js involved.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-dropdown',
host: {
'[class.open]': 'isOpen()',
'[attr.aria-expanded]': 'isOpen()',
'(keydown.escape)': 'isOpen.set(false)',
'(document:click)': 'onDocumentClick($event)',
},
template: `
<button (click)="toggle($event)">Filters</button>
@if (isOpen()) { <ul class="menu"><ng-content /></ul> }
`,
})
export class DropdownComponent {
isOpen = signal(false);
toggle(e: Event) {
e.stopPropagation();
this.isOpen.update((v) => !v);
}
onDocumentClick(_: Event) {
this.isOpen.set(false); // click anywhere outside closes
}
}
Q22Where does Angular actually use RxJS, and what must you know about Observables even in a signals-first codebase?
BasicRxJS
Answer
Even as signals take over component state, RxJS remains load-bearing in Angular's async APIs: HttpClient returns Observables; the Router exposes events, paramMap and queryParamMap as streams; reactive forms expose valueChanges and statusChanges; and libraries from NgRx to Angular Material CDK speak Observables. The concepts you must hold precisely: an Observable is a lazy push stream; nothing executes until subscribe, and each subscription to a cold Observable re-runs the producer (two subscriptions to http.get means two HTTP requests). Hot sources like Subjects share one producer among subscribers.
Subjects come in flavours interviewers love to contrast: Subject (no replay), BehaviorSubject (requires an initial value, replays the latest to new subscribers, historically the backbone of hand-rolled state services), ReplaySubject (replays N values), AsyncSubject (emits only the final value on completion). Completion semantics matter in practice: HttpClient Observables complete after one emission, so forgetting to unsubscribe from a plain GET does not leak, but interval, fromEvent, valueChanges and store selections never complete on their own, and those are the leak sources. Errors terminate a stream, which is why catchError placement inside a switchMap (on the inner stream) rather than on the outer pipeline is the difference between one failed search killing your typeahead forever and it recovering on the next keystroke. The honest 2026 framing for interviews: signals for state, RxJS for events and async orchestration, with toSignal/toObservable bridging the two.
import { inject, Injectable } from '@angular/core';
import { BehaviorSubject, map } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SessionService {
private user$ = new BehaviorSubject<{ name: string } | null>(null);
// late subscribers immediately get the current value
readonly isLoggedIn$ = this.user$.pipe(map((u) => u !== null));
login(name: string) {
this.user$.next({ name });
}
logout() {
this.user$.next(null);
}
}
Q23How do environment-specific configuration and build budgets work in angular.json?
BasicTooling
Answer
angular.json defines build configurations, typically production and development, under projects.<name>.architect.build.configurations. The production configuration enables optimisation, output hashing and license extraction; development keeps source maps and disables optimisation for fast rebuilds. Environment switching uses fileReplacements: the production configuration swaps src/environments/environment.ts for environment.prod.ts at build time, so code importing the environment object compiles against the right values, API base URLs, feature flags, analytics keys.
Two production cautions belong in your answer: never put secrets in environment files, everything in a frontend bundle is public; and remember the replacement happens at build time, so a single build cannot serve staging and production, which is why teams doing build-once-deploy-many instead load config at runtime from a JSON endpoint before bootstrap, often via provideAppInitializer. Budgets are the second half of this question: the budgets array in the production configuration fails or warns the build when bundles exceed thresholds, for example type 'initial' with maximumWarning '500kB' and maximumError '1MB', plus 'anyComponentStyle' budgets that catch a single component stylesheet ballooning. Budgets are the cheapest performance regression gate that exists, the build simply fails in CI when someone imports a charting library into the initial bundle, and mentioning that you tune budgets rather than delete them when they fire is exactly the production instinct interviewers listen for.
// angular.json (excerpt)
{
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"budgets": [
{ "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
{ "type": "anyComponentStyle", "maximumWarning": "4kB" }
],
"outputHashing": "all"
}
}
}
Q24What role does zone.js play in change detection, and why is Angular moving away from it?
BasicChange Detection
Answer
zone.js monkey-patches the browser's async primitives, setTimeout, Promise callbacks, addEventListener, XMLHttpRequest, fetch, so that Angular is notified whenever any asynchronous work completes anywhere in the application. NgZone wraps the app in such a zone, and when the microtask queue drains, Angular runs change detection from the root, walking the component tree and comparing every template binding against its previous value. This design made the original framework magical: mutate any field anywhere, and the UI updates, no notifications required.
The costs became clearer at scale. First, it is indiscriminate: a mousemove listener or a chatty WebSocket triggers full-tree checks even when nothing visible changed, which is why performance work in zone-based apps revolves around OnPush and NgZone.runOutsideAngular. Second, patching global APIs creates friction with modern JavaScript: native async/await had to be down-compiled for zone.js to see it, and third-party libraries doing heavy async work cause storms of pointless checks.
Third, zone.js is a large, load-bearing dependency the Angular team froze feature-wise and formally deprecated as the long-term direction. Signals provide the replacement mechanism: when templates read signals, Angular knows precisely which views depend on which state, so it no longer needs a global spy on async operations. Recent Angular versions ship zoneless change detection via provideZonelessChangeDetection(), and new projects can omit zone.js from the polyfills entirely. For interviews, hold both models: zones ask 'something happened, check everything'; signals ask 'this exact value changed, update these exact views'.
Key Points
- zone.js patches async APIs so Angular knows when to run CD
- Zone-based CD checks the whole tree from the root
- Costs: indiscriminate checks, async/await down-compilation, deprecated path
- provideZonelessChangeDetection() + signals replace it in modern apps
Q25What exactly does ChangeDetectionStrategy.OnPush change, and what marks an OnPush component dirty?
IntermediateChange Detection
Answer
Default-strategy components are checked on every change detection cycle. Setting changeDetection: ChangeDetectionStrategy.OnPush tells Angular to skip a component and its subtree unless something explicitly marked it dirty. The precise dirty-marking triggers are what interviewers want enumerated: an input binding receives a new reference (mutating the same object does not count, which is why OnPush pushes you toward immutable updates); an event handler bound in this component's template fires; an async pipe in its template receives an emission; a signal read by its template changes; or code calls ChangeDetectorRef.markForCheck() manually, which is what you do inside third-party callbacks that Angular cannot see. markForCheck does not run change detection itself, it marks the path from the component up to the root so the next cycle descends into it; contrast with detectChanges(), which synchronously runs CD for that component right now, and detach(), which removes the subtree from CD entirely until reattached.
The classic OnPush bug: a service mutates an array in place, the component's input still points at the same reference, and the list silently stops updating; the fix is replacing the reference ([...items, newItem]) or moving the state into a signal. In 2026 the practical guidance is OnPush everywhere (many teams enforce it with an ESLint rule) with signals carrying the state, because that combination is exactly the behaviour zoneless change detection formalises, making OnPush-plus-signals the migration path to dropping zone.js.
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, signal } from '@angular/core';
@Component({
selector: 'app-notifications',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span>{{ unread() }} unread</span>
`,
})
export class NotificationsComponent {
private cdr = inject(ChangeDetectorRef);
unread = signal(0);
constructor(ws: WebSocketService) {
ws.raw.onmessage = () => {
// Signal write marks the view dirty on its own.
this.unread.update((n) => n + 1);
// If this updated a plain field instead, we would need:
// this.cdr.markForCheck();
};
}
}
Key Points
- Dirty triggers: new input reference, template event, async pipe emission, signal change, markForCheck()
- markForCheck marks the path up; detectChanges runs CD synchronously
- In-place mutation + OnPush = silently stale UI
- OnPush + signals is the on-ramp to zoneless
Q26How does zoneless change detection work with provideZonelessChangeDetection, and what breaks when you enable it?
IntermediateChange Detection
Answer
Zoneless Angular removes zone.js and replaces 'an async task finished, check everything' with explicit notifications feeding a scheduler. You enable it by adding provideZonelessChangeDetection() to the providers in app.config.ts and removing zone.js from the polyfills array in angular.json. After that, change detection runs only when something tells the scheduler a view may be stale: a signal written that some template reads, an event handler bound in a template firing, an async pipe emission, markForCheck() (which is what legacy code and many libraries call under the hood), or view attachment/detachment.
The scheduler coalesces notifications and renders once per animation frame, so multiple synchronous signal writes produce a single render pass. What breaks is exactly the code that relied on zone magic: updating a plain class field from a setTimeout, a WebSocket callback, or a third-party SDK callback no longer refreshes the UI, because nothing notified Angular. The fixes are mechanical, move that state into signals, or call markForCheck() in the callback, but finding all the spots is the real migration work, so the recommended path is enforcing OnPush plus signals first (an app that behaves correctly under OnPush is nearly zoneless-ready), then flipping the provider.
Also gone is NgZone.onStable for 'wait until rendered' logic; afterNextRender is the replacement. Benefits worth quoting: smaller bundle (zone.js gone), no patched globals so native async/await and third-party async run untouched, cleaner stack traces, and no more ExpressionChangedAfterItHasBeenCheckedError storms from zone-driven double checks in dev mode.
// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig = {
providers: [
provideZonelessChangeDetection(),
// ...router, http, etc.
],
};
// angular.json: remove "zone.js" from the polyfills array.
// Component code that keeps working zoneless:
@Component({
template: `<p>Price: {{ price() }}</p>`,
})
export class TickerComponent {
price = signal(0);
constructor() {
const socket = new WebSocket('wss://ticks.example.com');
socket.onmessage = (e) => this.price.set(JSON.parse(e.data).ltp);
// a plain field assignment here would never render zoneless
}
}
Q27When should you use effect(), and what are its rules around injection context, cleanup and untracked?
IntermediateSignals
Answer
effect(fn) registers a side-effectful function that runs once immediately and again whenever any signal it read during its previous run changes. It exists for synchronising signal state with the outside world: writing to localStorage, driving a chart library, logging analytics, syncing document.title. It must be created in an injection context, typically a constructor or field initializer, because it registers cleanup with DestroyRef automatically; creating one inside ngOnInit throws NG0203 unless you pass { injector }.
Effects are scheduled, not synchronous: they run during change detection at least once per cycle where dependencies changed, so treat timing as 'soon after', never 'immediately upon set'. The dependency set is dynamic, only signals actually read on the last execution are tracked, which cuts both ways: conditional reads mean conditional subscriptions, and reading a signal you only wanted to sample once still subscribes you. untracked(() => sig()) reads a signal without creating the dependency, the tool for 'when A changes, log A along with the current B without re-running when B changes'. The effect callback receives an onCleanup registrar: onCleanup(() => clearTimeout(id)) runs before each re-run and on destroy, which is how you manage debounce timers or abort controllers inside effects.
The interview-critical guidance is what not to use effects for: propagating state into other signals. Writing to signals inside an effect is a design smell (and setting state that templates read can trigger NG0100-style inconsistencies); derive state with computed() or linkedSignal instead, and reserve effect() for genuine exits from the reactive graph.
import { Component, effect, signal, untracked } from '@angular/core';
@Component({ selector: 'app-editor', template: '...' })
export class EditorComponent {
draft = signal('');
userId = signal<number | null>(null);
constructor() {
effect((onCleanup) => {
const text = this.draft(); // tracked: re-runs on every draft change
const uid = untracked(this.userId); // sampled, not tracked
const id = setTimeout(() => {
localStorage.setItem(`draft-${uid}`, text);
}, 500);
onCleanup(() => clearTimeout(id)); // debounce across re-runs
});
}
}
Q28What problems do linkedSignal and the resource APIs solve that signal and computed cannot?
IntermediateSignals
Answer
computed() is strictly derived and read-only; signal() is writable but knows nothing about other state. linkedSignal covers the gap between them: writable state that resets according to a source. The canonical case is a selection that must survive user interaction but reset when the options change: selectedCity = linkedSignal(() => this.cities()[0]) is writable like a signal (the user picks Pune), yet when cities() changes it recomputes from the source (selection resets to the new first city). The full form takes { source, computation } where the computation also receives the previous value, letting you implement 'keep the selection if it still exists in the new list, else fall back'. resource() addresses async data as first-class reactive state: it takes params (a reactive function producing request parameters) and a loader (an async function receiving those params plus an AbortSignal), and exposes value(), status(), error() and isLoading() as signals.
When the params change, the resource re-fetches and, importantly, aborts the in-flight previous request via the AbortSignal, giving you switchMap-style cancellation semantics without RxJS. httpResource() layers this over HttpClient: httpResource<Job[]>(() => `/api/jobs?city=${this.city()}`) declaratively re-fetches whenever the URL's signal dependencies change. These APIs arrived recently (resource landed experimentally in Angular 19 and the family has been stabilising since), so flag their maturity honestly in interviews while showing you understand the intent: replace hand-rolled fetch-on-change effects, which are error-prone around races and cancellation, with a declarative request/response graph.
import { Component, linkedSignal, resource, signal } from '@angular/core';
@Component({ selector: 'app-job-search', template: '...' })
export class JobSearchComponent {
city = signal('Bengaluru');
// writable, but resets when city changes
selectedJobId = linkedSignal<string, number | null>({
source: this.city,
computation: () => null,
});
jobs = resource({
params: () => ({ city: this.city() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(`/api/jobs?city=${params.city}`, {
signal: abortSignal, // previous request aborted on param change
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as { id: number; title: string }[];
},
});
// template: @if (jobs.isLoading()) {...} @else { use jobs.value() }
}
Q29switchMap vs mergeMap vs concatMap vs exhaustMap: which do you use for search, saves, and button spam?
IntermediateRxJS
Answer
All four flatten a source Observable into inner Observables; they differ only in how they treat a new outer emission while an inner one is in flight, and picking wrong causes real production bugs. switchMap unsubscribes the previous inner Observable when a new value arrives: perfect for typeahead search and any 'only the latest matters' flow, because stale HTTP responses are cancelled rather than racing back out of order and overwriting fresh results. mergeMap runs all inners concurrently: right for independent parallel work like uploading several files at once, wrong for searches (no cancellation, responses interleave), and its optional concurrency argument (mergeMap(fn, 3)) caps parallelism. concatMap queues: each inner starts only after the previous completes, preserving order, which is what you want for sequential writes where ordering matters, autosave patches that must apply in order, or a queue of audit events. exhaustMap ignores new outer emissions while an inner is active: the canonical answer for submit-button spam and login flows, where the first click triggers the request and further clicks do nothing until it settles; it is also the standard choice for a 'refresh' action wired to polling. The mnemonic worth saying aloud: switch cancels old, merge runs all, concat queues, exhaust ignores new. A strong follow-up answer notes the failure mode of each misuse: switchMap on a save cancels a write halfway (data loss), exhaustMap on a search makes the box feel dead while typing, mergeMap on ordered writes reorders them under latency.
import { exhaustMap, switchMap, concatMap, debounceTime, distinctUntilChanged } from 'rxjs';
// 1. Typeahead: cancel stale searches
results$ = this.query$.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.http.get<Job[]>(`/api/jobs?q=${q}`)),
);
// 2. Ordered autosave: never reorder writes
saves$ = this.patch$.pipe(
concatMap((patch) => this.http.patch('/api/profile', patch)),
);
// 3. Login button spam: first click wins
login$ = this.loginClick$.pipe(
exhaustMap((creds) => this.http.post('/api/login', creds)),
);
Key Points
- switchMap cancels the previous inner: searches, latest-wins reads
- concatMap queues in order: sequential writes, autosave
- exhaustMap ignores while busy: submit buttons, login, refresh
- mergeMap runs concurrently: parallel independent work, cap with concurrency arg
Q30Compare subscription cleanup strategies: takeUntilDestroyed, DestroyRef, and the async pipe.
IntermediateRxJS
Answer
Long-lived subscriptions (router events, valueChanges, store selections, WebSockets, interval) outlive their component unless something tears them down, and leaked subscriptions are the top Angular memory leak. The modern default is takeUntilDestroyed() from @angular/core/rxjs-interop: piped onto a stream, it completes the stream when the enclosing context is destroyed. Called with no arguments it must run in an injection context (field initializer or constructor), because it injects DestroyRef internally; used later, you pass the ref explicitly: takeUntilDestroyed(this.destroyRef).
This replaced the old ritual of a private destroy$ = new Subject(), takeUntil(this.destroy$) on every pipe, and next()/complete() in ngOnDestroy, which worked but was boilerplate people got subtly wrong (takeUntil placed before shareReplay, or the Subject never completed). DestroyRef itself is worth knowing independently: destroyRef.onDestroy(cb) registers arbitrary teardown from anywhere in the DI tree, including plain functions and services, decoupling cleanup from the OnDestroy interface. The async pipe remains the zero-code option: if the only consumer of a stream is the template, let the pipe subscribe and unsubscribe, and you have nothing to clean up.
Ordering nuance for strong candidates: place takeUntilDestroyed last in the pipe (or at least after any shareReplay/retry), otherwise inner chains can keep running after the notifier fires. And know what does not need cleanup: single-shot HttpClient calls complete on their own; awaiting firstValueFrom(obs) also self-terminates. In signal-heavy code, toSignal() manages its own subscription lifecycle, shrinking this whole problem class.
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ selector: 'app-session-timer', template: '{{ seconds }}s' })
export class SessionTimerComponent {
private destroyRef = inject(DestroyRef);
seconds = 0;
constructor() {
// injection context: no argument needed
interval(1000)
.pipe(takeUntilDestroyed())
.subscribe(() => this.seconds++);
// arbitrary non-RxJS cleanup, no OnDestroy interface required
const id = setInterval(() => console.log('heartbeat'), 30_000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}
Q31How do functional HTTP interceptors work, and how would you implement auth-token injection with 401 handling?
IntermediateHTTP
Answer
A functional interceptor is a plain function of type HttpInterceptorFn: (req, next) => Observable<HttpEvent<unknown>>, registered via provideHttpClient(withInterceptors([a, b, c])). Interceptors form a chain in registration order for requests and unwind in reverse for responses. Because HttpRequest is immutable, you modify it by cloning: req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }), and because it is a plain function you get dependencies with inject(), which works here since interceptors execute in an injection context.
The auth pattern interviewers expect: read the token from your auth store, skip attaching it for external or public URLs, clone with the header, and handle 401 responses in catchError, typically by attempting a token refresh and replaying the original request, with a guard so the refresh endpoint itself is never intercepted into an infinite loop, and concurrent 401s share one refresh (a shared refresh observable, not one per failed request). Other standard interceptor jobs: retry with exponential backoff for idempotent GETs (retry with a delay function), central error-to-toast mapping, request timing metrics, and correlation-ID headers for tracing. Two subtleties that score points: interceptors run per subscription, so a retried Observable passes through the chain again, meaning a fresh token is attached on replay for free; and ordering matters, put logging first in the array if it must see the request exactly as sent, since later interceptors' mutations happen after earlier ones on the request path.
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
if (req.url.startsWith('/api/public') || req.url.includes('/refresh')) {
return next(req);
}
const authed = req.clone({
setHeaders: { Authorization: `Bearer ${auth.accessToken()}` },
});
return next(authed).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status !== 401) return throwError(() => err);
return auth.refreshToken().pipe(
// replay original request with the new token
switchMap(() =>
next(req.clone({
setHeaders: { Authorization: `Bearer ${auth.accessToken()}` },
})),
),
);
}),
);
};
Q32How do functional route guards work, and what is the difference between canActivate and canMatch?
IntermediateRouting
Answer
Modern guards are plain functions. A CanActivateFn receives (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) and returns boolean, UrlTree, or an Observable/Promise of either; dependencies come from inject(). Returning a UrlTree, built with inject(Router).parseUrl('/login') or createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }), is the correct way to redirect: it cancels the current navigation and starts a clean one, whereas calling router.navigate() inside a guard and returning false causes two competing navigations and subtle history bugs.
The guard family: canActivate gates entering a route, canActivateChild gates child activation, canDeactivate asks the current component for permission to leave (unsaved-changes prompts; its function form receives the component instance so you can check component.form.dirty), and canMatch decides whether the route matches at all during route resolution. canMatch is the one candidates underexplain: when it returns false, the router pretends the route definition does not exist and continues matching later routes, enabling two powerful patterns, role-based routing where the same path 'dashboard' maps to AdminDashboard or UserDashboard depending on which canMatch passes, and protecting lazy routes so the chunk never even downloads for unauthorised users (canActivate runs after loadChildren resolves; canMatch runs before). Class-based guards implementing CanActivate are deprecated; if you meet them in legacy code, the migration is mechanical since the interfaces mirror the function signatures. Async guards block navigation until they emit, so keep them fast and cache authorisation state rather than issuing an HTTP call per navigation.
import { CanActivateFn, CanMatchFn, Router } from '@angular/router';
import { inject } from '@angular/core';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn()
? true
: router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
export const adminMatch: CanMatchFn = () => inject(AuthService).hasRole('admin');
// routes: same path, different components by role
export const routes = [
{ path: 'dashboard', canMatch: [adminMatch], component: AdminDashboardComponent },
{ path: 'dashboard', component: UserDashboardComponent },
];
Q33How do typed reactive forms work, and how do you write a custom cross-field validator?
IntermediateForms
Answer
Since Angular 14, FormControl, FormGroup and FormArray are generic: FormControl<string> has value: string, and form.value is a typed Partial of the group's shape (Partial because disabled controls drop out of value; getRawValue() returns the complete typed object including disabled controls, a distinction interviewers ask about directly). The nullability wrinkle: a plain FormControl('x') types as FormControl<string | null> because reset() returns it to null; NonNullableFormBuilder (or { nonNullable: true }) makes reset() return to the initial value instead, giving you FormControl<string> and saving endless null checks. Custom validators are functions: a ValidatorFn takes an AbstractControl and returns ValidationErrors | null.
Attached to a single control they validate its value; attached to a FormGroup they see all children, which is how cross-field rules work, password confirmation, 'expected CTC must exceed current CTC', date ranges. The group-level validator sets an error on the group, so the template checks form.errors?.['ctcRange'], not a child control's errors, and a common polish step is also calling setErrors on the relevant child so the field itself highlights. Async validators (AsyncValidatorFn) return an Observable or Promise, run only after sync validators pass, and put the control in status 'PENDING' meanwhile: the standard example is a username or email uniqueness check debounced against an API. updateOn: 'blur' on the control or form tempers validation churn for expensive validators. Errors surface through control.errors, and the invalid-and-touched combination gates when to show messages.
import { NonNullableFormBuilder, Validators, AbstractControl, ValidationErrors } from '@angular/forms';
function ctcRange(group: AbstractControl): ValidationErrors | null {
const current = group.get('currentCtc')?.value;
const expected = group.get('expectedCtc')?.value;
return current != null && expected != null && expected <= current
? { ctcRange: 'Expected CTC should exceed current CTC' }
: null;
}
export class OfferFormComponent {
private fb = inject(NonNullableFormBuilder);
form = this.fb.group(
{
currentCtc: [0, [Validators.required, Validators.min(0)]],
expectedCtc: [0, [Validators.required, Validators.min(0)]],
},
{ validators: [ctcRange] },
);
// template: @if (form.errors?.['ctcRange'] && form.touched) { <p>...</p> }
}
Q34Build a debounced typeahead with valueChanges: which operators, in which order, and why?
IntermediateRxJS
Answer
The typeahead is the most-asked practical RxJS question because it packs five operator decisions into ten lines. Start from control.valueChanges, an Observable of every keystroke. debounceTime(300) waits for a 300ms pause before letting a value through, collapsing bursts of typing into one candidate query; distinctUntilChanged() then drops the value if it equals the previous one (user typed a character and deleted it), preventing a duplicate request. filter(q => q.length >= 2) skips one-character searches that return uselessly broad results. switchMap issues the HTTP call and, critically, cancels the in-flight request when a newer query arrives, which is the entire defence against the out-of-order response race where results for 'ang' land after results for 'angular' and overwrite them. Error placement is the detail that separates seniors: catchError must wrap the inner observable inside switchMap, returning of([]) so a failed request emits an empty result and the outer stream survives; put catchError on the outer pipe instead and the first network error completes valueChanges' subscription permanently, the typeahead just dies until page reload, a genuinely common production bug.
Finish by consuming with the async pipe or toSignal() so cleanup is automatic. Order matters throughout: debounce before distinct (or you compare raw keystrokes), filter before switchMap (no wasted requests). If asked to extend it: startWith('') to trigger an initial load, a loading flag via tap around the inner call, and shareReplay(1) if multiple template locations consume the results.
import { toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, filter, switchMap, catchError, of } from 'rxjs';
export class TypeaheadComponent {
private http = inject(HttpClient);
query = new FormControl('', { nonNullable: true });
results = toSignal(
this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
filter((q) => q.length >= 2),
switchMap((q) =>
this.http.get<Job[]>(`/api/jobs?q=${encodeURIComponent(q)}`).pipe(
catchError(() => of([] as Job[])), // inner: stream survives errors
),
),
),
{ initialValue: [] as Job[] },
);
}
Key Points
- debounceTime -> distinctUntilChanged -> filter -> switchMap, in that order
- switchMap cancels stale requests, killing the out-of-order race
- catchError goes on the inner observable or the typeahead dies on first error
- toSignal/async pipe make cleanup automatic
Q35How do @defer blocks work, and which triggers and sub-blocks do you get?
IntermediatePerformance
Answer
@defer carves a template region and its dependencies out of the initial JavaScript bundle; the compiler turns everything used only inside the block (components, directives, pipes) into dynamically imported chunks that load when a trigger fires. Triggers cover the realistic cases: on idle (default, via requestIdleCallback), on viewport (IntersectionObserver, ideal for below-the-fold content like charts and comment sections), on interaction (click or keydown on the placeholder or a referenced element), on hover, on timer(2s), and when condition, an arbitrary reactive expression for programmatic control. Multiple triggers combine with OR semantics. prefetch is a separate axis: @defer (on interaction; prefetch on idle) downloads the chunk quietly during idle time but only instantiates on click, so the click feels instant without costing initial bundle size.
Three sub-blocks manage the lifecycle UX: @placeholder renders before the trigger (with optional minimum duration to avoid flicker), @loading renders while fetching (supports after 100ms and minimum 500ms guards against skeleton flash), and @error renders on load failure. Constraints worth naming: deferred dependencies must be standalone; anything referenced by the surrounding template or via viewChild queries outside the block cannot be deferred; and content inside @placeholder/@loading is eagerly bundled, so keep skeletons light. Under SSR, deferred blocks render their placeholder on the server by default, and incremental hydration extends @defer with hydrate triggers. The interview framing: route-level code splitting became table stakes, @defer brings the same discipline inside a single page, and heavy widgets (charts, editors, maps) are the first candidates.
@defer (on viewport; prefetch on idle) {
<app-salary-trends-chart [data]="trends()" />
} @placeholder (minimum 300ms) {
<div class="chart-skeleton"></div>
} @loading (after 100ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Could not load the chart. <button (click)="reload()">Retry</button></p>
}
@defer (on interaction(openBtn)) {
<app-report-builder />
} @placeholder {
<button #openBtn>Build custom report</button>
}
Q36Explain provider types (useClass, useValue, useFactory, useExisting) and InjectionToken with a runtime-config example.
IntermediateDependency Injection
Answer
A provider tells the injector how to create a dependency for a token. The shorthand providers: [PricingService] means { provide: PricingService, useClass: PricingService }. useClass swaps implementations behind the same token, the classic seam for testing and for environment-specific behaviour ({ provide: PaymentGateway, useClass: MockPaymentGateway }). useValue supplies a ready-made object, right for configuration and constants. useFactory runs a function, with dependencies supplied via inject() inside the factory or the deps array, and is how you construct things that need runtime information, a logger configured differently per environment, a client built from a config object. useExisting aliases one token to another so both resolve to the same instance, useful when exposing a narrow interface for a wide service. Interfaces vanish at runtime in TypeScript, so they cannot be DI tokens; InjectionToken<T> fills that role: export const API_CONFIG = new InjectionToken<ApiConfig>('api.config'), provided with useValue or useFactory and consumed with inject(API_CONFIG).
Tokens accept a providedIn: 'root' factory for tree-shakable defaults. On hierarchy: the environment injector chain (root, and per-lazy-route injectors created by route providers arrays) sits above element injectors created by component/directive providers; resolution walks from the requesting element upward, and modifiers tune the walk: @Optional() (or inject(T, { optional: true })) returns null instead of throwing NG0201, @Self() checks only the local injector, @SkipSelf() starts from the parent, @Host() stops at the host component boundary. Component-level providers mean one instance per component instance, the standard trick for per-instance state in repeated widgets.
import { InjectionToken, inject } from '@angular/core';
export interface ApiConfig { baseUrl: string; timeoutMs: number }
export const API_CONFIG = new InjectionToken<ApiConfig>('api.config');
// app.config.ts
export const appConfig = {
providers: [
{ provide: API_CONFIG, useValue: { baseUrl: '/api', timeoutMs: 8000 } },
{
provide: RetryPolicy,
useFactory: () => {
const cfg = inject(API_CONFIG);
return new RetryPolicy(cfg.timeoutMs > 5000 ? 2 : 4);
},
},
{ provide: ReadonlyApi, useExisting: ApiClient }, // same instance, narrow face
],
};
// consumption
export class ApiClient {
private cfg = inject(API_CONFIG);
private http = inject(HttpClient);
get<T>(path: string) {
return this.http.get<T>(`${this.cfg.baseUrl}${path}`);
}
}
Q37What is the directive composition API (hostDirectives), and what problem does it solve?
IntermediateComponents
Answer
hostDirectives lets a component or directive apply other directives to its own host element declaratively, composing behaviour without inheritance and without requiring consumers to remember to add directives themselves. In the decorator you list hostDirectives: [CdkMenuTrigger] or the object form { directive: TooltipDirective, inputs: ['tooltipText: hint'], outputs: [...] }, which additionally re-exposes chosen inputs/outputs of the composed directive on the component's public API, optionally renamed with the 'internalName: publicName' syntax; anything not listed stays internal. The composed directives are instantiated with the host, participate in DI on the same element (the host can inject them to configure them programmatically), and their host bindings and listeners apply to the host element as if written there.
The problems this solves are real: before hostDirectives, sharing behaviour across component families meant base-class inheritance (fragile, single-slot, couples lifecycles) or documentation-enforced conventions ('always add cdkTrapFocus to your dialogs'). With composition, a DialogComponent bakes in focus trapping, an InteractiveCardComponent bakes in ripple and keyboard-activation directives, and every design-system button gets disabled-state and loading-state behaviour from one shared directive, guaranteed, invisible to consumers. Constraints interviewers may probe: host directives must be standalone; they cannot be dynamically added or removed at runtime (the composition is static, resolved at compile time); execution order is well defined (host directives initialise before the host itself, in listing order); and because each composed directive is a full instance, avoid piling heavy directives onto frequently repeated elements. It is the closest thing Angular has to mixins done safely.
import { Component, Directive, input } from '@angular/core';
@Directive({
selector: '[appTrackClick]',
host: { '(click)': 'track()' },
})
export class TrackClickDirective {
eventName = input('unnamed', { alias: 'appTrackClick' });
track() {
analytics.push({ event: this.eventName() });
}
}
@Component({
selector: 'app-cta-button',
hostDirectives: [
{
directive: TrackClickDirective,
inputs: ['appTrackClick: analyticsId'], // renamed on the public API
},
],
template: `<button class="cta"><ng-content /></button>`,
})
export class CtaButtonComponent {}
// usage: <app-cta-button analyticsId="apply_now" />
Q38How do route resolvers work with ResolveFn, and when are they the wrong tool?
IntermediateRouting
Answer
A resolver pre-fetches data during navigation so the routed component renders with data already available. The functional form is ResolveFn<T>: a function receiving (route, state), using inject() for dependencies, returning T, Promise<T> or Observable<T>; the router waits for the first emission (Observables must emit; a stream that never completes or emits will hang navigation) and stores the result on the route under the key you chose in the resolve map. The component reads it via ActivatedRoute.data, or, with withComponentInputBinding(), the resolved key binds straight onto a component input of the same name, which makes resolver-fed components pleasantly dumb.
Errors need explicit thought: an unhandled resolver error cancels the navigation silently, the user clicks and nothing happens, so production resolvers catch errors and either return a fallback shape or redirect via inject(Router) with a RedirectCommand. Now the honest half interviewers respect: resolvers block navigation until data arrives, so on a slow API the app feels frozen, no route change, no skeleton, just a dead click, unless you wire router events to a global progress bar. The competing pattern, navigate immediately and let the component load data behind a skeleton (resource(), or an Observable with a loading state), gives better perceived performance for most content pages, which is why resolvers fell out of default use.
They remain right when rendering without the data is worse than waiting: authorisation-adjacent lookups, small critical payloads, or editing forms where a flash of empty form then patched values would be jarring. Mentioning withNavigationErrorHandler and RedirectCommand for centralised failure handling marks current knowledge.
import { ResolveFn, RedirectCommand, Router } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, of } from 'rxjs';
export const jobResolver: ResolveFn<Job | RedirectCommand> = (route) => {
const router = inject(Router);
const id = route.paramMap.get('jobId')!;
return inject(JobsService)
.getJob(id)
.pipe(
catchError(() =>
of(new RedirectCommand(router.parseUrl('/jobs?missing=1'))),
),
);
};
// route config
// { path: 'jobs/:jobId', component: JobDetailComponent,
// resolve: { job: jobResolver } }
// with withComponentInputBinding() the component just declares:
// job = input.required<Job>();
Q39How do Angular SSR and hydration work, and what does incremental hydration add?
IntermediateSSR
Answer
Angular SSR (the @angular/ssr package; ng new --ssr or ng add @angular/ssr) renders the application to HTML on a Node server (or prerenders routes at build time), so users and crawlers get meaningful markup before JavaScript arrives. The client then hydrates: with provideClientHydration() in the providers, Angular walks the existing server-rendered DOM and attaches component logic to it instead of destroying and re-rendering it, which is what old Angular Universal did, complete with the visible flicker. Non-destructive hydration requires the server and client DOM to match, which imposes the rules that generate real production bugs: no direct DOM manipulation outside Angular's rendering, valid HTML only (a <div> inside a <p>, or table markup the browser silently corrects, causes mismatch errors in the NG0500 series), and components that genuinely cannot match can opt out individually with the ngSkipHydration attribute.
Server-only environments also mean guarding browser globals: window access belongs in afterNextRender, or behind isPlatformBrowser(inject(PLATFORM_ID)). withEventReplay() closes the interactivity gap: clicks made before hydration finishes are recorded and replayed after, so early clicks are not lost. Incremental hydration (stable since Angular 19 era) goes further by combining hydration with @defer: adding hydrate triggers like @defer (hydrate on viewport) or hydrate on interaction means the server renders the block's real content, but the client downloads and hydrates its JavaScript only when triggered, and hydrate never for purely static regions, cutting time-to-interactive on heavy pages dramatically. For India-facing consumer products where much traffic arrives on mid-range Android phones over inconsistent networks, this SSR-plus-partial-hydration story is a genuinely differentiating interview topic.
// app.config.ts
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
export const appConfig = {
providers: [
provideClientHydration(withEventReplay(), withIncrementalHydration()),
],
};
// template: server renders real content, JS hydrates only when visible
@defer (hydrate on viewport) {
<app-reviews-section [jobId]="jobId()" />
} @placeholder {
<div class="reviews-skeleton"></div>
}
// browser-only APIs must wait for the client:
constructor() {
afterNextRender(() => {
this.width = window.innerWidth; // never runs on the server
});
}
Q40What does NgOptimizedImage do, and how do you use it correctly?
IntermediatePerformance
Answer
NgOptimizedImage (the ngSrc directive from @angular/common) encodes image best practices that directly move Core Web Vitals, particularly Largest Contentful Paint. Swapping src for ngSrc activates enforcement: the directive requires explicit width and height (or the fill attribute for images sizing to a container), which reserves layout space and eliminates the layout shift that unsized images cause; it lazy-loads by default and warns if you lazy-load an image it detects as the LCP element; and marking your hero image with priority sets fetchpriority='high', disables lazy loading, and in dev mode Angular verifies your priority choice against the actual LCP element, logging a warning when they disagree, tooling-enforced performance review. It generates srcset automatically from the sizes attribute so devices download appropriately scaled variants, supports placeholder to show an automatically generated low-res blurred preview while loading, and integrates with image CDNs through loaders: provideImgixLoader, provideCloudinaryLoader, provideCloudflareLoader, provideImageKitLoader (ImageKit being an Indian company, a nice local note), or a custom IMAGE_LOADER function mapping ngSrc plus width to your CDN's resizing URL format.
Common mistakes it catches loudly rather than silently: distorted aspect ratios (warns when rendered ratio mismatches intrinsic), missing dimensions (build-time error), and oversized downloads. In interviews, connect it to business reality: image weight dominates payload on consumer sites, and on Indian mobile networks a hero image without priority plus a CDN loader is routinely the difference between a 2.5s and a 5s LCP. It works with any <img>, requires no backend changes for the basics, and adopting it is usually the highest-ROI single performance change on an image-heavy Angular page.
import { Component } from '@angular/core';
import { NgOptimizedImage, provideImageKitLoader } from '@angular/common';
// app.config.ts
export const appConfig = {
providers: [provideImageKitLoader('https://ik.imagekit.io/goodspace')],
};
@Component({
selector: 'app-company-banner',
imports: [NgOptimizedImage],
template: `
<!-- LCP hero: eager, high priority, CDN-resized -->
<img ngSrc="banners/hiring-week.png" width="1200" height="400" priority />
<!-- below the fold: lazy by default, responsive srcset, blur placeholder -->
<img
ngSrc="logos/acme.png"
width="96"
height="96"
sizes="(max-width: 600px) 48px, 96px"
placeholder
/>
`,
})
export class CompanyBannerComponent {}
Q41How do toSignal and toObservable bridge RxJS and signals, and what are their sharp edges?
IntermediateSignals
Answer
Both live in @angular/core/rxjs-interop. toSignal(obs$) subscribes immediately and exposes the latest emission as a signal; because a signal must always have a current value and the source may not have emitted yet, you choose the gap-filling strategy explicitly: pass { initialValue: [] }, or { requireSync: true } for sources that emit synchronously on subscription (BehaviorSubject, startWith pipelines), which types the signal without undefined but throws at runtime if the source is actually async. Without either, the signal's type unions undefined. Subscription lifetime follows the injection context: toSignal called in a field initializer unsubscribes on component destroy via DestroyRef; calling it outside a context requires passing { injector } or wrapping in runInInjectionContext.
Error behaviour is unforgiving and worth stating: if the source errors, reading the signal throws that error, so streams headed into toSignal should generally have catchError applied first. toObservable(sig) goes the other way, producing an Observable that emits the signal's value on subscription and on changes; under the hood it uses an effect, so emissions are asynchronous and coalesced, rapid consecutive signal writes produce one emission of the final value, not one per write, a timing subtlety that surprises people porting tests. The practical pattern this enables: keep component inputs and UI state as signals, drop to RxJS when you need operator power (debounce, switchMap against HTTP, retry), then surface the result back as a signal: toSignal(toObservable(this.query).pipe(debounceTime(300), switchMap(...))). That sandwich is the idiomatic 2026 answer to 'debounced reactive search with signal inputs'.
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { debounceTime, switchMap, catchError, of } from 'rxjs';
export class CandidateSearchComponent {
private http = inject(HttpClient);
city = signal('Delhi');
minLpa = signal(6);
private criteria = computed(() => ({ city: this.city(), minLpa: this.minLpa() }));
candidates = toSignal(
toObservable(this.criteria).pipe(
debounceTime(250),
switchMap(({ city, minLpa }) =>
this.http
.get<Candidate[]>(`/api/candidates?city=${city}&minLpa=${minLpa}`)
.pipe(catchError(() => of([] as Candidate[]))),
),
),
{ initialValue: [] as Candidate[] },
);
}
Q42How do you test a component with TestBed, and what changes when the component uses signal inputs?
IntermediateTesting
Answer
TestBed builds a real Angular environment per test: TestBed.configureTestingModule({ imports: [JobCardComponent], providers: [...] }) registers the standalone component and its test doubles, TestBed.createComponent returns a ComponentFixture, and you drive rendering explicitly with fixture.detectChanges(), change detection does not run by itself in tests, which is the first thing candidates forget. Query the DOM through fixture.nativeElement or fixture.debugElement.query(By.css('.title')), and simulate interaction by dispatching events then re-running detectChanges. Doubles go in through DI: { provide: JobsService, useValue: jasmine.createSpyObj('JobsService', ['getJobs']) }, keeping tests hermetic.
Signal inputs changed the mechanics meaningfully: you cannot assign component.jobId = 5 because input() properties are read-only InputSignals; the supported API is fixture.componentRef.setInput('jobId', 5), which routes through the input machinery, marks the view dirty and updates transforms, followed by detectChanges() to render. Outputs are still asserted by subscribing: component.apply.subscribe(spy) then triggering the emitting interaction. Two modern practices worth naming: fixture.autoDetectChanges() (and zoneless-friendly awaiting of fixture.whenStable()) reduces manual detectChanges ceremony, and component harnesses from @angular/cdk/testing let you interact with Material components through a stable API instead of brittle CSS selectors into their internals.
On runners: Karma is deprecated; new CLI projects and migrating teams run unit tests on modern runners (the CLI has an experimental Vitest-based builder, and Jest setups are widespread in industry), but the TestBed API is identical regardless of runner, so your TestBed fluency transfers. A strong closing point: prefer testing through the DOM (what the user sees) over asserting internal fields, fixture-based tests make that natural.
import { TestBed } from '@angular/core/testing';
describe('JobCardComponent', () => {
it('emits apply with the job id when clicked', async () => {
await TestBed.configureTestingModule({
imports: [JobCardComponent],
}).compileComponents();
const fixture = TestBed.createComponent(JobCardComponent);
// signal inputs are read-only: use setInput, not direct assignment
fixture.componentRef.setInput('jobId', 42);
fixture.componentRef.setInput('title', 'Angular Developer');
fixture.detectChanges();
const emitted: number[] = [];
fixture.componentInstance.apply.subscribe((id) => emitted.push(id));
(fixture.nativeElement as HTMLElement)
.querySelector('button')!
.click();
expect(emitted).toEqual([42]);
});
});
Q43How do you test HTTP code with provideHttpClientTesting and HttpTestingController?
IntermediateTesting
Answer
HTTP tests should never hit a network; Angular swaps the backend with a mock you script. Setup order matters and is a known trap: providers: [provideHttpClient(), provideHttpClientTesting()], with the testing provider after the real one so it overrides the backend. You then inject HttpTestingController to intercept requests.
The flow per test: call the service method and subscribe (nothing fires without subscription, cold Observables again), assert the request with const req = httpMock.expectOne('/api/jobs?city=Pune'), inspect req.request.method, headers and body, then resolve it with req.flush(mockBody) for success, req.flush(msg, { status: 500, statusText: 'Server Error' }) for HTTP errors, or req.error(new ProgressEvent('error')) for network-level failure (that status 0 path). expectOne throws if zero or multiple requests match, which is itself an assertion; match() returns whatever matches without asserting count, useful for deduplication tests; and afterEach(() => httpMock.verify()) fails the test if any request went unhandled, catching accidental extra calls, the assertion that catches the double-subscription bug in review. This controller pattern shines for testing interceptors: request the URL through HttpClient in the test and assert the intercepted req.request.headers.get('Authorization') carries the token, or flush a 401 and verify the refresh-and-replay sequence by expecting the refresh call then the replayed original. For retry logic, flush an error then expectOne again for the retried request. Because flush is synchronous, these tests run fast and deterministically, no fakeAsync needed unless timers (debounce, backoff delays) are involved, in which case fakeAsync plus tick(300) advances virtual time precisely.
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import {
provideHttpClientTesting,
HttpTestingController,
} from '@angular/common/http/testing';
describe('JobsService', () => {
let service: JobsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(JobsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify()); // no stray requests allowed
it('sends city as a query param and maps the response', () => {
let result: Job[] = [];
service.search('Pune', 8).subscribe((r) => (result = r));
const req = httpMock.expectOne('/api/jobs?city=Pune&minLpa=8');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, title: 'Angular Dev', lpa: 12 }]);
expect(result.length).toBe(1);
});
});
Q44How do you build a custom form control with ControlValueAccessor?
IntermediateForms
Answer
ControlValueAccessor (CVA) is the bridge that lets any component participate in Angular forms as if it were a native input: usable with formControlName, ngModel, validators, touched/dirty tracking, the lot. You implement four methods. writeValue(v) receives values flowing from the form model into your component (patchValue, reset), and must handle null defensively since reset passes it. registerOnChange(fn) hands you the callback to invoke whenever the user changes the value inside your component, this is how changes propagate outward; store it and call it in your event handlers. registerOnTouched(fn) similarly reports the touched transition, conventionally on blur or first interaction. setDisabledState(isDisabled) reacts to control.disable()/enable(). Registration is the part people fumble: provide NG_VALUE_ACCESSOR with useExisting: forwardRef(() => RatingComponent) and multi: true, multi because several accessors can coexist and Angular picks appropriately.
With that wiring, <app-rating formControlName="communication" /> just works, including Validators.required treating your null value as empty. If the component should also self-validate, implement Validator and provide NG_VALIDATORS the same way. Real-world CVA candidates: star ratings, OTP input groups, chip selectors, salary range sliders, rich-text editors wrapping a third-party library.
Two production notes that impress: never call the onChange callback from inside writeValue (that echoes model writes back out and can cause loops with valueChanges subscribers), and keep the component usable standalone by making CVA optional, guard the callbacks with defaults. Interviewers love CVA because it proves you understand the forms plumbing rather than just consuming it.
import { Component, forwardRef, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingComponent),
multi: true,
},
],
template: `
@for (star of [1, 2, 3, 4, 5]; track star) {
<button
type="button"
[disabled]="disabled()"
[class.filled]="star <= (value() ?? 0)"
(click)="pick(star)"
(blur)="onTouched()"
>★</button>
}
`,
})
export class RatingComponent implements ControlValueAccessor {
value = signal<number | null>(null);
disabled = signal(false);
private onChange: (v: number | null) => void = () => {};
onTouched: () => void = () => {};
writeValue(v: number | null) { this.value.set(v); }
registerOnChange(fn: any) { this.onChange = fn; }
registerOnTouched(fn: any) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled.set(d); }
pick(star: number) {
this.value.set(star);
this.onChange(star);
}
}
Q45How do you build a global error-handling strategy: ErrorHandler, HTTP errors, and user-facing recovery?
IntermediateError Handling
Answer
Angular routes every uncaught error, template errors, lifecycle exceptions, unhandled promise rejections inside the app, through the ErrorHandler service, whose default implementation just calls console.error. Production apps replace it: { provide: ErrorHandler, useClass: GlobalErrorHandler } with a class whose handleError(error) reports to Sentry or an in-house endpoint, dedupes floods, and optionally shows a generic toast. Inside a custom handler, be careful injecting services that themselves might throw, and remember handleError can run outside change detection, so UI updates from it should go through signals or explicit notification.
HTTP errors deserve a separate, layered strategy rather than dumping everything on ErrorHandler: an interceptor centralises cross-cutting concerns (401 refresh flows, 503 maintenance-mode redirects, attaching correlation IDs to error reports), while feature code handles what it can meaningfully recover from, catchError returning fallback data for a non-critical widget, retry with backoff for flaky GETs, and surfacing actionable messages for form submissions (a 422 with field errors should map onto setErrors of the corresponding controls, not a toast). Distinguish error classes explicitly in your answer: HttpErrorResponse with status 0 is network/CORS (message: 'check your connection'), 4xx is a client problem (do not retry), 5xx may merit limited retry, and only unexpected exceptions belong in the global handler. Two modern additions: provideBrowserGlobalErrorListeners() wires window 'error' and 'unhandledrejection' events into ErrorHandler so async escapes are captured too, and the Router's withNavigationErrorHandler catches navigation-time failures (chunk load errors after a deploy are the classic, fix by prompting a reload when a stale lazy chunk 404s). Tie it together with correlation: log the URL, user ID and route so a Sentry event is debuggable.
import { ErrorHandler, Injectable, inject, provideBrowserGlobalErrorListeners } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: unknown) {
if (error instanceof HttpErrorResponse) {
if (error.status === 0) this.toast.show('You appear to be offline.');
// 401/5xx handled in the interceptor; do not double-report
} else {
reportToSentry(error, { url: location.href });
this.toast.show('Something went wrong. The team has been notified.');
}
}
}
// app.config.ts
export const appConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
],
};
Q46What goes into app.config.ts, and how does bootstrapApplication differ from the old NgModule bootstrap?
IntermediateArchitecture
Answer
app.config.ts exports an ApplicationConfig, essentially a providers array, that bootstrapApplication(AppComponent, appConfig) uses to build the root environment injector. Everything the old AppModule did through imports of configured NgModules now happens through provide* functions: provideRouter(routes, withComponentInputBinding(), withPreloading(...)), provideHttpClient(withFetch(), withInterceptors([...])), provideAnimationsAsync() (which lazy-loads the animations engine instead of shipping it eagerly, a free bundle win over the old BrowserAnimationsModule), provideClientHydration(...) for SSR, provideZonelessChangeDetection(), and library equivalents like provideStore from NgRx. The differences beyond syntax are worth articulating.
First, tree-shaking: provider functions only pull in what the features you pass actually reference, whereas NgModules dragged their whole configured surface along. Second, composition: config objects merge cleanly, the SSR setup uses mergeApplicationConfig(appConfig, serverConfig) to layer server-specific providers over shared ones, and libraries expose feature functions rather than forRoot/forChild static methods with their awkward typing. Third, startup logic: the old APP_INITIALIZER multi-provider pattern is superseded by provideAppInitializer(() => ...), which takes a function (sync, Promise or Observable) that must settle before the app renders, the standard hook for loading runtime config JSON or restoring a session; keep it fast because the user stares at a blank screen while it runs.
Also mention provideEnvironmentInitializer for side-effect initialisation of lazily created environment injectors. The interview one-liner: app.config.ts turned application assembly from module graph archaeology into a flat, readable list of function calls.
import { ApplicationConfig, provideAppInitializer, inject } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideAnimationsAsync(),
provideAppInitializer(() => {
const config = inject(RuntimeConfigService);
return config.load(); // Promise resolves before first render
}),
],
};
// main.ts
bootstrapApplication(AppComponent, appConfig).catch(console.error);
Q47How do router preloading strategies work, and when would you write a custom one?
IntermediatePerformance
Answer
Lazy loading solves initial bundle size but introduces click latency: the chunk downloads when the user navigates. Preloading removes that latency by fetching lazy chunks after the initial app is stable, in the background. The router ships two built-ins configured via withPreloading(): NoPreloading (default) and PreloadAllModules, which eagerly background-loads every lazy route once the app boots.
PreloadAllModules is fine for small apps but self-defeating at scale: an app with thirty lazy routes saturates the network fetching admin screens most users never open, competing with images and API calls that matter now. A custom PreloadingStrategy fixes that: implement preload(route, loadFn), return loadFn() to preload or of(null) to skip, and decide per route using route.data flags. Practical policies seen in production: preload only routes tagged data: { preload: true } (the classic selective strategy); delay preloading with a timer so it never competes with startup; respect the Network Information API, skip preloading on navigator.connection?.saveData or effectiveType '2g', which is directly relevant for Indian mobile traffic; or preload based on observed user behaviour (hover/viewport proximity of the link, the quicklink pattern).
Note the interplay with guards: canMatch-guarded routes will not preload for users who fail the guard when using canMatch (another argument for it over canActivate on protected lazy routes). And distinguish this from @defer prefetching, which handles in-page deferred blocks; route preloading handles navigation targets. Measure the effect in DevTools' network panel: chunks should arrive during idle, and route transitions should hit cache.
import { PreloadingStrategy, Route, provideRouter, withPreloading } from '@angular/router';
import { Injectable } from '@angular/core';
import { Observable, of, timer, mergeMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreload implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
const conn = (navigator as any).connection;
if (conn?.saveData || conn?.effectiveType === '2g') return of(null);
return route.data?.['preload']
? timer(3000).pipe(mergeMap(() => load())) // after startup settles
: of(null);
}
}
// app.config.ts
provideRouter(routes, withPreloading(SelectivePreload));
// route: { path: 'jobs', loadChildren: ..., data: { preload: true } }
Q48What causes ExpressionChangedAfterItHasBeenCheckedError (NG0100), and what are the correct fixes?
IntermediateChange Detection
Answer
NG0100 is development-mode-only: after each change detection pass, dev builds run a second verification pass, and if any template binding produces a different value the second time, Angular throws ExpressionChangedAfterItHasBeenCheckedError. It is not noise, it is telling you your view was internally inconsistent within a single render: some state changed after its consumer had already been checked, so part of the screen showed the old value. Classic causes: a child mutating parent state synchronously during initialisation (child's ngOnInit sets a shared service field the parent's template already rendered); ngAfterViewInit changing something a template binds to (view init runs after the view was checked, by definition); a getter or template function returning a fresh value each call (new Date(), Math.random(), a new array literal, these differ between pass and verification pass); and parent-child cycles through outputs firing during the first render.
The fixes, in order of correctness: restructure so data flows downward before CD runs (move the write earlier, often to ngOnInit of the right component or into a service resolved before render); make the expression stable (compute once into a field or a computed() signal instead of a getter that fabricates values); if the state change is genuinely legitimate post-view work, defer it out of the current cycle with queueMicrotask/setTimeout or, better, afterNextRender; and as a last resort ChangeDetectorRef.detectChanges() after the mutation to re-check synchronously. Never 'fix' it by disabling dev mode checks. The modern angle worth stating: signals largely dissolve this class of bug, because a signal write marks consumers dirty and schedules another CD pass properly instead of leaving a half-updated view, and zoneless scheduling formalises exactly when re-renders happen.
// BUG: fresh reference every check -> NG0100 in dev builds
@Component({ template: `<app-badge [style]="badgeStyle" />` })
export class BuggyComponent {
get badgeStyle() {
return { color: 'green' }; // new object each call
}
}
// FIX 1: stable reference
export class FixedComponent {
badgeStyle = { color: 'green' };
}
// FIX 2: state written after view init, deferred correctly
export class MeasuredComponent {
height = signal(0);
constructor() {
afterNextRender(() => {
// runs after paint; signal write schedules a clean new CD pass
this.height.set(document.querySelector('.hero')!.clientHeight);
});
}
}
Q49How does NgZone.runOutsideAngular improve performance, and when is code trapped inside the zone a real problem?
AdvancedChange Detection
Answer
In a zone-based app, every callback that zone.js patched, timers, listeners, promises, resolves inside the Angular zone, and each resolution can trigger a full change detection cycle. That is catastrophic for high-frequency events: a mousemove-driven drag handler, a scroll listener, a requestAnimationFrame animation loop, a WebSocket ticking fifty times a second, or a charting library's internal timers each force Angular to re-check the component tree even though most ticks change nothing bindable. NgZone.runOutsideAngular(fn) executes fn in the parent (non-Angular) zone, so async work registered inside it no longer triggers CD; when a meaningful state change finally occurs, you re-enter deliberately with ngZone.run(() => ...) or, in modern code, write to a signal, which schedules CD correctly regardless of zone.
The standard production pattern wraps third-party library initialisation (charts, maps, editors) in runOutsideAngular because their internal event plumbing is none of Angular's business, then surfaces only the summarised results (selection changed, drag ended) back into Angular state. Diagnostics: Angular DevTools' profiler shows CD cycles per event; a drag that fires hundreds of cycles per second is the smoking gun. Related tools: ngZone.onStable/onMicrotaskEmpty for post-render coordination (largely superseded by afterNextRender), and provideZoneChangeDetection({ eventCoalescing: true }) which coalesces multiple events in one task into a single CD pass, a cheap mitigation. The forward-looking close interviewers appreciate: zoneless change detection makes this whole topic obsolete by inverting the model, nothing triggers CD unless it notifies, so runOutsideAngular is fundamentally a bridge technique for the zone.js era and for libraries not yet signal-aware.
import { Component, NgZone, inject, signal } from '@angular/core';
@Component({
selector: 'app-price-stream',
template: `<span>NIFTY {{ lastPrice() }}</span>`,
})
export class PriceStreamComponent {
private zone = inject(NgZone);
lastPrice = signal(0);
constructor() {
this.zone.runOutsideAngular(() => {
const ws = new WebSocket('wss://ticks.example.com/nifty');
let latest = 0;
ws.onmessage = (e) => (latest = JSON.parse(e.data).ltp);
// throttle UI updates to 4/sec instead of one CD per tick
setInterval(() => {
// signal write schedules CD safely even from outside the zone
this.lastPrice.set(latest);
}, 250);
});
}
}
Q50How does the signal graph propagate changes internally: push/pull, memoization, and equality?
AdvancedSignals
Answer
Angular's signal implementation is a push-pull hybrid designed to be glitch-free. On write, a signal does not eagerly recompute its consumers; it pushes only a dirty notification up the dependency graph, computeds transitively flag themselves as possibly-stale, and effects/templates schedule themselves. Actual recomputation is pull-based: nothing evaluates until someone reads a value, at which point the computed checks whether its producers actually changed (using version counters internally) and re-runs only if needed.
This lazy pull gives you two guarantees interviewers can probe. First, no glitches: in a diamond dependency (A feeds B and C, both feed D), D never observes a state where B updated but C has not; by the time D is read, the whole graph is consistent, unlike naive event-emitter reactivity where D would fire twice with an inconsistent intermediate. Second, automatic memoization with cutoff: if a computed re-runs but produces an equal value, its consumers are not re-notified, pruning whole subtrees of work.
Equality is where practice meets theory: the default comparison is referential (Object.is semantics), so a computed returning a fresh array each time (items().filter(...)) defeats cutoff, every read looks like a change; supplying { equal: (a, b) => ... } (or returning stable references) restores pruning, and the same equal option on writable signals suppresses no-op set calls. Dependency tracking is dynamic, rebuilt on every execution from what was actually read, so branches drop stale dependencies automatically, and untracked() opts a read out. Templates are just consumers in this graph: a signal read in a template registers the view, which is the precise mechanism that lets zoneless Angular know exactly which views to re-render, no tree walking, no dirty-checking every binding.
Key Points
- Writes push dirty flags; reads pull recomputation lazily
- Glitch-free: diamond dependencies never expose inconsistent state
- Equality cutoff prunes propagation; custom equal for value semantics
- Dynamic dependency tracking rebuilt per execution; untracked() opts out
- Template = graph consumer: the foundation of zoneless CD
Q51What does the AOT compiler actually do, and how does strictTemplates change what errors you catch?
AdvancedCompilation
Answer
Ahead-of-time compilation translates components and templates into efficient JavaScript instructions at build time. The Ivy compiler parses each template into an AST and emits template functions built from instructions (elementStart, advance, property, textInterpolate) that the runtime executes in two phases, creation and update, per component. Because templates become code at build time, three things follow: no compiler ships in the production bundle (JIT compilation in production died with the old engine; AOT is the only sane mode and the default for ng build and ng serve), templates fail the build instead of failing users at runtime, and the emitted code is aggressively tree-shakable because Ivy's locality principle compiles each component independently rather than requiring whole-program metadata.
The type-checking half is where senior candidates differentiate. With strictTemplates: true under angularCompilerOptions in tsconfig.json, the compiler generates hidden type-check blocks for every template expression and runs TypeScript over them, so templates are checked as strictly as .ts code: binding a string to an input typed number is error NG2322-style at build; misspelling an input surfaces NG8002 (can't bind to unknown property); an unknown element yields NG8001; a nullable object dereferenced without guard fails under strictNullChecks; and $event, template ref variables, @for loop variables and pipe arguments (a DatePipe fed an object, say) are all typed precisely. Generic components even get input type inference across the boundary.
Escape hatches exist ($any(expr) casts locally) and fine-grained flags (strictNullInputTypes, strictDomEventTypes) let legacy codebases ratchet up gradually. The practical interview claim: strictTemplates converts the largest class of Angular production errors, template/data-shape mismatches after refactors, into compile-time failures, which is why enabling it is usually step one when inheriting an older codebase.
// tsconfig.json
{
"compilerOptions": {
"strict": true
},
"angularCompilerOptions": {
"strictTemplates": true,
"strictInjectionParameters": true
}
}
// With strictTemplates on, ALL of these fail the BUILD, not the user:
// <app-job-card [jobId]="'42'" /> string into input<number>
// <app-job-card [titel]="title" /> NG8002 unknown property
// {{ job.salary.toFixed(1) }} job possibly undefined
// {{ createdAt | date:'shortDate' }} createdAt: number|Date ok, object fails
Q52Diagnose hydration failures: what causes NG0500-series errors, and how do you fix them without disabling hydration?
AdvancedSSR
Answer
Hydration mismatch errors (the NG0500 series, NG0500 node mismatch, NG0501 missing expected node, NG0502 hydration-during-bootstrap issues, and related codes) mean the client-side application, walking the server-rendered DOM to claim nodes, found reality diverging from what its own first render pass expected. Root causes cluster predictably. Invalid HTML nesting is the sneakiest: the server emits <p><div>...</div></p> or a <table> missing <tbody>, the browser's parser silently restructures it while parsing, and the corrected DOM no longer matches Angular's expectations; the fix is fixing the markup, and dev builds now warn about common invalid nestings.
Direct DOM manipulation is the second family: anything mutating the DOM outside Angular's renderer before hydration completes, a script tag injecting content, a third-party widget, innerHTML set in a constructor, breaks the walk. Third: genuinely divergent rendering between server and client, branching on isPlatformBrowser during initial render, timestamps or randomness in templates (Date.now(), Math.random()), or locale/timezone differences between the Node server and the user's browser producing different formatted text. Fixes in preference order: repair the HTML; move browser-only logic into afterNextRender so the initial client render matches the server; make nondeterministic values deterministic (render server-provided timestamps, defer client-only personalisation until after hydration); for components that legitimately cannot match, an SSR'd third-party ad slot, apply ngSkipHydration on the host element so that subtree re-renders destructively while the rest of the page hydrates properly, a scalpel, not the provideClientHydration removal hammer.
Also know the i18n caveat historically attached to hydration support, and that event replay (withEventReplay) plus incremental hydration only work when the underlying DOM actually matches. Debugging workflow: dev-mode console messages name the expected vs actual node and the component; reproduce with ng serve against the SSR dev server, and diff view-source (server HTML) against the post-hydration DOM.
Key Points
- NG0500-series = server DOM vs client first-render divergence
- Top causes: invalid HTML nesting, out-of-band DOM mutation, nondeterministic templates
- afterNextRender for browser-only work; deterministic data for first render
- ngSkipHydration on the offending component, never app-wide disable
- Diff view-source HTML against hydrated DOM to localise the mismatch
Q53Compare NgRx classic (actions/reducers/effects/selectors) with SignalStore: when does each earn its complexity?
AdvancedState Management
Answer
Classic NgRx implements Redux: state lives in one immutable store; components dispatch actions (created via createAction/props); pure reducers (createReducer, on) compute the next state; createEffect handles side effects by listening to the action stream and dispatching results (the ofType, switchMap-to-API, map-to-success-action pipeline); and memoized selectors (createSelector) derive view state. Its value is discipline at scale: every mutation is an explicit, logged, replayable event, DevTools time-travel works, effects centralise async orchestration, and large teams get guardrails against ad-hoc mutation. Its cost is ceremony, four files per feature, and mountains of RxJS in components.
SignalStore (from @ngrx/signals) is the signal-native alternative: signalStore(withState(...), withComputed(...), withMethods(...)) declares state as deep signals, derived values as computed, and mutations as methods calling patchState; rxMethod bridges RxJS for debounced/cancellable async, and withEntities provides normalised collection management. Components consume store signals directly in templates, no async pipe, no select. The honest decision framework interviewers want: SignalStore (or even plain signals in a service) for most feature state, its per-feature stores align with standalone architecture and lazy routes, and boilerplate drops dramatically.
Classic NgRx retains the edge when you specifically need its event log: audit-grade traceability, complex multi-feature workflows where many parts react to one domain event, undo/redo, or an existing large NgRx codebase where consistency beats novelty. Both interoperate during migration (selectSignal exposes store slices as signals). What loses you points is maximalism in either direction, 'NgRx everywhere' for a CRUD app or 'signals in components' for a trading dashboard with cross-cutting event flows both signal poor judgment. State management is chosen per problem shape: event-sourced coordination vs owned reactive state.
import { signalStore, withState, withComputed, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { computed, inject } from '@angular/core';
import { pipe, debounceTime, switchMap, tap } from 'rxjs';
export const JobsStore = signalStore(
{ providedIn: 'root' },
withState({ jobs: [] as Job[], query: '', loading: false }),
withComputed(({ jobs }) => ({
count: computed(() => jobs().length),
})),
withMethods((store, api = inject(JobsService)) => ({
setQuery(query: string) {
patchState(store, { query });
},
search: rxMethod<string>(
pipe(
debounceTime(300),
tap(() => patchState(store, { loading: true })),
switchMap((q) =>
api.search(q).pipe(
tap((jobs) => patchState(store, { jobs, loading: false })),
),
),
),
),
})),
);
// component: store = inject(JobsStore); template: {{ store.count() }}
Q54How do you hunt memory leaks in a long-running Angular SPA: sources, tooling, and fixes?
AdvancedPerformance
Answer
Angular leak sources are a short, known list, which is why interviewers expect a systematic answer. One: infinite Observable subscriptions made manually and never torn down, router.events, valueChanges, store selections, interval, fromEvent, each subscription closure retains the component, so a route the user visits fifty times retains fifty dead component trees. Two: listeners and timers attached outside Angular's mechanisms, document.addEventListener in ngOnInit without removal, setInterval without clearInterval, third-party libraries (charts, maps, editors) whose destroy() was never called in ngOnDestroy.
Three: references parked in long-lived services: a root service caching component instances, ViewContainerRefs or DOM nodes; detached DOM held by a stale reference is unreclaimable. Four: RxJS specifics like shareReplay without refCount keeping a source alive forever after all real subscribers left. Detection workflow: reproduce with a navigation loop (enter route, leave, repeat ten times), then in Chrome DevTools' Memory panel take heap snapshots between cycles and diff; filter the comparison for your component class names, ten retained JobDetailComponent instances after ten visits is the smoking gun, and the retainers tree names exactly what holds them (usually a subscription closure or a listener).
The performance timeline's sawtooth-that-trends-upward confirms it macroscopically, and queryObjects(ComponentClass) in the console gives quick counts. Detached-DOM views in the snapshot point at listener leaks. Fixes map one-to-one: takeUntilDestroyed/async pipe/toSignal for streams, DestroyRef.onDestroy for imperative teardown (timers, library destroy(), removeEventListener), host-object listeners instead of manual addEventListener (auto-cleaned), WeakMap/WeakRef for caches keyed by objects, and code-review vigilance on any subscribe( in a component without a visible teardown story. State the prevention culture too: an ESLint rule banning bare subscribe in components converts this from debugging skill to non-event.
Key Points
- Big four: unmanaged subscriptions, listeners/timers, service-held references, shareReplay without refCount
- Repro loop + heap snapshot diff, filtered by component class name
- Retainers tree identifies the exact closure holding the component
- Fix with takeUntilDestroyed, DestroyRef.onDestroy, host listeners, weak references
- Prevent with lint rules, not heroics
Q55How do you architect Angular micro frontends with module federation, and what breaks in practice?
AdvancedArchitecture
Answer
Micro frontends split one product across independently built and deployed applications: a shell app loads remote Angular applications at runtime, typically via module federation, where each remote exposes routes or components and the shell maps paths to them. In the Angular ecosystem this means the community tooling around @angular-architects/module-federation and its successor @angular-architects/native-federation, the latter built on standard ES modules and import maps rather than webpack-specific runtime, which matters since the CLI moved to esbuild and webpack-based federation stopped being a natural fit. The shell's routes use loadChildren with loadRemoteModule pointing at the remote's exposed routes, and a federation manifest maps remote names to deployed URLs so environments switch without rebuilds.
Now the failure modes, which is where real experience shows. Version skew: Angular packages must be shared singletons (shared: { '@angular/core': { singleton: true, strictVersion: true } }), because two Angular runtimes in one page fight over the platform; with zone.js present, double-loading the zone patch corrupts async behaviour app-wide. Singleton DI illusions: root-provided services are singletons per injector tree, so shell and remote each get their own AuthService unless auth state is deliberately shared through the shell (exposed via federation or a browser-level channel).
CSS collision across teams (mitigated by encapsulation, but globals and theming clash). Routing contention: only the shell may own the URL; remotes must register subtree routes, not instantiate their own Router bootstrapping. And operationally, a stale remote deployed against a newer shared library version fails only at runtime, so contract testing and synchronized Angular upgrades across teams become governance work. The honest architectural caveat interviewers respect: micro frontends pay off when independent deployment cadence across multiple teams is a genuine organisational constraint (large banks and GCCs, exactly the Indian Angular heartland); for a single team they add failure modes without benefits, and a well-modularised monolith with lazy routes is strictly simpler.
Key Points
- Shell + remotes via module federation; native federation aligns with esbuild
- Share @angular/core as a strict singleton; never load zone.js twice
- providedIn root is per-app: cross-app auth/state needs explicit sharing
- Shell owns the URL; remotes contribute route subtrees
- Version-skew failures appear only at runtime: contract-test deployments
Q56How does CDK virtual scrolling keep 50,000-row lists responsive, and what are its constraints?
AdvancedPerformance
Answer
Rendering 50,000 rows creates 50,000 component instances and often millions of DOM nodes; the page dies in layout and GC long before change detection matters. Virtual scrolling renders only the visible window plus a buffer, recycling DOM as the user scrolls. With @angular/cdk/scrolling you wrap content in <cdk-virtual-scroll-viewport itemSize="56"> and iterate with *cdkVirtualFor="let job of jobs" (this remains a structural directive; the block syntax has no virtual equivalent, so CommonModule-era muscle memory survives here).
The fixed-size strategy needs itemSize in pixels to compute total scroll height and which indices are visible; minBufferPx/maxBufferPx tune how much off-screen content stays rendered to absorb fast flings, too little buffer shows blank flashes on fast scroll wheels and cheap Android devices, too much erodes the win. *cdkVirtualFor supports trackBy and exposes context variables like index; templateCacheSize controls recycled template reuse. Constraints that decide whether you can use it: rows must have known, ideally uniform height, variable-height autosizing exists only as an experimental strategy (or you implement VirtualScrollStrategy yourself, providing VIRTUAL_SCROLL_STRATEGY), so designs with wildly variable row heights often get redesigned to fixed heights instead; the viewport needs an explicit height; in-page Ctrl+F stops finding off-screen rows (a real acceptance-criteria issue, provide in-app search); accessibility needs care since off-screen items do not exist for screen readers; and combining with sticky headers or CSS that assumes all rows exist takes work. Pair it with OnPush rows, stable track identities, and signals for row state, and combine with server-side pagination when even the data (not just the DOM) is too large: virtual scrolling solves DOM volume, not payload volume, naming that distinction lands well in interviews.
import { Component } from '@angular/core';
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-candidate-list',
imports: [ScrollingModule],
styles: `
cdk-virtual-scroll-viewport { height: 640px; }
.row { height: 56px; display: flex; align-items: center; }
`,
template: `
<cdk-virtual-scroll-viewport itemSize="56" minBufferPx="400" maxBufferPx="800">
<div
class="row"
*cdkVirtualFor="let c of candidates(); trackBy: trackId; let i = index"
>
{{ i + 1 }}. {{ c.name }} ({{ c.experienceYears }}y, {{ c.city }})
</div>
</cdk-virtual-scroll-viewport>
`,
})
export class CandidateListComponent {
candidates = input.required<Candidate[]>();
trackId = (_: number, c: Candidate) => c.id;
}
Q57How does Angular prevent XSS, and when does bypassSecurityTrustHtml become a vulnerability you shipped?
AdvancedSecurity
Answer
Angular's core defence is contextual auto-sanitization: every value entering the DOM through interpolation or property binding is treated as untrusted and sanitized according to its sink context, HTML, style, URL, or resource URL. Interpolated text is always safe (rendered as text, never parsed). Binding [innerHTML]="userContent" does not disable protection; DomSanitizer strips script tags, event-handler attributes (onerror, onclick), javascript: URLs and similar vectors, logging a console warning when it sanitizes. [href] and [src] go through URL sanitization.
Resource URL contexts, iframe src, script src, are strictest: no sanitization is even attempted; binding them with arbitrary values is a build/runtime error unless the value is explicitly trusted. That is where DomSanitizer.bypassSecurityTrustHtml / bypassSecurityTrustUrl / bypassSecurityTrustResourceUrl / bypassSecurityTrustScript / bypassSecurityTrustStyle enter: they wrap a value as pre-trusted, telling Angular 'I vouch for this'. Every call is a security decision you now own; the vulnerability pattern is depressingly consistent in audits: bypassSecurityTrustHtml applied to content that includes anything user-influenced, rich-text job descriptions, chat messages, CMS content edited by non-engineers, URL parameters interpolated into embed URLs.
Correct usage is narrow: static, developer-authored content (an inline SVG asset), or content already sanitized server-side by a real HTML sanitizer with an allowlist (DOMPurify server- or client-side before trusting). Best practices to name: treat any bypassSecurityTrust* in a diff as a mandatory security review trigger (grep for it in CI), never concatenate user input into trusted values, prefer rendering rich text through a vetted sanitizer pipeline instead of trusting raw backend HTML, layer CSP as the outer defence (Angular supports Trusted Types with policies like angular and angular#unsafe-bypass, which make bypass attempts enforceable at the browser level), and remember SSR adds server-context injection concerns on top. Angular makes XSS opt-in; bypass APIs are the opt-in switch.
import { Component, inject, input, computed } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import DOMPurify from 'dompurify';
@Component({
selector: 'app-job-description',
template: `<article [innerHTML]="safeDescription()"></article>`,
})
export class JobDescriptionComponent {
private sanitizer = inject(DomSanitizer);
rawHtml = input.required<string>(); // recruiter-authored rich text
safeDescription = computed<SafeHtml>(() => {
// sanitize with an allowlist FIRST; only then mark as trusted
const clean = DOMPurify.sanitize(this.rawHtml(), {
ALLOWED_TAGS: ['p', 'ul', 'ol', 'li', 'b', 'strong', 'em', 'br', 'a'],
ALLOWED_ATTR: ['href'],
});
return this.sanitizer.bypassSecurityTrustHtml(clean);
});
}
// Anti-pattern to flag in review:
// bypassSecurityTrustHtml(this.rawHtml()) // trusting unsanitized user input
Q58Walk through diagnosing and fixing an oversized Angular bundle: tools, common causes, and the order you attack them.
AdvancedPerformance
Answer
Start with evidence, not guesses. ng build prints per-chunk sizes: confirm which budget fired and whether the problem is the initial bundle, a lazy chunk, or styles. Then ng build --stats-json and feed dist/stats.json to esbuild-aware analyzers (source-map-explorer against the emitted source maps also works and is bundler-agnostic): you get a treemap showing exactly which node_modules and source files occupy the initial chunk. The usual suspects, in the order they show up in real audits: a heavyweight library imported eagerly for trivial use, moment with all locales, an entire charting suite for one sparkline, lodash imported wholesale instead of lodash-es per-function; fix by replacing (date-fns/dayjs), importing granularly, or pushing the consumer behind @defer or a lazy route.
Second: broken lazy boundaries, a static import from an eagerly loaded file into a 'lazy' feature hoists it into main; the treemap makes this obvious when admin code sits in the initial chunk, and the fix is cutting the static edge (move shared bits to a shared module boundary). Third: barrel files (index.ts re-exporting everything) that defeat tree-shaking by creating import graphs where consuming one symbol pulls the whole feature; import from concrete files. Fourth: duplicated dependencies from version skew (two rxjs copies via mismatched peer deps, check with npm ls rxjs).
Fifth: styles, a global SCSS importing a whole design system into every component stylesheet via shared imports; anyComponentStyle budget catches it. Also verify the basics: production configuration actually used, provideAnimationsAsync instead of eager animations, NgOptimizedImage handling images, compression (brotli) at the CDN, this is delivery, not bundling, but it is what users feel. Then lock the win in: tighten budgets in angular.json to just above the new size so CI fails on regression, and add source-map-explorer to the pipeline for visibility. That closing loop, measure, fix, ratchet the gate, is the answer structure interviewers reward, because it is a repeatable process rather than a bag of tricks.
# 1. See what the budgets see
ng build
# 2. Produce analyzable output
ng build --stats-json
npx source-map-explorer dist/goodspace-web/browser/*.js
# 3. Find duplicate packages
npm ls rxjs
npm ls @angular/core
# 4. Typical fixes
# - import { debounce } from 'lodash-es' (not 'lodash')
# - charting component behind @defer (on viewport)
# - kill barrel imports: import from './job-card.component'
# 5. Ratchet the regression gate (angular.json)
# { "type": "initial", "maximumWarning": "420kB", "maximumError": "500kB" }
Q59How do you run a major-version Angular upgrade on a large codebase, and what do ng update migrations actually automate?
AdvancedTooling
Answer
Angular ships a major every six months, supports each with roughly six months of active development plus twelve of LTS, and the upgrade path is deliberately mechanical: never skip majors, go 16 to 17 to 18 sequentially, because migrations are written per version. The workflow: read update.angular.io (the Angular Update Guide) for your exact from/to pair, ensure a green test suite first (upgrading without a safety net is how upgrades stall for quarters), then ng update @angular/core@<next> @angular/cli@<next>, which updates dependencies, verifies peer ranges, and runs migration schematics that rewrite your code. Those schematics are the underrated part: across recent majors they have auto-converted *ngIf/*ngFor templates to @if/@for (ng generate @angular/core:control-flow), migrated codebases to standalone (ng generate @angular/core:standalone), rewritten constructor injection to inject() (ng generate @angular/core:inject), converted decorator inputs to signal inputs (signal-input-migration), queries to signal queries (signal-queries-migration), and outputs to output() (output-migration), meaning much modernisation is a command, not a rewrite.
After core: ng update @angular/material and other Angular-ecosystem deps, then third-party libraries against their compatibility tables (the classic blocker: an abandoned library pinning an old Angular peer range; options are ngcc-era forks, patch-package, replacing the library, or, pragmatically, npm overrides while you migrate off it). TypeScript and Node version floors move with Angular majors, so CI images and local toolchains update in the same PR. Deprecations removed after two majors get flagged by the compiler; fix warnings in the release you meet them, not the release that deletes the API.
For a genuinely large monorepo, land the upgrade as a short-lived branch executed in days, not weeks, feature development racing an upgrade branch is where merge hell comes from. And validate like production: full E2E run, bundle-size diff (regressions hide in dependency bumps), and a canary deploy. Teams that treat the six-month cadence as routine hygiene spend hours per upgrade; teams that batch three majors spend weeks, that operational observation is exactly what a senior interview wants to hear.
# never skip majors: 17 -> 18 -> 19, one at a time
npx ng update # lists what can update from here
ng update @angular/core@18 @angular/cli@18
# ecosystem next
ng update @angular/material@18
# optional modernisation schematics (run when ready, not forced)
ng generate @angular/core:control-flow
ng generate @angular/core:standalone
ng generate @angular/core:inject
ng generate @angular/core:signal-input-migration
# verify
npm test && npm run e2e
ng build --stats-json # diff bundle sizes against the previous build
Q60A dashboard with 400+ components has visible input lag. Walk through your change detection optimisation strategy end to end.
AdvancedPerformance
Answer
Structure the answer as measure, contain, migrate. Measure first: Angular DevTools' profiler records change detection cycles, showing per-component check time and what triggered each cycle; Chrome's performance panel correlates them with long tasks over 50ms. Typical findings on such dashboards: every keystroke or WebSocket tick triggers app-wide CD (zone-based, default strategy everywhere), a few components are individually expensive (template functions doing formatting or filtering on every check, methods called in templates are executed every cycle, which is why they are banned in favour of pipes or computed signals), and high-frequency events fire uncoalesced.
Contain: switch components to ChangeDetectionStrategy.OnPush top-down, starting at layout boundaries, so checks skip clean subtrees; replace template method calls with pure pipes or precomputed fields; ensure immutable data flow so OnPush actually skips; add trackBy/track everywhere lists rerender; wrap third-party high-frequency sources in runOutsideAngular and re-enter deliberately; enable event coalescing (provideZoneChangeDetection({ eventCoalescing: true })) so a burst of events yields one pass; and consider ChangeDetectorRef.detach() plus manual detectChanges() for the truly extreme cases like a ticking grid, reattaching on visibility. Structural relief: put heavy below-the-fold widgets behind @defer (on viewport), virtualise long lists with CDK virtual scrolling, and paginate data streams so the DOM holds what users see. Migrate: move component state to signals so updates mark precisely the views that read them, then adopt zoneless (provideZonelessChangeDetection) once OnPush-plus-signals is the norm; at that point a WebSocket tick updates exactly the price cell that changed instead of scheduling tree-wide checks, and the input-lag class of bug structurally disappears. Close with the regression guard: profile again to quantify (cycles per interaction, ms per cycle), keep a performance budget in CI, and enforce OnPush plus no-template-method-calls via lint rules so the fix survives the next quarter of feature work.
Key Points
- Profile with Angular DevTools before touching code: find triggers and hot components
- OnPush + immutable flow + track + pure pipes contain the blast radius
- runOutsideAngular + event coalescing tame high-frequency sources
- @defer + CDK virtual scroll cut DOM volume
- Signals then zoneless make precise updates structural, not heroic
- Lint rules + CI perf budgets keep it fixed
Frequently Asked Questions
What salary can an Angular developer expect in India in 2026?
The broad band is ₹6-20 LPA. Freshers at service companies (TCS, Infosys, Cognizant, Capgemini) start around ₹3.5-6 LPA, and Angular-heavy projects there are plentiful. With 3-5 years and solid RxJS plus modern-Angular depth (signals, standalone, SSR), product companies and GCCs pay ₹12-20 LPA. Banking and fintech GCCs, Deutsche Bank, JP Morgan, Wells Fargo, UBS in Pune, Bengaluru and Hyderabad, are the strongest Angular payers, and senior or lead roles there cross ₹25-35 LPA. Full-stack pairing (Angular plus Java/Spring or .NET) widens options materially, since most enterprise Angular roles in India are advertised as full-stack.
How long does it take to prepare for an Angular interview?
If you already work with Angular daily, two to three weeks of focused preparation is realistic: one week consolidating the modern layer many production codebases lag on (signals, @if/@for blocks, standalone patterns, zoneless direction), one week on RxJS operator scenarios and change detection internals, and a few days on testing patterns with TestBed and HttpTestingController, since testing rounds filter hard. Coming from React or Vue with strong JavaScript, plan six to eight weeks including building one non-trivial app with routing, typed forms, interceptors and SSR. The differentiator in interviews is explaining why things behave as they do, why OnPush skips a check, why switchMap beats mergeMap for search, not reciting API lists.
What do interviewers expect from freshers vs experienced Angular developers?
Freshers are tested on TypeScript fundamentals, component communication (inputs, outputs, services), template syntax including the new control flow, basic routing and forms, and enough RxJS to explain an Observable and the async pipe; a small deployed project with clean structure beats certificates. At 3-5 years, expect change detection mechanics, OnPush reasoning, operator-choice scenarios, interceptor and guard design, typed reactive forms, and at least conversational depth on signals and SSR, plus a live-coding round building a feature with API integration. Above 5 years, the loop shifts to architecture: state management trade-offs, performance debugging narratives, migration strategy for legacy NgModule codebases, micro frontend trade-offs, and how you enforce standards across a team.
Is Angular still worth learning in 2026 given React's popularity?
Yes, with clear eyes about where the demand sits. React dominates Indian startup job volume, but Angular holds a durable, well-paying enclave: banking, insurance, healthcare, ERP and government-adjacent systems, exactly the domains where India's GCCs concentrate, and those codebases have decade-long lifespans with steady hiring. Competition per opening is also thinner, because bootcamp pipelines overwhelmingly produce React developers. The framework itself is in its strongest technical shape in years: signals, standalone components, esbuild tooling, SSR with incremental hydration. The pragmatic play is Angular as your paid specialty with enough React literacy to stay mobile; the underlying skills (TypeScript, reactive programming, component architecture) transfer either way.
Angular vs React vs Vue: how should I position myself for the Indian market?
Think in terms of employer types rather than framework rankings. React maximises startup and product-company optionality (Flipkart, Swiggy, Zomato, CRED and most funded startups). Angular maximises enterprise and GCC value: Deutsche Bank, JP Morgan, PayPal, SAP and the big services firms run enormous Angular estates, pay well for depth, and struggle to find candidates current on signals-era Angular. Vue is the smallest Indian market of the three. If you are already in the services ecosystem, deep Angular plus Java or .NET is the highest-probability path to a GCC offer at ₹15-25 LPA. Whichever you pick, TypeScript mastery and RxJS-style reactive thinking are the transferable core interviewers actually probe.
How much RxJS do I really need now that Angular has signals?
More than the hype suggests, less than before. You still need genuine fluency in the core: Observables vs Subjects, cold vs hot, the flattening operators (switchMap, mergeMap, concatMap, exhaustMap) with scenario judgment, debounceTime and distinctUntilChanged, catchError placement, and cleanup via takeUntilDestroyed, because HttpClient, router events, valueChanges and most existing codebases speak RxJS, and interviewers test it heavily. What you can skip is the exotic tail (custom operators, complex multicasting variants, marble-test trivia) unless the role demands it. Equally important now is the bridge layer: toSignal and toObservable, and articulating the division of labour, signals for state, RxJS for async event orchestration. Candidates who dismiss either side read as dated.
Introduction
Angular in 2026 is a very different framework from the one most tutorials still describe. Standalone components are the default, NgModules are legacy, signals drive reactivity alongside RxJS, the build pipeline runs on esbuild and Vite, and zone.js is optional rather than mandatory. The template syntax itself changed: @if, @for and @defer replaced the old structural directives in new code. If your mental model stopped at Angular 12, an interviewer will notice within the first ten minutes. The good news is that the modern framework is smaller, faster, and far more pleasant to reason about, and the companies still betting on Angular tend to be the ones paying for depth, not buzzword familiarity.
Indian hiring for Angular concentrates in banking and enterprise: Deutsche Bank, JP Morgan and PayPal run large Angular estates out of their India engineering centres, while TCS, Infosys, Cognizant and Accenture staff hundreds of Angular projects for global clients. Interview loops at these companies probe change detection mechanics, OnPush and zoneless strategies, signals versus RxJS trade-offs, typed reactive forms, SSR with hydration, and testing with TestBed and HttpTestingController. Product companies add system-level rounds: how you would keep a 500-component dashboard responsive, or how you would migrate a decade-old NgModule codebase without freezing feature work for a quarter.
This guide contains 60 Angular interview questions ordered from basic through intermediate to advanced, written against the framework as it actually ships in 2026. Every answer explains real behaviour, names the exact APIs and error codes involved, and flags the production gotchas interviewers use to separate candidates who have shipped Angular from candidates who have read about it. Work through the basic set to make sure the modern syntax is reflexive, then spend most of your preparation time on the intermediate section: change detection, RxJS operator choice, interceptors, guards and deferred loading decide the majority of offers between ₹8 and ₹20 LPA.
Ready to practice Angular interviews?
Don't just read, practice these Angular questions live with an AI interviewer that asks follow-ups and scores your answers.