Angular interviews for 3 years of experience ask less for definitions and more for what you did, such as an error you traced, a form or route that misbehaved, how you test components and services, how you debug and deploy, and what code review taught you. This page is written for Angular developers with about two to four years of real work, the stage where you own features end to end but someone else still designs the app. Each question shows what the interviewer is checking, the shape of a good answer and a short spoken answer. Swap the stories for your own before the interview.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Read it: the message gives the old and new value; find the binding that shows them.
Find the write: which code changed that value during the check, often a child hook pushing state up.
Fix the flow: give the value one owner so nothing updates it mid-render, instead of hiding it with setTimeout.
“On an edit page, the Save button in the header was bound to a formValid flag in the parent. The form itself lived in a child component, and that child emitted its valid status to the parent from ngOnInit. The button sits above the child in the template, so Angular had already checked it with the old value when the child changed it. In development mode Angular checks twice and caught the difference, and the error showed false becoming true. My first instinct was a setTimeout, but a reviewer pointed out that just hides it. The real fix was to let the parent own the FormGroup and pass it down, so the button reads form.invalid directly and there's one source of truth. The error went away and the code got simpler too.”
Saying you fixed it with setTimeout or detectChanges without being able to explain what changed the value during the check.
Hook: a class that implements ErrorHandler, provided in place of the default one.
Context: send route, app version and a user id, not just the message.
Make it usable: private source maps for readable traces, and dedupe so one loop does not flood the log.
“Angular sends uncaught errors to its ErrorHandler, which by default just logs to the console. I wrote our own class that implements ErrorHandler and provided it instead. It still logs to the console, but it also sends the error to our logging endpoint with the current route, the app version and the user id, so we can tell who hit it and on which build. HTTP errors that a component already handles don't reach it, which is fine, those are expected. Two lessons from doing it. Production stack traces are minified, so we upload source maps privately to the logging tool instead of serving them publicly. And one error in a loop sent thousands of reports, so now it drops repeats of the same message within a short window.”
@Injectable()
export class AppErrorHandler implements ErrorHandler {
private log = inject(ErrorLogService);
handleError(error: unknown) {
console.error(error);
this.log.report(error); // adds route, version, user id; drops repeats
}
}
// app config
providers: [{ provide: ErrorHandler, useClass: AppErrorHandler }]
Wrapping every method in try and catch, or shipping public source maps without thinking about it.
Cause: new Date on a date-only string gives midnight UTC, which is shown in the browser's local time.
Who sees it: users west of UTC get the previous day; sending back with toISOString can shift the other way.
Fix: treat date-only values as plain dates, not instants, both when showing and when saving.
“Our service mapped every response with new Date on the due date. A string like 2024-03-10 with no time is read by JavaScript as midnight UTC, and the date pipe then formats that moment in the browser's time zone. For anyone behind UTC, midnight UTC is still the evening of the ninth, which is why only some users saw it. Saving had the reverse bug: we built a Date at local midnight and sent it with toISOString, which converts to UTC and can land on the previous day for users ahead of UTC. The fix was agreeing that a due date is a date, not a moment in time. We keep it as the plain string end to end, and when we need a Date for a date picker we build it from the year, month and day parts, which gives local midnight.”
// new Date('2024-03-10') is midnight UTC, not local midnight
function toLocalDate(value: string): Date {
const [y, m, d] = value.split('-').map(Number);
return new Date(y, m - 1, d);
}
Adding or subtracting a day to make it look right on your own machine.
Angular DevTools: component tree, current inputs and state, and the profiler for change detection.
Browser tools: breakpoints in TypeScript through source maps, network tab, console.
Dev-mode helpers: ng.getComponent on a selected element to look at a live component.
“Most days it's the browser devtools plus the Angular DevTools extension. In the component tree I can click a component and see its current inputs and properties, which answers most "why is this showing that" questions quickly. When a page feels slow, I use the profiler tab to see how many change detection runs happen and which components take the time. For logic bugs I set breakpoints in the Sources panel. Source maps mean I'm stepping through my TypeScript, not the built code. The network tab tells me whether the bug is in the request, the response or my code. And in development mode I sometimes select an element and call ng.getComponent on it in the console to look at the live component. I still use console.log, but only for quick checks.”
Relying only on console.log and never having used breakpoints or the component inspector.
Cause: same route, new param, so the router keeps the component and ngOnInit does not run again.
Bug: the id was read once from the route snapshot.
Fix: react to paramMap with switchMap, or bind the param to a component input.
“The router sees the same route with a different parameter, so it reuses the existing component instead of creating a new one. That means ngOnInit doesn't run again. Our code read the id once from route.snapshot.paramMap in ngOnInit, so the URL changed but nothing reloaded. I changed it to listen to route.paramMap and switchMap into the API call, so every new id fetches its product and a slow earlier request gets cancelled instead of overwriting the newer one. Another clean option is turning on component input binding in the router, so the id arrives as an input and updates when the URL changes. While I was in there I also reset the scroll position and the selected tab, because those would have stayed from product 1 as well.”
private route = inject(ActivatedRoute);
private api = inject(ProductApi);
product$ = this.route.paramMap.pipe(
map(params => params.get('id')!),
switchMap(id => this.api.getProduct(id))
);
Suggesting a full page reload or forcing the router to recreate the component instead of reacting to the parameter.
Write: on filter change, navigate to the same route with new queryParams, merging the rest.
Read: build the list request from queryParamMap, so refresh, back and shared links all work.
Details: replaceUrl while typing, parse numbers and defaults, reset the page when a filter changes.
“I made the URL the single place the filters live. When a user changes a filter, I navigate to the same route with the new query params and merge them with the ones already there. The list doesn't listen to the form directly. It listens to queryParamMap, parses the values with defaults, and fetches from there. That way refresh, back and a link pasted in chat all show the same list. Two details bit me. Typing in the search box was adding a history entry per keystroke, so I used replaceUrl for text search and debounced it. And when someone changed a filter on page five, they got an empty page, so any filter change now resets page to one.”
applyFilters(status: string, page = 1) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { status: status || null, page },
queryParamsHandling: 'merge',
});
}
orders$ = this.route.queryParamMap.pipe(
map(p => ({ status: p.get('status') ?? '', page: Number(p.get('page') ?? 1) })),
switchMap(f => this.api.getOrders(f))
);
Keeping the filters only in a service or local storage, so a shared link or the back button still shows the wrong list.
Cause: each async pipe is its own subscription, and each subscription to an HttpClient observable sends a request.
Simplest fix: subscribe once in the template with @if and an alias, then use that value.
Shared stream: shareReplay with bufferSize 1 and refCount, or turn it into a signal once.
“HttpClient observables are cold. Nothing happens until someone subscribes, and every subscriber gets its own request. Our template used the async pipe on the same order stream in the header, in the summary and in a child input, so three subscriptions meant three calls. The quickest fix was to subscribe once at the top with @if and an alias called order, and use that everywhere below. Where a stream really had to be shared across components, I added shareReplay with bufferSize one and refCount true, so late subscribers get the last value without a new request. Converting it once with toSignal works too. I also check the network tab on every page I build now, because this kind of duplication doesn't show up any other way.”
order$ = this.route.paramMap.pipe(
switchMap(p => this.api.getOrder(p.get('id')!)),
shareReplay({ bufferSize: 1, refCount: true })
);
Blaming Angular or the backend, or adding a cache in the service without understanding why three subscriptions meant three requests.
Cause: root services live for the whole app, so cached subjects, signals and HTTP caches survive logout.
Treat it as serious: one user seeing another's data is a privacy bug, not a cosmetic flash.
Fix: one logout path that clears every store, or a full page reload on logout; then test the swap.
“In a single-page app, logging out doesn't reload anything. Every service provided in root keeps living, so a BehaviorSubject holding the last loaded orders, or a small cache in an interceptor, still has the first user's data when the second one lands on the page. It shows until the new request comes back. I'd treat this as a privacy bug and fix it today, not as a flicker. The quick, reliable fix is to do a full page load on logout, which throws away all in-memory state. The cleaner fix, which we did next, was one logout function that tells every store to reset, and keying caches by user id so stale data can never match. Then I added a test that logs in as one user, logs out, logs in as another and checks nothing leaks.”
Calling it a cosmetic flicker and hiding it with a loading spinner instead of clearing the state.
setValue vs patchValue: setValue needs every control and throws if one is missing; patchValue sets whatever matches.
Side effects: patching fires valueChanges, so a listener like "country changed, clear city" runs on load too.
Fix: patch with emitEvent false, or order the load so dependent lists are ready first.
“We had an address form where changing the country clears the city and loads a new city list. When I loaded a saved address with patchValue, the country change fired valueChanges, and the listener cleared the city I had just set. So the form opened with an empty city. I changed the load to patchValue with emitEvent false, then loaded the city list for that country myself. I use patchValue for loading because the API object often has extra or missing fields, while setValue throws if a control is missing, which I sometimes use on purpose when I want the shape checked. One thing I didn't need was markAsPristine. Setting values from code doesn't make a form dirty, only user input does.”
this.form.patchValue(address, { emitEvent: false });
this.loadCities(address.country);
Fixing it with a boolean like isLoading checked inside every listener, or not knowing that setting values from code fires valueChanges.
Cause: form.value skips disabled controls; getRawValue includes them.
Decide: should the server get this field at all, or is it display only?
Disable properly: call disable() or create the control disabled, not the HTML disabled attribute.
“In a reactive form, disabled controls are left out of form.value. We had a customer code field that was disabled once the record existed, and the update call stopped sending it, which the API rejected. getRawValue returns everything, disabled controls included, so that's what I used for the save. But I also asked whether the field should go to the server at all. If the user can't change it, maybe it belongs in the URL or the record id, not the form body. The other thing I cleaned up was how it was disabled. Someone had put the disabled attribute on the input next to formControlName, and Angular warns about that in the console. The right way is control.disable() or creating the control disabled from the start.”
code = new FormControl({ value: 'C-104', disabled: true });
save() {
const body = this.form.getRawValue(); // includes disabled controls
this.api.update(this.id, body).subscribe();
}
Using the disabled attribute in the template on a reactive control, or copying the value in by hand without knowing why it was missing.
Shape: a FormGroup with a FormArray of small groups, one per line.
Add and remove: push a new group, removeAt by index; buttons get type button so they do not submit.
Load: clear the array and push one group per saved line, because patchValue will not add rows.
“I keep a FormGroup with a FormArray called items, and a small method that builds one line group with its validators. Add pushes a new group, remove calls removeAt with the index. In the template I use formArrayName and loop over the controls with formGroupName set to the index. The Add and Remove buttons need type button, or they submit the form, which I learned the hard way. The trap is editing. When I first loaded a saved invoice with three lines into a form that had one row, patchValue filled the first row and quietly ignored the other two. It doesn't create controls. So to load, I clear the array, push one group per saved line, and then patch the rest of the form.”
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
customer: ['', Validators.required],
items: this.fb.array([this.newItem()]),
});
get items() { return this.form.controls.items; }
newItem(product = '', qty = 1) {
return this.fb.group({
product: [product, Validators.required],
qty: [qty, [Validators.required, Validators.min(1)]],
});
}
addItem() { this.items.push(this.newItem()); }
removeItem(i: number) { this.items.removeAt(i); }
load(inv: Invoice) {
this.items.clear();
inv.items.forEach(l => this.items.push(this.newItem(l.product, l.qty)));
this.form.patchValue({ customer: inv.customer });
}
Expecting patchValue or setValue to create the missing rows, or not knowing why a button inside the form submits it.
Timing: view queries are ready in ngAfterViewInit, not ngOnInit or the constructor.
Conditional: inside @if the element does not exist until the condition is true and the view updates.
Fix: react when the query changes, with a ViewChild setter or the viewChild signal query.
“Two things were going on. First, view queries aren't filled in until the view is created, so reading them in ngOnInit gives undefined. They're ready in ngAfterViewInit. Second, our input lived inside an @if, so even in ngAfterViewInit it was undefined while the panel was closed. When the user opened the panel, the code set the flag and tried to focus straight away, but the input only exists after Angular renders that change. The fix I used was a setter on @ViewChild, which runs whenever the element appears, and I focus it there. With newer Angular I'd use the viewChild signal query, which updates when the element shows up. static true doesn't help here, because it only works for elements that aren't inside a conditional block.”
@ViewChild('nameInput') set nameInput(el: ElementRef<HTMLInputElement> | undefined) {
el?.nativeElement.focus();
}
Wrapping the focus call in a setTimeout until it works, without knowing when the element actually exists.
Additive change: a new optional input with a default that keeps today's behaviour.
Check the uses: search every place it is used, run their tests, look at a few pages by hand.
Tell people: a short note to the owners, and never a check for one page name inside the shared component.
“First I'd look for a way to make the change additive. If my page needs sortable columns, I add an optional sortable input that defaults to false, so every existing page behaves exactly as today. I'd avoid anything like checking which page it's on inside the shared component, because that turns it into a pile of special cases. Then I search every place the component is used and run the tests for those areas, and I click through a few of the busiest pages myself. I'd also add a test in the shared component for the old behaviour, so my change can't quietly alter it. Before merging, I'd post a short note in the team channel saying what changed and that the default is unchanged, and tag whoever owns it.”
Changing the default behaviour because it suits your page, or copying the component to make your own version without telling anyone.
Replace the service: provide a fake with useValue that returns of(...) with test data.
Render: create the component and call detectChanges, which runs ngOnInit and the template.
Check output: query the DOM for what the user would see, not private fields.
“I don't want the real service or the network in a component test, so I provide a fake for the service with useValue, returning an observable of a couple of test orders. Then I create the component with TestBed and call fixture.detectChanges, which runs ngOnInit and renders the template. After that I check the DOM, for example that there are two rows and the first shows the right total, because that's what the user sees. I also add a test for the empty case and the error case, since those are where bugs hide. One thing that caught me once: if the component lists the service in its own providers array, the TestBed provider is ignored, and I have to use overrideComponent instead.”
it('shows one row per order', () => {
const fakeService = {
getOrders: () => of([{ id: 1, total: 20 }, { id: 2, total: 35 }]),
};
TestBed.configureTestingModule({
imports: [OrderListComponent],
providers: [{ provide: OrderService, useValue: fakeService }],
});
const fixture = TestBed.createComponent(OrderListComponent);
fixture.detectChanges();
const rows = fixture.nativeElement.querySelectorAll('tr.order-row');
expect(rows.length).toBe(2);
});
Testing only private fields and method calls, or letting component tests hit the real backend.
Setup: provideHttpClient then provideHttpClientTesting, and inject HttpTestingController.
Expect and answer: expectOne the URL, check method or body, flush data or an error status.
Verify: call verify after each test so an extra or missing request fails it.
“I set up TestBed with provideHttpClient and then provideHttpClientTesting, in that order, and inject both the service and the HttpTestingController. In the test I call the service method and subscribe, then use expectOne with the URL. That fails if the request wasn't made, or was made twice. I check the method and, for a post, the body. Then I flush fake data and assert what the subscriber got. For errors I flush with a status like 500 and check that the service maps it to the message or fallback we expect. In afterEach I call verify, which fails the test if any request was left unanswered. That's caught real bugs for me, like a service that quietly fired a second request.”
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(OrderService);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('loads orders for a customer', () => {
let result: Order[] | undefined;
service.getOrders(7).subscribe(o => (result = o));
const req = http.expectOne('/api/customers/7/orders');
expect(req.request.method).toBe('GET');
req.flush([{ id: 1, total: 20 }]);
expect(result?.length).toBe(1);
});
Mocking HttpClient by hand with a fake get method, so the test never checks the URL, the method or the error path.
Fake time: wrap the test in fakeAsync so timers only move when you call tick.
Prove the wait: type, tick just under the delay, expect no call; tick the rest, expect exactly one.
Limits: fakeAsync needs zone.js in the test setup, and leftover timers fail the test.
“I wrap the test in fakeAsync, so timers don't run on their own and only move forward when I call tick. I provide a fake API that records the terms it was called with. Then I type into the input and dispatch an input event, type again, and tick 299 milliseconds. At that point I expect no calls. One more tick and I expect exactly one call, with the last term. That proves both the debounce and that the first keystroke got dropped. Two things to know. If a timer is still pending when the test ends, fakeAsync fails it, so I use flush or make sure everything settles. And fakeAsync depends on zone.js, so in a project without zone.js in the tests I'd use the test runner's fake timers instead.”
it('searches once after typing stops', fakeAsync(() => {
const calls: string[] = [];
const fakeApi = { search: (t: string) => { calls.push(t); return of([]); } };
TestBed.configureTestingModule({
imports: [SearchComponent],
providers: [{ provide: SearchApi, useValue: fakeApi }],
});
const fixture = TestBed.createComponent(SearchComponent);
fixture.detectChanges();
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
input.value = 'ang'; input.dispatchEvent(new Event('input'));
input.value = 'angular'; input.dispatchEvent(new Event('input'));
tick(299);
expect(calls).toEqual([]);
tick(1);
expect(calls).toEqual(['angular']);
}));
Making the test wait for real time, or only checking that the API was called without proving the delay.
Cause: index.html has base href set to the site root, so it asks for scripts at /main.js, not /portal/main.js.
Fix: build with the right base href, from the command line or the build config.
Assets: use relative paths, because a path starting with a slash ignores the base href.
“The first thing I'd open is the built index.html and look at the base tag. By default it's a single slash, so the browser asks for main.js at the site root, and under /portal/ that's a 404, which gives you a blank page. The fix is building with base href set to /portal/, either with the flag on ng build or in the build configuration so nobody forgets it. The router also uses the base href, so links become /portal/orders on their own. Then I'd check images and icons. We had a few written as /assets/logo.png, and a leading slash goes to the domain root and ignores the base href, so I changed them to relative paths.”
ng build --base-href /portal/
Guessing at server settings without first opening the built index.html and the failing request URLs.
Measure: build with stats and open them in a bundle analyzer to see what is in the initial chunk.
Usual causes: a whole library imported for one function, or an eager import pulling a lazy feature into main.
Fix and guard: import only what you need, move heavy code behind a lazy route or @defer, keep the budget.
“The budget warning was the good part, it caught it before users complained. I built with stats turned on and opened the output in a bundle analyzer, which shows each package by size inside each chunk. Two things stood out. I'd imported a date helper from a big utility library as a default import, which pulled in the whole library instead of one function. And a chart component I'd added to a shared barrel file meant the dashboard's chart library was now in the initial bundle, even though only one lazy page used it. I switched to importing the single function, stopped exporting the chart from the shared barrel, and wrapped the chart in @defer. The main bundle went back under budget, and I added the analyzer check to my own routine for new libraries.”
Raising the budget limit so the warning goes away, or guessing at causes without looking at what is in the bundle.
Build time: environment files swapped by fileReplacements per build configuration.
Public: everything in the bundle can be read by anyone, so no secrets there.
Runtime option: load a small config file at startup when one build must go to many environments.
“We keep an environment file per setup with things like the API base URL and feature flags, and the build configuration swaps them in with fileReplacements. So ng build with the production configuration bakes in the production file. The rule I stick to is that nothing secret goes there. Whatever's in an environment file ends up in the JavaScript anyone can open in the browser. Keys that are meant to be public, like a maps key locked to our domain, are fine. Anything else stays on the server, and the frontend calls our backend instead. On one project, ops wanted to build once and deploy the same files to staging and production, so we switched to loading a config.json at app startup and reading the URL from there.”
Putting a private API key or a password in an environment file because it is not committed as plain text in the component.
The comment: what the reviewer flagged, in one line.
The why: what you learned about how Angular works.
The habit: what you do differently now, with a small example.
“Early on I had a table where each row called methods from the template, like isOverdue(order) for a CSS class and getTotal(order) in a cell. A senior dev left a comment asking how often I thought those ran. I guessed once per row. The real answer is on every change detection pass, so every click or keystroke anywhere on the page ran them again for every row. It wasn't slow yet, but getTotal looped over line items, so it would be with bigger orders. We moved that work out of the template: the total and the overdue flag were worked out once when the data arrived. In newer code I use computed signals or a pure pipe for this. Now when I write a function call in a template, I stop and ask how often it'll run.”
Describing the change without being able to say why it mattered, or getting defensive about the comment.
The ask: what the feature was for and what you clarified before coding.
Your decisions: component split, state, API contract, loading, empty and error states, tests.
Release and after: how it shipped, what broke or surprised you, what you would change.
“I owned a bulk import screen where support staff upload a spreadsheet of orders. Before coding, I asked what should happen with bad rows, and we agreed to show a preview with errors per row and only import the valid ones. I agreed the response shape with the backend developer early, so I could build against a mocked service while the API was being written. I split it into an upload component, a preview table and a results summary, with the state in one service. I made sure the loading, empty and partly-failed states all had designs. I wrote component tests for the preview and service tests for the upload. It shipped behind a feature flag to two users first, and they found that files with a header row in a different order broke it. Next time I'd ask for real sample files on day one.”
Only describing the code, with no word on clarifying the ask, testing or what happened after the release.
The miss: the task, your estimate and how long it really took.
What you missed: the specific parts, not just "it was harder".
Now: how you break work down and when you raise a slip.
“I estimated a date range filter for a reports page at two days. It took almost two weeks. The picker itself was quick. What I missed was everything around it. The dates had to survive in the URL, the backend expected a different time zone handling than I assumed, the empty state for a range with no data had no design, and the picker had keyboard problems that QA flagged. I also didn't say anything until day four, which made it worse. Now I break a task into the component, the state and the URL, the API contract, the states nobody designed, and tests, and I estimate each part. If the first unknown takes longer than expected, I tell my lead that day, not days later.”
Blaming the ticket or other people, or saying you still estimate the same way.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.