Angular interviews for 5 years of experience rarely ask what a pipe is; they ask why you picked NgRx or plain services, what broke when you turned on SSR or zoneless, how you traced a bug that only happened in production, and when you said no. This page is written for Angular developers with roughly five to seven years behind them. You own a feature area or a whole app, you choose how state, forms and data loading work inside it, you get the call when a release breaks, and you review other people's pull requests. Each answer below is a first-person story or a decision you can defend. Swap in your own project details before you say it.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Context: how much state was shared across features, how many developers, how hard bugs were to trace.
Choice: what you picked and the one or two reasons that decided it.
Cost: boilerplate, learning curve or lost tooling, and how you kept it under control.
“On our claims app most state was local to one screen, and only the signed-in user, permissions and a few reference lists were truly shared. So I went with small services per feature holding signals, with computed values for derived data, instead of a full NgRx store. It kept new joiners productive in days and cut a lot of action and reducer files. The cost showed up in debugging: without the store devtools we couldn't replay what happened before a bug. I made up for it by keeping every write behind a named method on the service and logging those calls in dev builds. If we'd had many features editing the same data at once, with undo or offline sync, I'd have picked NgRx and accepted the boilerplate.”
Saying one approach is always right, or picking NgRx for a small app without being able to say what problem it solved.
Apply first: write the change into the row state straight away and remember the previous value.
Roll back: on failure restore the old value only if nothing newer touched that row, and tell the user.
Ordering: send saves for one row one at a time, in order, or use version numbers so the server rejects stale writes.
“Users edited status and owner inline, and waiting for the server felt sluggish, so I wrote each change into the row state immediately and saved in the background. For failures I kept the previous row and a per-row edit counter. If a save failed and no newer edit had happened, I put the old row back and showed a message saying the change didn't save. If a newer edit existed, I left it alone. The nastier bug was two quick edits to one row: both requests raced, and if the first finished last, the server stored the older value. I grouped saves by row and used concatMap inside each group, so one row's saves go out in order while different rows still save in parallel. The trade-off is that a user can briefly see a change that later disappears, so I only did this for low-risk fields, never for anything that needs a firm confirmation.”
private saves = new Subject<RowEdit>();
constructor() {
this.saves.pipe(
groupBy(edit => edit.rowId),
mergeMap(row$ => row$.pipe(
concatMap(edit => this.api.saveRow(edit).pipe(
catchError(() => {
this.rollBack(edit); // restores only if no newer edit touched the row
return EMPTY;
})
))
)),
takeUntilDestroyed()
).subscribe();
}
Rolling back blindly and wiping a newer edit, or never telling the user that a save failed.
Line: RxJS for events over time (HTTP, debouncing, websockets), signals for state the template reads.
Crossing points: toSignal needs an injection context and a starting value; toObservable emits later through an effect, not the moment you set the signal.
Bug and fix: a concrete surprise and how you changed the code.
“In our search feature I kept typing, debouncing and HTTP calls in RxJS, then turned the result into a signal with toSignal for the template. Filters and selection were plain signals, with computed for derived values. The bug came at the crossing point. A colleague turned two filter signals into observables with toObservable and combined them with combineLatest to drive the search. toObservable emits through an effect, not the moment you set the signal, so when a clear-filters button reset both signals, the search fired twice, the first time with one filter cleared and the other still set. I merged the related filters into one signal holding an object, so a reset was one update and one search. I also replaced an effect that copied one signal into another with computed. Our rule became: effects for side effects only, never for syncing state.”
Converting everything back and forth without a rule, or using effect to keep one signal in sync with another.
What still triggers a refresh: signals read in templates, template event handlers, the async pipe, markForCheck.
What breaks: plain field changes inside setTimeout, subscribe callbacks or third-party callbacks.
Rollout: move components to OnPush and signals first, then remove zone.js and test the risky screens.
“Before touching the provider I made every component OnPush and fixed whatever broke, because an app that works with OnPush is most of the way to zoneless. Then I switched on zoneless change detection and removed zone.js from the polyfills. Three kinds of things stopped updating. A toast service changed a plain field inside setTimeout, so nothing told Angular to re-render. A map library fired callbacks that updated component fields directly. And some old code waited on NgZone.onStable, which never fires without zones. I turned those fields into signals, which schedule a render on their own, and removed the onStable waits. Our fakeAsync tests also needed rework, since they depend on zone.js. The payoff was a smaller bundle and far fewer wasted change detection passes.”
Thinking zoneless means nothing ever refreshes automatically, or flipping it on for a big app with no audit of setTimeout and third-party callbacks.
Profile: Angular DevTools profiler and the browser performance panel on an idle page.
Cause: a timer, animation loop or high-frequency listener running inside the zone, so every tick checks the whole app.
Fix: run it outside Angular and come back in only when data actually changes.
“I opened the page, left it idle and recorded with the Angular DevTools profiler. It showed change detection running dozens of times a second with nobody touching anything. The browser's performance panel pointed at two sources: a polling timer that ran every 200 milliseconds to check a job status, and a charting library that ran a requestAnimationFrame loop for its hover effects. Both ran inside the zone, so every tick made Angular check the entire app. I moved the polling and the chart setup into runOutsideAngular, slowed the poll to once a second, and only set a signal when the job status actually changed. Idle CPU dropped to almost nothing. The trade-off is that anyone editing that code must remember it lives outside the zone, so I left a clear comment.”
private zone = inject(NgZone);
readonly status = signal<JobStatus>('pending');
startPolling(jobId: string) {
this.zone.runOutsideAngular(() => {
this.timer = setInterval(async () => {
const next = await this.api.jobStatus(jobId);
if (next !== this.status()) this.status.set(next); // signal schedules the render
}, 1000);
});
}
Blaming the browser or the laptop, or making the whole page OnPush without finding what keeps triggering change detection.
Mismatch: code that renders differently on server and browser, like dates, random ids or localStorage reads.
DOM edits: libraries or directives that change the DOM directly behind Angular's back.
Fixes: run browser-only code after render, skip hydration for one stubborn component, and reuse the server's data.
“The errors pointed at a header and a chart. The header read the user's theme from localStorage behind a browser check, so the server always drew the light theme and the browser then drew dark. I moved that into afterNextRender, which only runs in the browser, and put the theme in a cookie so the server could render it right. The chart library built its own DOM inside our component, so the server output never matched. I put ngSkipHydration on that one host element and let it render fresh on the client. The flicker had a second cause: the page fetched the same data again after load. Using HttpClient's transfer cache from the hydration setup meant the browser reused what the server had fetched, so the content stopped jumping.”
Wrapping everything in a browser check or skipping hydration app-wide, which throws away the benefit of SSR.
Plan: run the standalone schematic step by step, one area at a time, each step its own pull request.
Manual work: shared modules, providers that lived in modules, and tests that declared modules.
Protect the team: short-lived branches, clear rules for new code, done before the next big feature.
“Our app had about forty NgModules, including a huge SharedModule every feature imported. I used the Angular standalone schematic, which works in steps: convert declarations to standalone, remove modules that are no longer needed, then switch to standalone bootstrap. I ran the first step one feature folder at a time, each as a small pull request that merged within a day, so nobody was stuck on a long branch. The schematic couldn't untangle SharedModule, so I split it by hand and made each component import only what it used. Providers that lived in modules moved into route providers or the app config, which I checked carefully because it changes where instances are created. From day one the rule was that new code is standalone. It took three sprints alongside normal work.”
Doing the whole migration in one giant pull request, or trusting the schematic without reviewing where providers ended up.
Find it: the network panel showed requests running one after another, each waiting for the last.
Restructure: start independent requests together and chain only the ones that truly need an earlier result.
What the user sees: show the page frame first, and give each section its own loading and error state.
“The account overview took about four seconds before anything showed. In the network panel the requests formed a staircase: a route resolver fetched the account, then the component fetched the plan, then invoices, then usage, each waiting for the one before, even though only usage needed the plan id. The resolver also kept the screen blank until the account came back. I dropped the resolver, rendered the page frame straight away, and started the account, plan and invoice requests at the same time as separate streams, each feeding its own section. Only usage waited on the plan. Each section got its own skeleton and error state, so a slow invoice call no longer held up the rest. First content came in under a second. The cost was more loading states to design and test, and the layout shifting as sections filled in, so I gave each skeleton a fixed height.”
Blaming the back end for slowness without looking at the order the front end makes its requests in.
Throttle the UI: batch messages and apply them at most a few times a second, not once per message.
Render less: OnPush or signals, tracked lists, and update only changed rows.
Reconnect: retry with growing delays, show a stale-data banner, resubscribe to channels after reconnect.
“The socket sent hundreds of price ticks a second, and at first each one updated the view, so the page stuttered. I collected ticks with bufferTime into batches every quarter second, merged them into a map by symbol so only the latest price per row survived, and wrote that into a signal. The table used track by symbol, so only changed rows re-rendered. For drops, I used RxJS retry with a delay that doubles each time up to a cap, plus a little random jitter, so a server restart didn't bring every client back at the same instant. While disconnected, a banner said prices might be stale, and after reconnecting the app sent its subscribe messages again. The trade-off is prices on screen can be a quarter second old, which the traders were fine with.”
readonly prices = signal(new Map<string, number>());
connect() {
webSocket<Tick>(this.url).pipe(
bufferTime(250),
filter(batch => batch.length > 0),
retry({
delay: (_err, attempt) =>
timer(Math.min(30000, 1000 * 2 ** attempt) + Math.random() * 1000),
resetOnSuccess: true
}),
takeUntilDestroyed(this.destroyRef)
).subscribe(batch => {
const next = new Map(this.prices());
for (const t of batch) next.set(t.symbol, t.price);
this.prices.set(next);
});
}
Updating the view on every message, or reconnecting in a tight loop with no delay.
Find the element: check which element is the LCP and what delays it.
Fixes: prerender or SSR the page, NgOptimizedImage with priority on the hero, defer heavy widgets below the fold.
Honest part: a change you expected to help that made little difference.
“The LCP element was the hero image, and on a slow phone it didn't start downloading until the whole app booted, because everything rendered on the client. Prerendering that route at build time made the biggest difference, since the HTML and image tag arrived straight away. Then I switched the hero to NgOptimizedImage with the priority attribute, which gives it high fetch priority and preloads it, and served properly sized versions for phones. The reviews carousel and a chat widget below the fold went into defer blocks so their code didn't compete for the first load. What didn't help much was shaving a little more off the main bundle. Once the page was prerendered, the bundle was no longer on the path to the LCP.”
Optimising at random without knowing which element is the LCP or what is delaying it.
Reproduce: throttle the network and click fast between two records.
Cause: the component is reused when only the id changes, and each id started its own request with nothing cancelling the old one.
Fix: derive the data from the route param with switchMap, and add a test for the race.
“I couldn't reproduce it on a fast network, so I throttled the connection and clicked between two customers quickly. That did it. The router reuses the same component when only the id in the URL changes, and our code subscribed to the param map and, inside that, started a fresh HTTP call for each id. Nothing cancelled the earlier call, so if customer one's response was slower, it landed after customer two's and overwrote the page. I rewrote the stream as param map piped into switchMap, which drops the old request when a new id arrives, and fed the result to the template with the async pipe. Then I wrote a test with HttpTestingController that switches between two ids, checks the first request was cancelled, and answers the second.”
Adding a delay or a loading flag that hides the race instead of cancelling the stale request.
Cause: users still running the old app ask for old lazy chunk files that the deploy deleted.
Hosting fix: never cache index.html, and keep the previous release's chunks around for a while.
App fix: catch the failed lazy load and reload once, or prompt for an update if a service worker is in use.
“Each build gives lazy chunks new hashed names. Someone who opened the app before the release still has the old main bundle, and when they navigate to a lazy route it asks for an old chunk file that the deploy had already removed. On the hosting side I set index.html to no-cache and changed the deploy so the previous release's chunks stay for a few days. In the app I added a global error handler that spots a failed dynamic import and reloads the page once, using a session flag so it can't loop, and I checked the router's navigation errors lead there too. After that the error reports from releases dropped to almost none. The trade-off is keeping old files on the server longer, which was cheap.”
@Injectable()
export class AppErrorHandler implements ErrorHandler {
handleError(error: unknown): void {
const msg = String((error as Error)?.message ?? error);
const chunkFailed = /Loading chunk|dynamically imported module/.test(msg);
if (chunkFailed && !sessionStorage.getItem('reloaded-for-chunk')) {
sessionStorage.setItem('reloaded-for-chunk', 'yes');
location.reload();
return;
}
console.error(error);
}
}
// providers: [{ provide: ErrorHandler, useClass: AppErrorHandler }]
Telling users to clear their cache, or reloading on every error without a guard against loops.
Reproduce: build with the production configuration and serve it locally, with source maps on for that local build only.
Know the differences: minified and renamed code, no dev-mode checks, different environment files and build options.
Fix and guard: remove the reliance on build details, and run a smoke test against a production build.
“Our analytics events started arriving with screen names like e and t, but only from production. Locally everything looked right. First I built with the production configuration and served it on my machine, with source maps turned on for that local build only, so I could step through it. The cause was a helper that used this.constructor.name to label each screen. In development the class name survives, but the production build minifies the code and renames classes, so every screen reported a short meaningless name. I replaced it with an explicit screen name in each route's data, read from the router. Then I added a smoke test in CI that runs against the production build instead of the dev server and checks a few key events. The lesson I shared with the team was simple: nothing may depend on class or function names at runtime.”
Guessing and redeploying over and over, or turning off optimisation in production to make the bug go away.
Structure: one FormGroup per section, built from the config, with typed controls or a FormRecord for keys only known at runtime.
Hidden sections: disable them rather than remove, so they don't block validity and drop out of value.
Submit: know when to use value versus getRawValue, and keep display logic out of the template.
“The insurance quote form came from a config the back end sent, with around fifteen sections. I built one FormGroup per section from that config, using non-nullable controls so reset went back to defaults instead of null. For custom fields whose keys we only knew at runtime I used a FormRecord. The tricky part was hidden sections. At first we removed and re-added controls, which lost what the user typed and caused flicker. I changed it so a hidden section is disabled instead. Disabled controls don't count toward validity and aren't included in value, so the form was valid when it should be and the payload only had visible answers, while their typed text survived if they toggled back. Where we needed everything, like saving a draft, we used getRawValue.”
Typing the whole form as any, or hiding sections with CSS while their required validators still block the submit.
Unit: services, pipes, validators and pure logic, fast and many.
Component: TestBed with harnesses and a fake HTTP backend for real user flows on one screen.
End-to-end and delete: a few critical journeys; remove tests that only check implementation details.
“Most of our real bugs were in how a screen behaved with real data, not in single functions. So I kept plain unit tests for services, validators and pipes, and put most effort into component tests with TestBed, using the component harnesses for material controls and HttpTestingController to fake the back end. Those tested what a user does: fill the form, click save, see the error. End-to-end tests covered only five journeys, like login and checkout, because they were slow and flaky. What I deleted were tests that spied on private methods or checked that a component had a certain property. They broke on every refactor and never caught a bug. The trade-off was lower line coverage on paper, which I explained to the team lead with the list of bugs our tests had actually caught.”
Chasing a coverage number, or testing that private methods get called rather than what the user sees.
Dialogs: trap focus inside, return it to the trigger on close, close on Escape.
Updates: announce async results and errors through a live region.
Navigation: give each route a title and move focus to the main heading after navigation.
“The audit found three big problems. Our home-made dialogs let Tab wander behind them and never returned focus when they closed. I moved them onto the CDK dialog, which traps focus and restores it to the button that opened it, and closes on Escape. Second, when a search finished or a save failed, nothing told screen reader users. I used the CDK LiveAnnouncer to announce results and errors politely. Third, after route changes focus stayed on the link just clicked, so screen readers didn't know the page had changed. I set a title on every route and moved focus to the page's main heading after each navigation. I also added an automated accessibility check to our component tests so the basics couldn't slip back in.”
Treating accessibility as adding alt text and aria labels, with no thought for focus and keyboard flow.
Packaging: generated with the CLI library tooling, Angular as a peer dependency, secondary entry points.
Versioning: semantic versions, a changelog, and deprecations before removals.
Problems: apps on different Angular versions, bloated imports, and who owns fixes.
“Three apps had copied the same table, date picker and layout components, and they'd drifted apart. I created a library with the CLI, which builds it in the Angular package format, and listed Angular as a peer dependency so apps didn't end up with two copies. At first everything came from one entry point, and one app complained that importing a button pulled in the table's dependencies. I split it into secondary entry points, one per component group. We used semantic versions and a changelog, and anything we planned to remove got marked deprecated for one release first. The hardest problem was people, not code: one app lagged a major Angular version behind. We agreed the library supports the current and previous major, and the lagging app had a date to catch up.”
Putting Angular in the library's regular dependencies, or shipping breaking changes in a minor version.
Map what they know: components, props and hooks to inputs, outputs, services and signals.
Real work early: a small, well-scoped ticket with pairing on the Angular parts that trip people up.
Feedback: reviews that explain the why, and a check-in after a few weeks.
“She was strong in React but kept fighting dependency injection and RxJS. Instead of pointing her at docs, I spent an hour mapping what she knew: props to inputs, callbacks to outputs, context to services provided at the right level, and useMemo roughly to computed signals. Then I gave her a small feature on a quiet screen and paired on the first day, mostly on where to provide a service and why our streams used switchMap. In her reviews I explained the reason behind each comment and linked to an example in our own code rather than just saying change this. After three weeks she was shipping on her own. She also pointed out that our RxJS use was more complex than needed in places, and we simplified two services because of it.”
Handing over the docs and a big ticket, then judging them in review for not knowing Angular conventions.
Explain: template expressions run on every change detection pass for that view, so the method runs again and again.
Suggest: a computed signal, a pure pipe, or a value prepared when data arrives.
Teach: show one example, let them change the rest, and point to how to measure it.
“I'd start by saying the feature works and the structure is fine. Then I'd explain the one thing that's slowing it: anything called in a template runs on every change detection pass for that view, not once. With a long list and methods that loop over orders, that adds up with every keystroke. I'd rewrite one of them in the review as an example, turning getTotal into a computed signal based on the orders signal, and suggest a pure pipe for isOverdue since it takes an argument per row. I'd ask them to do the rest themselves and show them how to confirm it in the DevTools profiler. Small getters that just return a field are fine, so I wouldn't ask them to change those.”
readonly orders = input.required<Order[]>();
readonly total = computed(() =>
this.orders().reduce((sum, o) => sum + o.amount, 0)
);
// template: {{ total() }} -- reads a cached value, recalculates only when orders change
Rewriting the whole pull request yourself, or banning every function call in templates without explaining why.
Risk: bypassing turns off Angular's sanitiser, so a comment with a script or event handler runs for every reader.
Default: binding to innerHTML already sanitises and keeps safe tags like bold and links.
If more is needed: sanitise on the server with an allow list, and keep the bypass out of shared code.
“I'd block the merge, but explain why rather than just flag it. Users write these comments and other users read them, so bypassing the sanitiser means anyone can post HTML with an onerror handler that runs in every reader's session. That's stored XSS. I'd ask why they needed the bypass. Usually it's because a style or a certain tag got stripped. In most cases plain innerHTML binding is enough, because Angular's sanitiser keeps bold, italics and safe links and removes scripts and handlers. If the feature really needs more, the right place is a server-side sanitiser with a strict allow list, so the data is clean before anyone sees it. I'd also suggest a content security policy as a second layer, and a lint rule that flags bypass calls for review.”
Approving it because only logged-in users can post, or not knowing Angular sanitises innerHTML by default.
Problem first: what pain is this solving: slow releases, merge conflicts, team ownership?
Costs: shared Angular versions, duplicated code in bundles, shared login and state, harder local setup and testing.
Smaller step: lazy feature libraries with clear owners and separate pipelines, then split only what truly needs it.
“I'd start by asking what problem we're solving. When I asked this in a similar situation, the real pain was that one team's bugs blocked everyone's release. Then I'd lay out what micro frontends cost in Angular: every remote has to agree on the Angular version or you ship it twice, shared services like auth have to work across builds, and local setup and end-to-end testing get harder. I'd suggest a smaller step first: keep one app, but split it into lazy feature libraries with clear owners, separate test pipelines, and feature flags so one team's broken work doesn't block a release. If a team still needs its own release cycle after that, we split out just that area. I'd offer to try it with my area first so we learn on real code.”
Either agreeing because it sounds modern or refusing flatly, without asking what problem it solves.
Decision: what you chose and why it made sense at the time.
Cost: how it showed up: slow builds, bugs, onboarding pain.
What changed: how you undid it or contained it, and the lesson you apply now.
“Early on I built a generic base component that every page extended. It held subscriptions, loading flags, error handling and a few helpers, and it felt clever because pages were short. Two years later it was a trap. Every page inherited behaviour it didn't need, changing the base class meant retesting the whole app, and new developers couldn't tell where anything came from. Every subclass also had to pass its services up through super(), which made the constructors messy. I stopped adding to it and replaced it piece by piece with small composable pieces: a loading helper built on signals, takeUntilDestroyed instead of the base class's cleanup, and an error service. It took a few months of small pull requests. Now I prefer composition over inheritance for components, and I'm wary of anything generic before there are three real users.”
Choosing a trivial mistake, or blaming the team or the framework instead of owning the decision.
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.