Angular interviews for 10+ years of experience skip basics like what a component is and go after what only experience teaches, such as how change detection and signals behave underneath, the injector, router and RxJS corners that cause rare production bugs, how to shape a codebase that many teams share, and how you lead people through hard calls. This page is written for Angular developers with roughly eight to fifteen years behind them, interviewing for senior, lead or architect roles. Each question shows what the interviewer is really checking, a shape for your answer and a sample you can adapt. Read the sample, then say your own version out loud with a story from your own work.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Output: each component gets static fields with a factory and a definition, and the template becomes a function of creation and update instructions.
Ahead of time: templates compile at build time, so the browser never downloads or runs a template compiler.
Locality: a component compiles from its own metadata and what it directly imports, which keeps rebuilds fast and lets libraries ship compiled code.
Payoffs: unused framework instructions tree-shake away, and strict template checks catch binding mistakes at build time.
“The compiler turns each component into plain JavaScript. The class gets static fields: a factory that knows how to create it with its dependencies, and a definition that holds the selector, the inputs and a template function. That function has two parts: a creation block that builds the DOM once, and an update block that checks bindings on each change detection pass. It's a list of small instructions, like create this element or update this property. Because that happens at build time, the browser never downloads a template compiler, and instructions the app never uses can be tree-shaken out. Each component compiles from its own metadata and the things it directly imports, which keeps rebuilds fast and lets libraries ship compiled code. And with strict template type checking, a wrong input name or a typo in a binding fails the build instead of failing in front of a user.”
Saying the browser still compiles templates at runtime, or having no idea why framework features an app doesn't use stay out of the bundle.
Rule: inject() works only while Angular is creating something: constructors, field initialisers, provider factories, and guards or resolvers the router runs.
Why it fails: a click handler runs later, and code after an await resumes in a later task, so the context is gone.
Designs: inject at creation and return functions that use what was captured, or keep an Injector and use runInInjectionContext.
Guard rail: assertInInjectionContext at the top of shared helpers for a clear error.
“inject() doesn't find things by magic. It reads the injector Angular sets while it's creating a class or running a factory, and that's only true during a constructor, a field initialiser, a provider factory, or functional guards and resolvers that the router runs for you. A click handler runs later, and code after an await resumes in a later task, so the context is gone and inject throws. For reusable helpers, I call them at creation time, usually as a field initialiser, and have them return functions that use what they captured. If code really must resolve something later, I inject the Injector up front and wrap that call in runInInjectionContext. In shared helpers I put assertInInjectionContext at the top, so a teammate gets a clear message naming the helper instead of a vague error deep in the stack.”
export function injectTracker() {
assertInInjectionContext(injectTracker);
const http = inject(HttpClient); // resolved now, while the context exists
return (event: string) => http.post('/api/track', { event }).subscribe();
}
@Component({ selector: 'app-checkout', template: '<button (click)="pay()">Pay</button>' })
export class CheckoutComponent {
private track = injectTracker(); // field initialiser: inside the context
pay() {
this.track('pay-clicked'); // safe later: nothing is injected here
}
}
Saying inject() works anywhere in a component class, or fixing it by keeping the injector in a global variable.
Ownership: projected content is declared in the parent's template, so it's created when the parent renders, whatever the child does.
@if only places it: wrapping ng-content in @if decides where content shows, not whether it's created.
Lazy pattern: each tab takes an ng-template, and the tabs component stamps only the active one with ngTemplateOutlet.
Rule of thumb: content shown conditionally or more than once should come in as a template.
“Content you project is declared in the parent's template, so it belongs to the parent's view. Angular creates those components when the parent renders, whether or not the child ever displays them. An @if around ng-content only decides whether the already-created content is placed in the DOM. It doesn't delay creation, so every tab's ngOnInit runs and every API call fires at start. To make tabs lazy, the consumer wraps each tab's body in an ng-template. The tab component picks up that template with a content query, and the tabs component renders only the active one with ngTemplateOutlet. Now a tab's content is created when it's opened and destroyed when it's closed, unless I decide to cache it. My rule of thumb is that if a component shows content conditionally or more than once, it should take a template, not ng-content.”
@Component({ selector: 'app-tab', template: '' })
export class TabComponent {
label = input.required<string>();
body = contentChild.required(TemplateRef); // the lazy body
}
// Tabs template: only the active tab's body is created
// @for (tab of tabs(); track tab.label()) {
// @if (tab === active()) {
// <ng-container [ngTemplateOutlet]="tab.body()" />
// }
// }
// Usage:
// <app-tab label="Orders"><ng-template><app-orders /></ng-template></app-tab>
Believing an @if around ng-content stops the projected components from being created.
Identity: track tells Angular which existing DOM node and component instance belongs to which item.
Index tracking: remove one item and everything after it shifts; Angular keeps instances by position and only updates their inputs.
Result: state that lives inside the row component stays with the position, not the data.
Fix: track by a stable id, and keep state that matters in the data or form model.
“The track expression tells Angular how to match items to the DOM nodes and component instances it already has. With index tracking, position is identity. When I delete the second row, the item that used to be third is now second, so Angular keeps the second component instance, hands it the new item through its input, and destroys the last instance instead. Anything that lives only inside the row component, like a half-typed value in a local field, an expanded flag or which input has focus, stays with the position, so it now sits next to the wrong data. It's slower too, because every row after the deleted one gets new inputs and re-renders. The fix is to track by a stable id from the data. And for state that matters, like unsaved edits, I keep it in the parent's data or the form model, so it survives even if a row component is recreated.”
Treating track as only a performance hint with no effect on what the user sees.
Ownership: a component you create yourself lives until you destroy it, or until its view container's host goes, which for an app-wide toast host is never.
Common leaks: a close path that never calls destroy, and subscriptions, timers or window listeners the toast set up and never removed.
Proof: heap snapshots before and after opening and closing many toasts; follow the retainer chain.
Fix: destroy on every close path, clean up with DestroyRef, or use the CDK dialog and overlay services.
“When you create components yourself, Angular doesn't know when you're done with them. One placed in a view container is only cleaned up when that container's host is destroyed, and a toast host usually lives as long as the app. One created with createComponent and attached to the application lives until you destroy it. So first I'd check that every close path calls destroy on the ComponentRef, including when the user navigates away with a toast still open. Then I'd look at what each toast sets up: a subscription to a service, a listener on window, an auto-dismiss timer. If those aren't torn down with DestroyRef or in ngOnDestroy, they can keep the instance reachable even after destroy. To prove it, I take a heap snapshot, open and close a hundred toasts, force garbage collection and take another. If toast instances or detached DOM nodes keep growing, I follow the retainer chain to see what's holding them.”
Assuming Angular garbage collects runtime-created components on its own once they're off screen.
Simpler first: filters in query params, loaded rows in a service cache, and the router's scroll restoration.
How it works: the strategy chooses which routes to detach and store on leave, and hands back the stored view on return.
Lifecycle trap: a stored component isn't destroyed, so ngOnDestroy and ngOnInit don't run; subscriptions keep going and data goes stale.
Memory trap: stored views pile up unless you store only chosen routes, cap the cache and clear it on logout.
“I'd try the simpler route first. Filters belong in query params, the loaded rows can sit in a service cache, and the router can restore scroll position, which often gives product what they want with no lifecycle surprises. If the page is heavy enough that rebuilding it is the real problem, a custom RouteReuseStrategy works. It decides which routes to detach and store when the user leaves, and returns the stored view instead of creating a new one when they come back. The traps come from the component not being destroyed. ngOnDestroy doesn't run on leave, so its subscriptions, polling and listeners keep running in the background. ngOnInit doesn't run on return, so data can be stale, and I'd refresh it from router events instead. Stored views also pile up in memory, so I'd store only chosen routes, cap the cache and clear it on logout, or the next user could see the previous user's list.”
Turning on route reuse for every route without noticing that components are no longer destroyed.
markForCheck: marks this view and its ancestors dirty so the next pass checks them; it doesn't run a check itself.
detectChanges: checks this view and its children right now, synchronously, but not its parents.
detach and reattach: takes the view out of normal passes until you reattach it or check it by hand.
Bugs: waiting for a pass nothing starts, stale parent bindings or an error on a destroyed view, and a quietly frozen screen.
“markForCheck doesn't run anything. It marks this component and every ancestor up to the root as dirty, so the next change detection pass walks down to it, even through OnPush parents. It's the right call when an OnPush component gets data from a callback. The bug is calling it from code running outside the zone and expecting the screen to update, when nothing is going to start that pass. detectChanges checks this view and its children right now, synchronously. It's handy for a component updated outside the normal flow, but it doesn't touch the parents, so a parent binding stays stale, and calling it after the component is destroyed throws. detach takes the view out of the tree, so normal passes skip it until I reattach it or call detectChanges myself. I've used that for a live ticker refreshed a few times a second. The classic bug is forgetting to reattach, so part of the screen quietly freezes.”
Treating the three as interchangeable ways to refresh the view, or sprinkling detectChanges around until the screen updates.
Order: element injectors from the requesting element upward, then environment injectors from a lazy route's injector up to root, then an error.
Projection: projected content sits under the host element in the outer template, so it sees the host's providers but not its viewProviders.
Options: self looks only at this element, skipSelf starts at the parent, host stops at the host element, optional gives null instead of an error.
Fix: move the service to providers if content should share it, or keep it private on purpose.
“Angular first searches element injectors, starting at the element that asks and walking up through its ancestors. If none of them provides it, it moves to the environment injectors, from a lazy route's injector up to root, and throws if nothing has it. The catch here is projection. Projected content is declared in the outer template, so in the injector tree it sits under the host element of the component it's projected into, not inside that component's own view. It can see what the component lists in providers, but not what it lists in viewProviders, which are only for its own template. So this component almost certainly uses viewProviders, the search skips it, and an outer component that provides the same service wins. I'd move it to providers if content should share it. For tighter control, inject takes self, skipSelf, host and optional, to limit where the search looks or return null instead of throwing.”
Saying projected content lives inside the receiving component's view, or not knowing element injectors are searched before the route and root injectors.
Graph: reading a signal inside a computed, an effect or a template records a dependency.
Push then pull: a write only marks dependents stale; a computed recomputes lazily, when it's read.
Equality: a computed that recomputes to an equal value doesn't disturb what's below it; Object.is by default, or a custom equal function.
Effects: scheduled, not run on each write, so several writes give one run with the final values.
“When a computed, an effect or a template reads a signal, Angular records that link, so you get a dependency graph. A write doesn't recompute anything straight away. It just marks everything downstream as possibly stale. The real work happens on read: a computed only recomputes if something it depends on actually changed, and only when someone asks for its value. That's why you never see a half-updated state, the kind of glitch you can get when two observable streams fire one after the other. If a computed recomputes to the same value, by Object.is or a custom equal function, the things below it aren't disturbed. Effects are scheduled rather than run on every write, so three writes in a row give one effect run with the final values. The trap I watch for is a computed that returns a fresh object each time, because then it always looks changed.”
Describing computed as recalculating on every write, or effects as running synchronously the moment a signal is set.
Reference check: pushing into the same array and returning it looks unchanged, so nothing downstream is told.
Fix one: return a new array or object from update, and treat signal values as immutable.
Dynamic tracking: a computed depends only on the signals it read on its last run; a plain field in a condition can hide a signal behind it.
Fix two: make the condition a signal too, so it's tracked on every run.
“Signals decide whether something changed by comparing the old and new value with Object.is. If the update function pushes into the existing array and returns it, it's the same reference, so the signal sees no change and nothing downstream is told. The fix is to return a new array, like spreading the old one with the new item, and to agree as a team that signal values are treated as immutable. The second bug is about how dependencies are tracked. A computed depends only on the signals it actually read the last time it ran. Here the computed checks a plain boolean field first. On the first run it's false, so it returns early without reading the data signal. Now the computed has no signal dependencies at all, and since the field isn't a signal, nothing ever tells it to run again. Making the flag a signal fixes it, because it's read and tracked on every run.”
readonly items = signal<Item[]>([]);
// Bug: same array back, so the signal sees no change
addBroken(item: Item) {
this.items.update((list) => { list.push(item); return list; });
}
// Fix: return a new array
add(item: Item) {
this.items.update((list) => [...list, item]);
}
// Bug: loaded is a plain field, so data() is never read while it's false
loaded = false;
readonly visible = computed(() => (this.loaded ? this.data() : []));
Blaming OnPush or change detection and calling detectChanges, instead of fixing how the value is updated and read.
Default: shareReplay(1) means refCount false, so the source subscription stays alive after every subscriber leaves.
When it hurts: harmless for an HTTP call that completes, a leak for polling, sockets or other endless sources.
Fix: shareReplay with bufferSize 1 and refCount true, so the source is torn down with the last subscriber.
Find it: a network tab showing calls on an idle page, and finalize logging on the source.
“shareReplay(1) on its own is the same as setting refCount to false. Once the first subscriber connects, the source subscription stays alive for good, even after every subscriber has unsubscribed. For a plain HTTP call that doesn't matter, because the request completes and the stream is finished. But this stream polls every few seconds and never completes, so the timer and the requests keep running in the background long after every screen has gone. The fix is shareReplay with bufferSize one and refCount true, so the source is torn down when the last subscriber leaves and started again when someone comes back. I'd find it the way I found it last time: the network tab showing calls on a page that shouldn't be calling anything, then a finalize log on the source that never fires when screens close.”
// Keeps polling forever once anyone has subscribed
readonly pricesLeaky$ = timer(0, 5000).pipe(
switchMap(() => this.api.prices()),
shareReplay(1),
);
// Stops with the last subscriber, restarts with the next one
readonly prices$ = timer(0, 5000).pipe(
switchMap(() => this.api.prices()),
shareReplay({ bufferSize: 1, refCount: true }),
);
Saying shareReplay always cleans up its source when subscribers leave.
Rule: an error ends the stream it travels through; nothing is emitted after it.
Bug: catchError sits after switchMap on the outer pipe, so the first error kills the input stream.
Fix: catch inside switchMap on the inner request and return a fallback value.
Polish: show an error state, and retry on the inner call only.
“An error in RxJS ends the stream it travels through. If catchError sits on the outer pipe, after switchMap, the first failed request travels up, catchError swaps in its fallback, and by then the original stream is already dead. It isn't listening to the text box any more, so typing does nothing. The fix is to handle the error inside switchMap, on the inner HTTP observable. Then only that one request fails, it's turned into a fallback value like an empty result with an error flag, and the outer stream keeps listening. I'd also show a small error message instead of silently returning nothing, and if the backend is flaky I'd add a retry with a short delay on the inner call, never on the outer stream. I look for this in every review of search or autocomplete code, because it passes every happy-path test.”
results$ = this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) =>
this.api.search(q).pipe(
map((items) => ({ items, error: false })),
catchError(() => of({ items: [], error: true })), // inner: the search stream survives
),
),
);
Adding catchError at the end of the pipe and assuming the stream carries on afterwards.
Order matters: takeUntil completes the stream at the point where it sits.
Bug: placed before switchMap or mergeMap, it completes the outer source, but the flattening operator waits for its active inner stream.
Endless inner: a socket never completes, so it runs for ever.
Fix: takeUntil or takeUntilDestroyed last in the pipe, backed by a lint rule.
“takeUntil completes the stream at the point where it sits. If it's placed before a switchMap, then on destroy the outer source completes, but switchMap still has an active inner subscription, the websocket, and it waits for that inner stream to finish before completing itself. A socket never finishes, so it runs forever. The same goes for mergeMap and concatMap. So the rule is that takeUntil goes last, after any operator that subscribes to inner streams. The same applies to takeUntilDestroyed, which I prefer now because it removes the hand-made destroy subject. In a big codebase I don't rely on people remembering this. There are lint rules that flag takeUntil placed before flattening operators, and I'd switch them on so it's caught in CI rather than in a memory profile months later.”
// Leaks: the inner socket stream outlives the component
this.room$
.pipe(takeUntil(this.destroy$), switchMap((id) => this.socket.join(id)))
.subscribe();
// Correct: the cleanup operator goes last
this.room$
.pipe(
switchMap((id) => this.socket.join(id)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
Saying takeUntil anywhere in the pipe is enough to clean up.
App shell: a service worker caches the built files so the app opens offline, and users are told when a new version is ready.
Local data: assigned jobs live in IndexedDB; screens read from there first and refresh when online.
Write queue: every save goes to a local outbox with an id made on the device, syncs in order, and the server ignores repeats of the same id.
Conflicts: version numbers on records, agreed rules for simple fields, and a review screen for real clashes.
“I'd split it into four parts. First, the app itself: Angular's service worker caches the built files, so the app opens with no signal, and I'd use its update events to tell users a new version is ready rather than swap it under them mid-form. Second, data: the jobs assigned to a worker live in IndexedDB. Screens read from there first and refresh from the server when online, so reads never wait on the network. Third, writes: every save goes into a local outbox with an id generated on the device, and a sync service sends them in order when the connection comes back. The server treats that id as an idempotency key, so a retry after a lost response doesn't create a duplicate. Fourth, conflicts: each record carries a version number. If someone in the office changed the same job, simple fields follow an agreed rule and real clashes go to a review screen instead of being silently overwritten.”
Relying on the browser cache and a retry loop, with no local queue, no protection against duplicates and no plan for conflicts.
Runtime config: load the tenant's settings as the app starts, from the domain or the login, never baked into the build.
Branding: design-system CSS custom properties set from config, so colours and logos are data.
Features: flags read in one service and used by canMatch route guards and the UI, so a hidden area's code never loads.
Custom screens: extension points through injection tokens or lazy routes registered per tenant, and no customer-name checks in shared code.
“My aim is one build and no forks, because a fork per customer turns every fix into ten fixes. As the app starts, an app initializer fetches the tenant's config, based on the domain or the login, and holds it in a service. Branding comes from the design system's CSS custom properties, so a tenant's colours, logo and fonts are data, not code. Features are flags checked in one place. Routes use a canMatch guard, so a tenant without a module never even loads its code, and menus and buttons read the same flags. Custom screens are the hard part. I'd define extension points, like an injection token for dashboard widgets or a slot in the routes, and register each tenant's components there as lazy chunks. The rule I'd enforce in review is no checks for a customer's name in shared code, because that's how a product slowly turns into a pile of special cases.”
A separate branch or build per customer, or if-statements on customer names spread through the code.
Layers: feature, UI, data-access and utility libraries, with one-way import rules.
Enforcement: lint rules on import boundaries and public entry points, not a wiki page.
Speed: build and test only what a change affects, with caching, and each feature as a lazy route.
Ownership: code owners per library and a small shared core with a high bar.
“I'd use a monorepo split into libraries by type and by business domain. Feature libraries hold routed pages and the components that talk to services, UI libraries hold presentational components with no services, data-access libraries hold API calls and state, and utility libraries hold plain functions. The rule runs one way: features can use UI and data-access, UI can't import features, and one domain can't reach into another's internals. Each library exposes a public index file and nothing else. I'd enforce that with lint rules on import boundaries, because a rule people read once is forgotten in a month. For speed, CI should build and test only what a change affects, with caching, and each feature is a lazy route so the main bundle doesn't grow with every team. Code owners per library keep reviews with the right people, and the shared core stays small, with a high bar for adding anything.”
Folders by file type, like components, services and models, across the whole app with nothing enforcing boundaries.
Race: several requests fail together, and each one starts its own refresh.
Rotation: if each refresh token works only once, the later refreshes send a spent token, get rejected and the app logs out.
One tab: share a single in-flight refresh; failed requests wait for it and retry once with the new token.
Many tabs: coordinate with the Web Locks API or a broadcast channel so one tab refreshes and the others reuse its result.
“When the token expires, a screen that fires five requests gets five 401s at once, and a naive interceptor starts five refreshes. If the server rotates refresh tokens, each one works only once. The first refresh succeeds, the others send a token that was just used, the server rejects them, and the error path logs the user out. That's why it only shows up in real use and never when you test one request at a time. The fix inside a tab is a single shared refresh in a service: the first 401 starts it, every other failed request waits on the same result, then retries once with the new token. A second 401 after that retry goes to login, not another refresh. Open tabs have the same race, so I'd coordinate across them with the Web Locks API or a broadcast channel, so one tab refreshes and the others use its result.”
private refreshing$: Observable<string> | null = null;
refreshToken(): Observable<string> {
// Every caller shares the same in-flight refresh
this.refreshing$ ??= this.http.post<{ token: string }>('/api/auth/refresh', {}).pipe(
map((res) => res.token),
finalize(() => (this.refreshing$ = null)),
shareReplay(1),
);
return this.refreshing$;
}
Blaming the server for random logouts, or adding retries without making the refresh happen once.
Two routes: a hybrid app with the upgrade package when screens are tangled, or a route-by-route split when the app divides cleanly by URL.
Rule one: new features only in modern Angular from day one.
Order: shared services and leaf components first, then whole routes.
Costs: two change detection systems, a heavier download and harder debugging, so set an end date.
“I'd pick between two routes. If screens are tangled together, I'd run a hybrid app with the upgrade package: both frameworks boot on the same page, new Angular components are downgraded so old templates can use them, and old services are upgraded so new code can inject them. If the app splits cleanly by URL, a route-by-route approach is simpler: a new Angular app owns some routes, the old app owns the rest, and I move one route at a time. Either way, the rule from day one is that new features are written only in modern Angular. Then I move shared services and leaf components first, since everything depends on them, and whole routes after. I'd be honest about the costs: in hybrid mode two change detection systems run and trigger each other, the download is heavier and debugging is harder. So I set an end date and track the count of remaining AngularJS files every sprint.”
Proposing a big-bang rewrite with a feature freeze, or leaving a hybrid app running with no end date.
Rule: only standalone dependencies that nothing else in the file uses eagerly can move into a lazy chunk.
Common leaks: the component referenced in the class, like a view query, or imported eagerly by another component on the page.
Check: look for a new lazy chunk in the build output or a stats file, not at the template.
Tuning: choose the trigger and prefetch on purpose, and give the placeholder a minimum time so it doesn't flash.
“A defer block only moves a dependency into a lazy chunk if the compiler can see nothing else needs it eagerly. The component has to be standalone, and it can't be referenced anywhere in that file outside the block. The usual catch is the component class: a view query for the chart, or using the chart class in code, keeps it eager, because the class now needs it at load time. Next I check whether something else imports it eagerly, like another component on the same page, because then it's in the main bundle anyway and the defer block only delays rendering. I don't trust the template to tell me. I look at the build output for a new lazy chunk, or at a stats file to see what pulls the chart in. Once it really splits, I choose the trigger on purpose, like on viewport with prefetch on idle, and set a minimum time on the placeholder so it doesn't flash.”
// Template: the chart loads when it scrolls into view, fetched early when the browser is idle
// @defer (on viewport; prefetch on idle) {
// <app-sales-chart [data]="sales()" />
// } @placeholder (minimum 500ms) {
// <div class="chart-skeleton"></div>
// }
// Class: this query keeps SalesChartComponent in the main bundle
// chart = viewChild(SalesChartComponent);
Assuming anything inside a defer block is lazy-loaded without checking the build output.
Find the real problem: hiring, delivery speed or old code; check the data.
Cost it honestly: a feature freeze or two systems at once; most effort is rediscovering business rules buried in code.
Cheaper options: upgrade to modern Angular, hire strong web developers and train them, fix the worst areas.
If still right: move route by route with checkpoints, never a big bang.
“I'd take it seriously rather than defend Angular out of habit. First I'd ask what's actually hurting. If it's hiring, I'd look at the data: how long roles stay open and why candidates say no. Often the real issue is that our Angular is several versions old and the code is hard to work in, not the framework itself. Then I'd lay out the cost honestly. A full rewrite of a large product means either freezing features for a long time or running two systems, and most of the effort isn't writing components, it's rediscovering business rules that only live in the old code. Cheaper options usually get most of the gain: upgrade to modern Angular with standalone components and signals, hire strong web developers and train them, and clean up the worst areas. If a move still makes sense after that, I'd propose doing it route by route, with checkpoints where we can stop, never as a big bang.”
Dismissing the idea out of loyalty to Angular, or agreeing to a big-bang rewrite without costing it.
Pick few: rules drawn from real bugs, like OnPush by default and no nested subscribes.
Automate: lint rules in CI and code generators with the right defaults.
Show, don't tell: one example feature beats a long document.
Keep it alive: a regular slot where anyone can challenge a rule.
“At my last company five teams shared one Angular platform and each had its own style, so moving people between teams was slow and reviews were full of style arguments. I started by collecting the bugs from the past few months and picking the handful of rules that would have stopped most of them: OnPush by default, no subscribe inside subscribe, typed reactive forms, and no HTTP calls straight from components. Then I automated them. We added lint rules that failed CI, and generators so a new component came out with OnPush and the right folder layout. I built one example feature showing everything together, which people copied far more than they read the document. Every month we had a short session where anyone could propose changing or dropping a rule. After a couple of quarters, style comments had mostly gone from reviews and people discussed logic instead.”
A long style guide nobody enforces, or rules imposed with no way to challenge them.
The project: what it was, what was promised and who depended on it.
What went wrong: the real causes, including your own part, not only outside factors.
What you did: how you told people, re-planned and saved what could be saved.
Now: the specific habits you changed.
“At my last company I led a rebuild of our customer portal in modern Angular, with four developers and a launch date promised to sales for the end of the quarter. Two months in, we'd rebuilt the easy screens nicely and hadn't touched billing, which depended on an old backend nobody fully understood. My part was real: I planned screens by how visible they were, not by risk, and I kept reporting green because each sprint looked fine. When billing blew up the estimate, I went to my manager and the sales lead the same day, said plainly that the date was gone and why, and offered a cut: launch with the new account and orders screens, and keep the old billing pages behind a link. We shipped that about six weeks late. Now I take the riskiest integration to production in the first weeks, and I report risks, not just progress.”
A story where the failure was all someone else's fault, or where nothing about how you lead changed afterwards.
Real work: a code review of a flawed component and a short pairing task, not trivia.
Probe why: ask for the reason behind each choice and what changes at scale.
Mid vs senior: mid builds it right; senior ranks risks, prevents the bug class and teaches in review.
Fairness: the same exercise and scoring notes for everyone.
“I avoid trivia, because knowing hook names says little about how someone works. My main exercise is a code review: a short Angular component with real problems, like a nested subscribe, a missing cleanup, a getter that creates a new array each time and an unsafe innerHTML. I ask them to review it as if a teammate wrote it. Then a short pairing task, like adding a filter to a list, where I ask why at each choice. A strong mid-level developer finds most of the bugs and builds the feature correctly. A senior also ranks what matters most, explains how they'd stop that bug class coming back, asks what happens with ten thousand rows or a slow network, and writes review comments that teach instead of just flag. Everyone gets the same exercise, and I score against the same notes straight after, so I'm comparing people fairly and not on gut feel.”
Hiring on framework trivia or speed alone, with no look at judgement or how the candidate explains.
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.