Ember.js Interview Questions and Answers

Last updated:

Check out 35 of the most common Ember.js interview questions, then take an AI-powered practice interview

JavaScriptEmber DataEmber CLIHandlebarsConvention over Configuration
35+
Questions
14
Basic
14
Intermediate
7
Advanced
Q1

What did the Octane edition change, and how do you tell whether a codebase is classic or Octane?

BasicFundamentals

Answer

Octane shipped with Ember 3.15 and is the programming model every current Ember app uses. It replaced five classic pillars at once. Classic Ember.Component became Glimmer component, extending Component from @glimmer/component.

Computed properties and observers became plain getters plus the @tracked decorator from @glimmer/tracking. Curly component invocation like {{user-card user=this.user}} became angle bracket invocation like <UserCard @user={{this.user}} />. The {{action}} modifier became the {{on}} element modifier combined with the {{fn}} helper.

And EmberObject.extend({...}) became native class syntax with a constructor. Telling the two apart during a code reading round is easy: look at the imports. If a component file imports Component from '@ember/component' and uses .extend() or computed('a.b'), it is classic.

If it imports Component from '@glimmer/component' and uses class Foo extends Component with @tracked fields, it is Octane. Also check the template. Curly invocation with positional-looking arguments and this-less property references such as {{firstName}} means the codebase predates the no-implicit-this codemod.

Most real Indian Ember teams are mixed: the routes and services migrated years ago, but a long tail of classic components survives because they rely on two-way binding or didInsertElement. Interviewers ask this to see whether you can navigate that mixed reality rather than only greenfield code.

// Classic (pre-Octane)
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
  fullName: computed('first', 'last', function () {
    return `${this.first} ${this.last}`;
  }),
  actions: {
    save() { this.get('onSave')(this.get('fullName')); },
  },
});

// Octane
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ProfileCard extends Component {
  @tracked first = '';
  @tracked last = '';

  get fullName() {
    return `${this.first} ${this.last}`;
  }

  @action
  save() {
    this.args.onSave(this.fullName);
  }
}

Key Points

  • Glimmer components, native classes, @tracked, angle brackets, {{on}}
  • Classic marker: '@ember/component' plus .extend() and computed()
  • Octane marker: '@glimmer/component' plus @tracked and getters
  • Real codebases are mixed; know how to work in both
💡 Pro Tip: When you inherit a mixed codebase, run npx ember-no-implicit-this-codemod and npx ember-angle-brackets-codemod before the native class codemod. Doing them in the other order produces templates the class codemod cannot reason about.
Q2

How is a Glimmer component different from the classic @ember/component, in behaviour not just syntax?

BasicComponents

Answer

The differences go far past syntax. First, a Glimmer component is tagless. Classic components wrapped themselves in a div (or whatever tagName said) and let you set classNames, classNameBindings, attributeBindings and elementId.

Glimmer components render exactly the markup in the template, and you forward attributes from the caller using ...attributes on the element you choose. Second, arguments are one-way and immutable. In a classic component every passed property was two-way bound, so a child could write this.set('value', x) and silently mutate the parent.

In Glimmer, arguments live on this.args, this.args is frozen in development, and assigning to it throws. You change parent state by calling a function the parent passed down, which is the data down actions up rule. Third, the lifecycle hooks are gone.

There is no didInsertElement, didRender, didUpdateAttrs or willRender. You get a constructor and a willDestroy, and anything DOM-related moves into an element modifier. Fourth, args are lazily consumed: a getter that reads this.args.foo only re-runs when foo actually changes and the getter output is actually rendered. Interviewers usually follow up with 'so where did didInsertElement go', and the correct answer names ember-modifier, not @ember/render-modifiers, because the render modifiers package exists mainly as a migration bridge.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class SearchBox extends Component {
  @tracked term = '';

  @action
  onInput(event) {
    this.term = event.target.value;
    // never this.args.term = ...  (args are frozen)
    this.args.onSearch?.(this.term);
  }

  willDestroy() {
    super.willDestroy(...arguments);
    this.args.onUnmount?.();
  }
}

{{! search-box.hbs }}
<div class="search" ...attributes>
  <input value={{this.term}} {{on 'input' this.onInput}} />
  {{yield this.term}}
</div>

Key Points

  • Tagless: use ...attributes instead of tagName and classNameBindings
  • this.args is one-way and frozen; mutate through callbacks (DDAU)
  • No didInsertElement / didRender; only constructor and willDestroy
  • DOM work belongs in an element modifier, not a lifecycle hook
Q3

What does @tracked actually do, and when does a getter that depends on it recompute?

BasicReactivity

Answer

@tracked from @glimmer/tracking turns a class field into a getter and setter pair backed by a tag, which is Glimmer's unit of change tracking. When something reads the property during rendering or inside another tracked computation, that consumer entangles with the tag. When you assign a new value, Glimmer bumps a global revision counter and stamps the tag with it, which marks every entangled consumer as stale.

Nothing recomputes at assignment time. On the next render pass Glimmer compares the revision it last saw with the current one and only re-runs the specific getters and template sections whose tags moved. That is why Octane needs no dependency keys: with computed('user.firstName', 'user.lastName') you had to list dependencies by hand and a typo silently produced stale UI, while a plain getter that reads this.user.firstName entangles automatically and correctly.

Two consequences trip up candidates. Getters are not cached by default, so a getter reading tracked state runs on every render that touches it, and if it does expensive work you need the @cached decorator. And @tracked tracks assignment to that exact property, not deep mutation, so pushing into a tracked array or setting a key on a tracked plain object does not invalidate anything. The fix is either reassignment or the TrackedArray and TrackedObject classes from tracked-built-ins.

import { tracked } from '@glimmer/tracking';
import { TrackedArray } from 'tracked-built-ins';

class Cart {
  @tracked coupon = null;
  @tracked items = [];          // reassignment only
  liveItems = new TrackedArray(); // deep mutation works

  get total() {
    // entangles with items and coupon automatically
    const sum = this.items.reduce((n, i) => n + i.price, 0);
    return this.coupon ? sum - this.coupon.value : sum;
  }

  addBroken(item) {
    this.items.push(item);      // no re-render, tag never bumped
  }

  addCorrect(item) {
    this.items = [...this.items, item]; // re-renders
    this.liveItems.push(item);          // also re-renders
  }
}

Key Points

  • @tracked installs a tag; readers entangle, writers bump a revision
  • Getters need no dependency keys, unlike computed()
  • Getters are uncached by default; use @cached for expensive ones
  • Tracks assignment only, not in-place mutation of arrays or objects
💡 Pro Tip: If a value updates in the console but not on screen, ninety percent of the time you mutated an array or object in place instead of reassigning the tracked property.
Q4

Explain angle bracket invocation, named arguments, ...attributes and block params.

BasicTemplates

Answer

Angle bracket invocation makes a component call look like an HTML element: <UserCard @user={{this.user}} class='wide' />. The at sign prefix marks a named argument, which lands on this.args.user inside the component. Anything without the at sign is an HTML attribute and does not reach this.args at all; instead it flows through the ...attributes splat and gets applied wherever you place ...attributes in the component template.

This split is the whole point of the syntax. Before Octane, {{user-card user=this.user class='wide'}} gave you no way to know whether class was data or styling, and the component had to merge classNames itself. Now the caller can pass class, data-test-id, aria-label, role or any DOM attribute and it just works, with class specifically merging rather than replacing.

Place ...attributes on your outermost element, and place it after your own attributes so the caller can override you rather than the other way round. Block params come from {{yield}}: writing {{yield this.term this.results}} lets the caller do <SearchBox as |term results|>. A component invoked with a block can check whether the caller supplied a named block using has-block, and named blocks like <:header> and <:footer> let one component expose several slots. Interviewers commonly ask what happens when you pass @class instead of class, the answer being that it becomes an ordinary argument and never reaches the DOM.

{{! app/components/panel.hbs }}
<section class="panel" data-test-panel ...attributes>
  {{#if (has-block 'header')}}
    <header>{{yield to='header'}}</header>
  {{/if}}
  {{yield @rows to='body'}}
</section>

{{! caller }}
<Panel class="panel--compact" aria-label="Applicants" @rows={{this.rows}}>
  <:header>Applicants ({{this.rows.length}})</:header>
  <:body as |rows|>
    {{#each rows key='id' as |row|}}
      <ApplicantRow @row={{row}} />
    {{/each}}
  </:body>
</Panel>

Key Points

  • @foo becomes this.args.foo; plain foo becomes an HTML attribute
  • ...attributes applies caller attributes; class merges rather than replaces
  • Put ...attributes last so callers can override component defaults
  • {{yield}} exposes block params; named blocks give multiple slots
Q5

Which Ember CLI commands do you use daily, and what does ember generate actually create?

BasicTooling

Answer

Ember CLI is the single build and scaffolding tool, so there is no debate about bundlers or folder layout. ember new my-app creates the project with the router, tests, linting and a working build. ember serve (or ember s) runs the dev server on port 4200 with live reload, and --proxy http://localhost:8080 forwards unmatched requests to a backend, which is the standard way Indian teams point an Ember frontend at a local Rails or Spring API without CORS pain. ember generate, shortened to ember g, is the interesting one: it scaffolds a file plus its test in one go, and for some blueprints it also edits other files. ember g route jobs creates app/routes/jobs.js, app/templates/jobs.hbs, a test, and importantly adds this.route('jobs') to app/router.js. ember g component job-card --component-class=@glimmer/component creates the class and template pair. ember g service session, ember g model job, ember g helper format-ctc and ember g adapter application follow the same pattern. ember destroy route jobs reverses it, including the router edit. ember install ember-concurrency runs npm install and then the addon's default blueprint, which is why you should use it rather than plain npm install for addons that need setup. ember build --environment=production produces the fingerprinted dist. ember test runs the suite headlessly, ember test --server keeps a browser open, and ember test --filter='job card' narrows to matching modules.

# scaffold
npx ember-cli new gs-portal --lang en

# day to day
ember serve --proxy http://localhost:8080
ember g route jobs/show --path=':job_id'
ember g component job-card --component-class=@glimmer/component
ember g service current-user
ember g model job
ember install ember-concurrency

# ship it
ember build --environment=production
ember test --filter='job card'
ember test --server

Key Points

  • ember serve --proxy avoids local CORS setup against a real backend
  • ember g route also edits app/router.js; ember destroy reverses it
  • ember install runs the addon blueprint, plain npm install does not
  • ember test --filter and --server for focused local debugging
Q6

How does Ember's router work: Router.map, nested routes and dynamic segments?

BasicRouting

Answer

app/router.js is the single source of truth for URLs. Inside Router.map you call this.route('name', options, function () { ... }) and nesting the callback nests both the URL and the templates. Nesting matters more than people expect, because a nested route's template renders into the parent template's {{outlet}}, and the parent's model hook has already resolved by the time the child's runs.

Dynamic segments use a colon, as in this.route('job', { path: '/jobs/:job_id' }), and the segment value arrives as the first argument to the model hook. Ember derives the param name from the route by default, so a route named job gets :job_id, and Ember Data's findRecord is the conventional body of that model hook. Every nested block also gets an implicit index route, so this.route('jobs', function () {}) means /jobs resolves to jobs.index, not jobs, and forgetting that is the classic reason a template renders blank.

Use { resetNamespace: true } when you want a deeply nested URL without a deeply nested route name. A wildcard segment like this.route('notFound', { path: '/*path' }) catches everything else and should be declared last. In the template you link with the <LinkTo @route='job' @model={{job}}> component, passing @models as an array for multi-segment routes and @query for query params. Interviewers often ask how you would move from hash URLs to real ones: set locationType to 'history' in config/environment.js and make sure the server rewrites unknown paths to index.html.

// app/router.js
import EmberRouter from '@ember/routing/router';
import config from 'gs-portal/config/environment';

export default class Router extends EmberRouter {
  location = config.locationType; // 'history'
  rootURL = config.rootURL;
}

Router.map(function () {
  this.route('jobs', function () {
    this.route('show', { path: '/:job_id' }, function () {
      this.route('applicants');
    });
  });
  this.route('login');
  this.route('not-found', { path: '/*path' });
});

{{! /jobs renders jobs.index into jobs.hbs's {{outlet}} }}
<LinkTo @route='jobs.show.applicants' @model={{this.job}}>Applicants</LinkTo>

Key Points

  • Nesting the callback nests URL, template outlet and model resolution
  • Every nested route block gets an implicit index route
  • :job_id arrives as the model hook's first argument
  • <LinkTo @route @model @models @query> replaces the old link-to helper
💡 Pro Tip: If /jobs shows an empty page after you add a nested block, you almost certainly need app/templates/jobs/index.hbs. The parent template now only holds chrome plus {{outlet}}.
Q7

What are the route hooks and in what order do beforeModel, model, afterModel, setupController and resetController run?

BasicRouting

Answer

For a single route the order is beforeModel, then model, then afterModel, then setupController, then the template renders. If any of the first three returns a promise, the transition pauses until it settles, which is exactly what powers loading substates. beforeModel receives the transition and runs before you know the model, so it is where authentication guards and redirects belong: aborting here avoids a wasted API call. model receives the dynamic segment params, the transition, and returns the data, usually via this.store.findRecord. afterModel receives the resolved model and the transition and is for decisions that need the record, for example redirecting a job in draft status to an edit route. setupController receives the controller and the resolved model, and the default implementation sets controller.model, so you must call super.setupController(controller, model) if you override it. resetController runs when you leave, receives an isExiting flag, and is the correct place to clear query params so a stale ?page=7 does not follow the user back. Two subtleties interviewers dig into.

First, when a route is entered by <LinkTo @model={{job}}> the model hook is skipped entirely, because you already handed Ember the record, so any side effect you hid in model silently stops happening on in-app navigation while still running on a hard refresh. Second, for nested routes all hooks run parent first, and the parent's model is available to a child through this.modelFor('jobs.show').

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class JobsShowRoute extends Route {
  @service store;
  @service router;
  @service session;

  beforeModel(transition) {
    if (!this.session.isAuthenticated) {
      transition.abort();
      this.router.transitionTo('login');
    }
  }

  model({ job_id }) {
    return this.store.findRecord('job', job_id, { include: 'applicants' });
  }

  afterModel(job) {
    if (job.status === 'draft') this.router.replaceWith('jobs.edit', job);
  }

  setupController(controller, model, transition) {
    super.setupController(controller, model, transition);
    controller.parentJob = this.modelFor('jobs');
  }

  resetController(controller, isExiting) {
    if (isExiting) controller.page = 1;
  }
}

Key Points

  • beforeModel, model, afterModel, setupController, then render
  • Promises returned from the first three pause the transition
  • <LinkTo @model={{record}}> skips the model hook entirely
  • resetController with isExiting is where you clear query params
Q8

What is a service in Ember, and how does @service injection resolve?

BasicDependency Injection

Answer

A service is a long-lived singleton owned by the application container, used for state and behaviour that must outlive any single route or component: the current user, a websocket connection, a feature flag store, a toast queue, shopping cart state, the router itself. You create one with ember g service current-user, which produces a class extending Service from '@ember/service'. You consume it by declaring a field with the @service decorator, imported as { service } from '@ember/service' in current versions rather than the older { inject as service } alias.

The decorator does not instantiate anything at class construction time; it defines a lazy getter that calls owner.lookup('service:current-user') the first time you read it. That laziness matters, because a service with an expensive constructor costs nothing until something actually touches it. The property name determines the lookup key by default, so @service currentUser resolves service:current-user through the dasherizing resolver, and you pass an explicit string when the names differ, as in @service('current-user') user.

Ember itself ships services you inject the same way: router, store when Ember Data is installed, fastboot under server rendering, and intl or session from popular addons. Because services are container-owned, tests can replace them: this.owner.register('service:current-user', StubUser) inside a setupTest block swaps the real one before your component ever looks it up. The failure mode to know is injecting a service into a plain class you constructed with new, which has no owner, so the lookup throws about a missing owner.

// app/services/current-user.js
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class CurrentUserService extends Service {
  @service store;
  @tracked record = null;

  async load() {
    this.record = await this.store.queryRecord('user', { me: true });
    return this.record;
  }

  get isRecruiter() {
    return this.record?.role === 'recruiter';
  }
}

// any component, route, controller or other service
import Component from '@glimmer/component';
import { service } from '@ember/service';

export default class NavBar extends Component {
  @service currentUser;          // -> service:current-user
  @service('current-user') me;   // explicit key
}

Key Points

  • Singletons in the container, resolved lazily on first property read
  • import { service } from '@ember/service' in current versions
  • Property name dasherizes to the lookup key unless you pass a string
  • this.owner.register(...) swaps a service in tests
Q9

How do loading and error substates work, and how does Ember choose which one to render?

BasicRouting

Answer

Ember gives you asynchronous UI for free through substates. When a route's beforeModel, model or afterModel returns a pending promise, Ember looks for a sibling loading template and renders it into the parent's outlet until the promise settles. For a route named jobs.show that is app/templates/jobs/show-loading.hbs, or app/templates/jobs/loading.hbs for the child level.

If the promise rejects, Ember looks for the equivalent error template and renders it with the error object as the model, so you can show error.message or branch on error.errors[0].status for a JSON:API payload. Both substates bubble: if jobs/show-loading.hbs does not exist Ember walks up to jobs/loading.hbs then application-loading.hbs, and the same for errors. You can intercept programmatically by defining loading(transition, origin) or error(err, transition) in the route's actions, returning true to let it bubble or false to stop it.

Two production details matter. First, the loading substate only appears on a transition where the promise is genuinely pending, so a fully cached Ember Data record resolves synchronously enough that no spinner flashes, which is usually what you want. Second, an unhandled error substate at application level effectively becomes your crash screen, and if you never create application-error.hbs the app silently renders nothing while the console shows the rejection. Every serious Ember app should have an application-error template wired to whatever error reporter it uses.

app/templates/
  application.hbs
  application-loading.hbs
  application-error.hbs
  jobs.hbs
  jobs/loading.hbs      <- shown while jobs.index model pends
  jobs/show.hbs
  jobs/show-loading.hbs <- shown while jobs.show model pends

// app/routes/jobs/show.js
import Route from '@ember/routing/route';
import { action } from '@ember/object';

export default class JobsShowRoute extends Route {
  @action
  error(err) {
    if (err?.errors?.[0]?.status === '404') {
      this.router.replaceWith('not-found');
      return false; // handled, stop bubbling
    }
    return true;    // let application-error render
  }
}

Key Points

  • Pending model promises render the sibling loading template
  • Rejections render the error template with the error as the model
  • Both bubble upward to application-loading and application-error
  • Route-level loading() and error() actions let you intercept
Q10

What is data down actions up, and how did Glimmer components remove two-way binding?

BasicArchitecture

Answer

Data down actions up, usually written DDAU, is Ember's answer to the debugging nightmare of the old two-way binding model. In classic Ember, passing a property into a component created a live binding in both directions: the child could this.set('value', 'x') and the parent's property changed with nothing in the parent's code hinting that it could. In a large app with several levels of nesting, tracking down which component mutated a value became genuinely difficult.

Glimmer components broke that on purpose. Arguments flow one way only, this.args is frozen in development builds, and assigning to this.args.value throws. To change parent state, the parent passes a function down and the child calls it, which means every mutation has a named entry point you can search for and set a breakpoint in.

In templates the {{fn}} helper partially applies arguments to that callback, so you can pass <Row @onSelect={{fn this.select row}} /> without creating a closure in JavaScript. Native form controls follow the same rule: you bind value={{this.term}} and attach {{on 'input' this.update}} rather than expecting the input to write back. The one place two-way binding survives is the built-in <Input> and <Textarea> components with @value, which are deliberately kept for convenience and which many teams ban in review for consistency. A classic interview follow-up is what {{mut}} was for: it manufactured a setter function to fake DDAU during the migration, and new code should not use it.

{{! parent.hbs }}
{{#each this.rows key='id' as |row|}}
  <ApplicantRow
    @row={{row}}
    @selected={{eq this.selectedId row.id}}
    @onSelect={{fn this.select row.id}}
  />
{{/each}}

// parent.js
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ApplicantList extends Component {
  @tracked selectedId = null;

  @action
  select(id) {
    this.selectedId = id; // the only place selection can change
  }
}

{{! applicant-row.hbs }}
<button type="button" {{on 'click' @onSelect}}>{{@row.name}}</button>

Key Points

  • Arguments are one-way; this.args is frozen in development
  • Children call parent-supplied callbacks to request changes
  • {{fn}} partially applies arguments without a JS closure
  • <Input @value> is the surviving two-way escape hatch; {{mut}} is legacy
Q11

How do you write a helper, and when do you reach for {{fn}} versus {{on}} versus a modifier?

BasicTemplates

Answer

These three solve different problems and mixing them up is a common junior mistake. A helper transforms values for display. You write one as a plain function wrapped in helper() from '@ember/component/helper', or in newer code as a bare function exported from a .gjs file, and it returns a value: {{format-ctc @job.ctc}} turns 1800000 into 18 LPA.

Helpers are pure, they run during render, and they must not touch the DOM or cause side effects, because Glimmer may run them at surprising times. {{on}} is an element modifier that attaches a DOM event listener and removes it automatically when the element is torn down, so {{on 'click' this.save}} replaces the old {{action 'save'}} with something that behaves like addEventListener including support for the once, passive and capture options. {{fn}} is a helper that partially applies arguments to a function without invoking it, so {{on 'click' (fn this.remove item)}} produces a listener that calls remove with the item plus the DOM event as the trailing argument. Anything that genuinely needs the element itself, focusing an input, initialising a chart library, observing intersection, belongs in a custom element modifier. Ember also ships small built-in helpers worth naming in an interview: {{concat}}, {{get}}, {{array}}, {{hash}}, {{unique-id}} for generating a stable id to wire a label to an input, and {{if}} and {{unless}} in both block and inline forms.

// app/helpers/format-ctc.js
import { helper } from '@ember/component/helper';

export default helper(function formatCtc([paise], { unit = 'LPA' } = {}) {
  if (paise == null) return 'Not disclosed';
  return `${(paise / 100000).toFixed(1)} ${unit}`;
});

{{! usage }}
<p>{{format-ctc @job.ctc unit='LPA'}}</p>

<label for={{this.inputId}}>Notice period</label>
<input id={{this.inputId}} {{on 'change' (fn this.update 'noticePeriod')}} />

<button type="button" {{on 'click' (fn this.remove @row) once=true}}>
  Remove
</button>

Key Points

  • Helpers transform values and must stay pure, no DOM and no side effects
  • {{on}} attaches and auto-removes a DOM listener
  • {{fn}} partially applies arguments; the DOM event arrives last
  • Element access belongs in a modifier, not a helper
Q12

What is the Ember Data store, and how do findRecord, peekRecord, query, queryRecord and findAll differ?

BasicEmber Data

Answer

The store is the identity map and the gateway to your API. Every record of a given type and id exists exactly once in the store, so two components that load user 42 get the same object and both update when it changes. The five lookup methods differ along two axes: whether they hit the network, and whether they return one record or many. findRecord('job', '7') returns a promise, serves the cached record immediately if present and by default kicks off a background reload to refresh it, or goes to the network if the record is unknown. peekRecord('job', '7') is synchronous, returns the cached record or null, and never touches the network, which makes it the right call inside a getter or a template-driven code path where you must not start a request. query('job', { city: 'Bengaluru' }) always hits the network with those params and resolves to an array snapshot for that query. queryRecord('user', { me: true }) is the same but expects a single record, which is the standard way to load the logged-in user when the API has no stable id for it. findAll('job') returns a live record array of every job of that type, resolves from cache and reloads in the background, and is a frequent performance mistake against endpoints with tens of thousands of rows. peekAll is its offline sibling.

Interviewers probe the caching behaviour with a scenario: you save a record on one screen and another screen shows stale data. The answer is usually that the second screen used a query snapshot rather than the live record, or that shouldBackgroundReloadRecord was turned off on the adapter.

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class JobsIndexRoute extends Route {
  @service store;

  model({ city, page }) {
    // network every time, with server-side pagination
    return this.store.query('job', { filter: { city }, page: { number: page } });
  }
}

// elsewhere, synchronous cache read: safe inside a getter
get cachedJob() {
  return this.store.peekRecord('job', this.args.jobId);
}

// force a fresh fetch and skip the cache
await this.store.findRecord('job', id, { reload: true });

// cache-first, no background request at all
await this.store.findRecord('job', id, { backgroundReload: false });

Key Points

  • Identity map: one object per type plus id across the whole app
  • peekRecord and peekAll are synchronous and never hit the network
  • findRecord and findAll serve cache first, then reload in background
  • query and queryRecord always hit the network with the given params
💡 Pro Tip: Reach for findAll only on genuinely small reference collections such as skill categories or cities. On a jobs table it will happily try to materialise the entire table into memory.
Q13

How do you define an Ember Data model, and why does the inverse option on relationships matter?

BasicEmber Data

Answer

A model is a class extending Model from '@ember-data/model' with fields declared using the @attr, @belongsTo and @hasMany decorators. @attr('string'), @attr('number'), @attr('boolean') and @attr('date') use the built-in transforms, @attr() with no type passes the raw value straight through, and you can register custom transforms for things like a money type or an ISO duration. Relationships take an explicit type string plus an options object, and in current Ember Data the async and inverse options are required rather than optional. That requirement exists because inverse resolution used to be guessed, and the guess was wrong often enough to produce genuinely confusing bugs: you set job.company and company.jobs did not update, or worse, two different relationships silently mapped onto each other.

Setting inverse: 'jobs' tells the store which relationship on the other side to keep in sync, and inverse: null explicitly says there is no back reference, which is correct for things like an audit log entry pointing at a user. The async flag decides what you get when you read the property: with async: true you get a promise-like proxy that triggers a fetch, and with async: false you get the already-loaded records or an error if they were never side-loaded. Interviewers like asking what happens when you read an async hasMany in a template. The answer is that Glimmer awaits the proxy for you, so it renders once resolved, but in JavaScript you must await it explicitly.

// app/models/job.js
import Model, { attr, belongsTo, hasMany } from '@ember-data/model';

export default class JobModel extends Model {
  @attr('string') title;
  @attr('number') ctc;
  @attr('boolean', { defaultValue: false }) isRemote;
  @attr('date') postedAt;

  @belongsTo('company', { async: false, inverse: 'jobs' }) company;
  @hasMany('applicant', { async: true, inverse: 'job' }) applicants;

  get ctcLpa() {
    return this.ctc ? this.ctc / 100000 : null;
  }
}

// reading an async hasMany from JS
const applicants = await job.applicants;
console.log(applicants.length);

Key Points

  • @attr, @belongsTo, @hasMany with explicit async and inverse
  • inverse keeps both sides of a relationship in sync; null means none
  • async: true returns a promise proxy that triggers a fetch on read
  • Templates await the proxy automatically; JavaScript does not
Q14

Does the Ember run loop still exist in 2026, and where does Backburner still surface?

BasicRun Loop

Answer

Yes, it still exists, but you rarely write against it directly any more. Backburner is the scheduler underneath Ember. It batches work into named queues that drain in a fixed order: actions, then routerTransitions, then render, then afterRender, then destroy.

The point is coalescing. If ten tracked properties change in one click handler, Glimmer does not re-render ten times, it schedules one render flush at the end of the loop. In Octane code the framework opens and closes the loop for you around DOM events, route transitions and promise resolutions, so day to day you use plain async and await and never think about it.

Where it still surfaces is threefold. First, in code that enters Ember from outside, a raw setTimeout, a websocket message handler, a third-party library callback, where you either wrap the work in run() or, far better, use ember-concurrency which handles scheduling and cancellation together. Second, in tests: settled() from @ember/test-helpers resolves only when all queues are empty, all pending timers scheduled via @ember/runloop have fired, all AJAX has finished and no render is pending.

That is why a test hangs forever when you use a bare setInterval, because the test waiter never sees it and the runloop never quiets. Third, scheduleOnce('afterRender', ...) remains the correct way to read layout after Glimmer has flushed the DOM, though a modifier is usually the cleaner answer.

import { later, cancel, scheduleOnce, debounce } from '@ember/runloop';
import { registerDestructor } from '@ember/destroyable';

export default class PollingBanner extends Component {
  constructor(owner, args) {
    super(owner, args);
    this.timer = later(this, this.poll, 30_000);
    registerDestructor(this, () => cancel(this.timer));
  }

  poll() {
    this.args.refresh();
    this.timer = later(this, this.poll, 30_000);
  }

  measure(element) {
    // read layout only after Glimmer has flushed DOM
    scheduleOnce('afterRender', this, () => {
      this.height = element.getBoundingClientRect().height;
    });
  }
}

Key Points

  • Backburner queues: actions, routerTransitions, render, afterRender, destroy
  • Batching is why ten tracked writes cause one render
  • settled() in tests waits for queues, timers and pending requests
  • Bare setInterval and setTimeout hang tests; use runloop timers or tasks
💡 Pro Tip: Never call run() inside application code just to make a test pass. If a test needs it, the production code is entering Ember from an unscheduled callback and should be fixed there.
Q15

What causes the assertion 'You attempted to update X, but it had already been used previously in the same computation', and how do you fix it?

IntermediateReactivity

Answer

This is the backtracking rerender assertion, and it is the single most common Octane runtime error in real codebases. It fires when you write to a tracked property after that property has already been read during the current render pass. Glimmer has already committed DOM based on the old value, so mutating it now would either produce a stale screen or force an unbounded revalidation loop, and rather than silently do either, Ember throws.

The usual sources are all variations of doing work during render. A getter that assigns to another tracked field as a side effect, for example a get filteredRows() that also sets this.matchCount. A helper that caches a result onto tracked state.

A component constructor that reads an argument and then writes a tracked property that the same template already displayed. Or an ember-concurrency task started from a getter. The fix is never to wrap the write in a runloop scheduling call to push it past the render, even though that appears to work: it just defers the problem and creates a double render every frame.

The right fix is to make derived data a getter with no writes, move the side effect into an event handler or an element modifier that runs after render, or use the resource pattern so async work is owned by a modifier rather than by render. In development the assertion names the property and the object, which is usually enough to find the offending getter in one grep.

// BAD: getter with a side effect triggers the backtracking assertion
get visibleJobs() {
  const rows = this.args.jobs.filter((j) => j.isRemote);
  this.matchCount = rows.length; // <- writes tracked state during render
  return rows;
}

// GOOD: two pure getters, no writes at all
get visibleJobs() {
  return this.args.jobs.filter((j) => j.isRemote);
}

get matchCount() {
  return this.visibleJobs.length;
}

// GOOD: side effects triggered by real events, not by render
@action
onFilterChange(event) {
  this.filter = event.target.value;
  this.analytics.track('jobs.filter', { value: this.filter });
}

Key Points

  • Thrown when tracked state is written after being read in the same render
  • Typical causes: getters with side effects, constructors writing rendered state
  • Do not paper over it with runloop scheduling; that creates double renders
  • Derived data belongs in pure getters; side effects belong in modifiers or actions
Q16

When should you use the @cached decorator instead of a plain getter?

IntermediatePerformance

Answer

Getters in Octane are not memoized. Every time a template or another getter reads one, the body runs again, and Glimmer only avoids re-running it when nothing it consumed has changed and the surrounding render did not touch it. For cheap derivations, string concatenation, a boolean comparison, reading a nested property, that is faster than caching would be, because a cache costs a tag check and an allocation. @cached from @glimmer/tracking changes the contract: the getter runs once, its value is stored, and it only recomputes when one of the tracked values it consumed actually changes.

Reach for it in three situations. First, genuinely expensive computation: sorting or grouping a few thousand records, running a regex over a large string, formatting dates with Intl in a loop. Second, when the getter returns a new object or array identity and something downstream depends on that identity, for example an {{#each}} over a derived array, because without caching every render produces a brand new array and Glimmer may do more DOM work than necessary.

Third, when a getter is read many times in one render, such as inside a loop body. The trap to mention in an interview is that @cached memoizes on tracked dependencies only. If the getter reads untracked state, plain class fields, Date.now(), a global, the cache will happily serve a stale value forever, and that bug is much harder to find than a slow getter.

import Component from '@glimmer/component';
import { cached, tracked } from '@glimmer/tracking';

export default class ApplicantTable extends Component {
  @tracked sortKey = 'score';
  @tracked direction = 'desc';

  @cached
  get sorted() {
    // 5k rows: without @cached this runs on every single render
    const rows = [...this.args.applicants];
    rows.sort((a, b) => {
      const delta = a[this.sortKey] > b[this.sortKey] ? 1 : -1;
      return this.direction === 'asc' ? delta : -delta;
    });
    return rows;
  }

  @cached
  get byStage() {
    return Object.groupBy(this.sorted, (row) => row.stage);
  }
}

Key Points

  • Plain getters are uncached and re-run on every read
  • @cached memoizes until a consumed tracked value changes
  • Use it for expensive work, stable array identity, or hot loops
  • Untracked dependencies make @cached serve stale values silently
💡 Pro Tip: Profile before adding @cached everywhere. In the Ember Inspector's render tree you can see which components re-render and how long each takes, which usually points at one hot getter rather than a broad problem.
Q17

How do element modifiers work, and what is the difference between the function form and a class extending Modifier?

IntermediateComponents

Answer

Element modifiers are how Octane replaced didInsertElement, didRender and willDestroyElement. A modifier receives the DOM element and runs when it is inserted and again whenever its tracked arguments change. The function form from ember-modifier is the right default: modifier((element, positional, named) => { ... }) where returning a function registers teardown, which runs before the element is removed and before every re-run.

That single return value handles both cases correctly, which is exactly what people got wrong by hand with the old lifecycle hooks. The class form extends Modifier from ember-modifier and implements a single modify(element, positional, named) method, which in version 4 of the addon replaced the earlier didInstall, didUpdateArguments and didReceiveArguments trio. Use the class when you need injected services, when you want to hold instance state across re-runs, or when teardown needs access to that state, and register cleanup with registerDestructor from @ember/destroyable rather than a willDestroy override.

The important semantic to get right in an interview is that a modifier re-runs when any tracked value it consumed changes, including arguments and any tracked property read inside the body, so a modifier that reads this.args.rows will rerun on every row change. If you want it to run once only, read the values you need outside the reactive path or guard with a flag. Third-party integration, charting libraries, drag and drop, focus traps, IntersectionObserver, map widgets, is the canonical use case.

// app/modifiers/on-visible.js  (function form)
import { modifier } from 'ember-modifier';

export default modifier((element, [callback], { rootMargin = '200px' }) => {
  const observer = new IntersectionObserver(
    (entries) => entries[0].isIntersecting && callback(),
    { rootMargin }
  );
  observer.observe(element);
  return () => observer.disconnect(); // teardown on destroy AND before re-run
});

// app/modifiers/autofocus.js  (class form with a service)
import Modifier from 'ember-modifier';
import { service } from '@ember/service';
import { registerDestructor } from '@ember/destroyable';

export default class AutofocusModifier extends Modifier {
  @service a11y;

  modify(element, [enabled = true]) {
    if (enabled) element.focus();
    registerDestructor(this, () => this.a11y.restoreFocus());
  }
}

{{! usage: infinite scroll sentinel }}
<div {{on-visible this.loadNextPage rootMargin='400px'}}></div>

Key Points

  • Function modifier returns its teardown; class modifier implements modify()
  • ember-modifier v4 collapsed the old install and update hooks into modify()
  • Re-runs whenever any consumed tracked value changes, not just on insert
  • Use registerDestructor for cleanup in class modifiers
Q18

Explain ember-concurrency task modifiers: restartable, drop, enqueue and keepLatest. When do you pick each?

IntermediateConcurrency

Answer

ember-concurrency is how Ember apps model asynchronous work, and every serious Ember interview covers it. A task is a cancellable async operation whose state you can read from the template. The modifier decides what happens when a task is performed while an instance is already running. restartable cancels the running instance and starts a new one, which is correct for typeahead search: each keystroke should abandon the previous request. drop ignores the new perform entirely while one is running, which is correct for a submit button, because it prevents the double-payment class of bug without any manual disabled flag. enqueue queues the new instance and runs them strictly in order, which suits sequential writes such as autosave. keepLatest ignores everything except the most recent pending perform, running it once the current one finishes, which suits a refresh button hammered during a slow request.

You can also cap parallelism with maxConcurrency, for example task({ enqueue: true, maxConcurrency: 3 }) for a bulk uploader. The reason tasks beat a plain async method is cancellation and derived state together: when the component is destroyed, running instances are cancelled automatically, so you never resume after teardown and write to a destroyed object. And the task object exposes isRunning, isIdle, performCount, last, lastSuccessful and last.value, which lets a template show spinners and results with no extra tracked bookkeeping. Combining yield timeout(300) with restartable gives you debounce and cancellation in two lines.

import Component from '@glimmer/component';
import { service } from '@ember/service';
import { restartableTask, dropTask, timeout } from 'ember-concurrency';

export default class JobSearch extends Component {
  @service store;

  @restartableTask
  *search(event) {
    const term = event.target.value;
    yield timeout(300);            // debounce; cancelled by the next keystroke
    return yield this.store.query('job', { q: term, city: 'Bengaluru' });
  }

  @dropTask
  *apply(job) {
    // double clicks are ignored while this instance runs
    yield this.store.createRecord('application', { job }).save();
  }
}

{{! template }}
<input {{on 'input' (perform this.search)}} />
{{#if this.search.isRunning}}<Spinner />{{/if}}
{{#each this.search.lastSuccessful.value as |job|}}
  <JobCard @job={{job}} />
{{/each}}
<button type="button" disabled={{this.apply.isRunning}} {{on 'click' (perform this.apply @job)}}>
  Apply
</button>

Key Points

  • restartable for typeahead, drop for submit, enqueue for ordered writes
  • keepLatest for refresh buttons; maxConcurrency caps parallel instances
  • Instances are cancelled automatically when the host object is destroyed
  • isRunning, last.value and lastSuccessful remove manual state bookkeeping
💡 Pro Tip: If an interviewer asks how you would debounce search without an addon, say setTimeout plus manual clearTimeout plus a destroyed check, then point out that restartable plus timeout does all three correctly in one line.
Q19

How do you prevent memory leaks in a long-lived Ember SPA?

IntermediateMemory Management

Answer

Ember apps often stay open for an entire working day, which is exactly the scenario in which small leaks compound into a browser tab using two gigabytes. Four sources cover most real cases. First, listeners attached to objects that outlive the component: window, document, a websocket, or a service.

A component that does window.addEventListener('resize', this.onResize) in its constructor and never removes it keeps the whole component graph alive forever. Fix that with registerDestructor from @ember/destroyable, or better, attach the listener from a modifier that returns its own teardown. Second, timers.

Any setInterval or setTimeout must be cancelled on destroy; ember-concurrency tasks and ember-lifeline's runTask and pollTask cancel themselves automatically, which is why teams standardise on them. Third, services accumulating state, a toast service that pushes into an array nobody empties, or a cache service keyed by record id that never evicts. Services are singletons for the life of the app, so anything you put in one is permanent unless you remove it.

Fourth, async continuing after teardown: an await resolves, the component is long gone, and setting a tracked property on it throws or silently resurrects references. Guard with isDestroyed or isDestroying from @ember/destroyable, or let a task handle it. To prove a leak in an interview scenario, describe the workflow: take a Chrome heap snapshot, transition between two routes twenty times, snapshot again, and filter detached DOM nodes and component instances by class name. Growth proportional to the number of transitions is your leak.

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import { registerDestructor, isDestroyed } from '@ember/destroyable';

export default class LiveApplicantCount extends Component {
  @service socket;
  @tracked count = 0;

  constructor(owner, args) {
    super(owner, args);

    const onMessage = (msg) => (this.count = msg.count);
    this.socket.on('applicants', onMessage);
    const onResize = () => this.measure();
    window.addEventListener('resize', onResize, { passive: true });

    registerDestructor(this, () => {
      this.socket.off('applicants', onMessage);
      window.removeEventListener('resize', onResize);
    });
  }

  async refresh() {
    const data = await this.args.load();
    if (isDestroyed(this)) return;   // component left the screen mid-request
    this.count = data.count;
  }
}

Key Points

  • registerDestructor from @ember/destroyable is the canonical cleanup hook
  • Guard post-await writes with isDestroyed or isDestroying
  • Singleton services keep everything you put in them for the app's lifetime
  • Diagnose with repeated route transitions plus Chrome heap snapshots
Q20

How do query parameters work in Ember, and what do refreshModel and replace actually control?

IntermediateRouting

Answer

Query params are declared on the controller with a queryParams array and backed by tracked properties whose initial values are the defaults. Ember then keeps the URL and those properties in sync in both directions: typing ?page=3 into the address bar sets controller.page, and setting controller.page updates the URL. You can rename the URL key with a mapping object, so [{ page: 'p' }] exposes ?p=3 while the property stays readable in code.

The route configures behaviour per param through its own queryParams hash. refreshModel: true tells Ember to re-run the model hook when that param changes, which is what you want for server-side filtering and pagination; leaving it false means the param changes but the data does not reload, which is right for pure client-side concerns such as a sort direction or an open tab. replace: true makes the transition use history.replaceState instead of pushState, so rapid filter changes do not fill the back button with dozens of entries. Two behaviours cause real bugs. First, a param whose default is a number is deserialized back to a number, but a param defaulting to a string or null stays a string, so page === 2 fails while page === '2' passes; declare numeric defaults as numbers.

Second, params at their default value are omitted from the URL entirely, which is usually desirable but surprises people building shareable links. And because controllers are singletons, a param you set on one visit persists into the next unless you clear it in resetController with the isExiting flag.

// app/controllers/jobs/index.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';

export default class JobsIndexController extends Controller {
  queryParams = [{ page: 'p' }, 'city', 'remoteOnly'];

  @tracked page = 1;        // number default -> deserialized as a number
  @tracked city = '';
  @tracked remoteOnly = false;
}

// app/routes/jobs/index.js
export default class JobsIndexRoute extends Route {
  queryParams = {
    page: { refreshModel: true, replace: true },
    city: { refreshModel: true },
    remoteOnly: { refreshModel: false }, // filtered client side
  };

  model({ page, city }) {
    return this.store.query('job', { page: { number: page }, filter: { city } });
  }

  resetController(controller, isExiting) {
    if (isExiting) {
      controller.page = 1;
      controller.city = '';
    }
  }
}

Key Points

  • Declared on the controller, configured on the route
  • refreshModel re-runs the model hook; use it for server-side filtering
  • replace: true avoids polluting browser history on rapid filter changes
  • Defaults are omitted from the URL, and controllers are singletons
Q21

How do you adapt Ember Data to a backend that does not speak JSON:API, and what does shouldBackgroundReloadRecord control?

IntermediateEmber Data

Answer

This is the reality of most Indian product teams, where the Ember frontend talks to a Spring Boot, Rails, Django or Express API that returns snake_case JSON with its own envelope. The adapter owns the network layer: which host and namespace to hit, what headers to send, how to build URLs, and how to interpret status codes. The serializer owns the shape: turning the server payload into the normalized form Ember Data stores, and turning records back into request bodies.

Start from RESTAdapter and RESTSerializer if the API is a conventional REST shape, or JSONAPIAdapter if it genuinely follows the spec. Override keyForAttribute and keyForRelationship to map snake_case to camelCase, override normalizeResponse when the payload is wrapped in an envelope like { data, meta, status }, and override serialize with the { includeId: true } option when the write shape differs from the read shape. In the adapter, a headers getter that reads the auth token from a session service is standard, and handleResponse is where you translate a 401 into a session invalidation or a 422 into an InvalidError so record.errors populates. shouldBackgroundReloadRecord controls one specific thing: when findRecord finds a cached record and returns it immediately, does the store also fire a request in the background and update the record when it lands.

Default is true, which keeps the screen fresh but doubles request volume on navigation-heavy apps. Return false for reference data that rarely changes, and pair it with shouldReloadRecord if you want the opposite behaviour of always waiting for fresh data.

// app/adapters/application.js
import RESTAdapter from '@ember-data/adapter/rest';
import { InvalidError } from '@ember-data/adapter/error';
import { service } from '@ember/service';

export default class ApplicationAdapter extends RESTAdapter {
  @service session;
  host = 'https://api.example.in';
  namespace = 'v2';

  get headers() {
    return { Authorization: `Bearer ${this.session.token}` };
  }

  shouldBackgroundReloadRecord(store, snapshot) {
    return !['city', 'skill-category'].includes(snapshot.modelName);
  }

  handleResponse(status, headers, payload) {
    if (status === 401) { this.session.invalidate(); }
    if (status === 422) { return new InvalidError(payload.field_errors); }
    return super.handleResponse(...arguments);
  }
}

// app/serializers/application.js
import RESTSerializer from '@ember-data/serializer/rest';
import { underscore } from '@ember/string';

export default class ApplicationSerializer extends RESTSerializer {
  keyForAttribute(key) { return underscore(key); }
  keyForRelationship(key) { return `${underscore(key)}_id`; }
}

Key Points

  • Adapter owns transport, serializer owns payload shape
  • keyForAttribute and normalizeResponse handle snake_case and envelopes
  • handleResponse maps 401 to logout and 422 to a populated record.errors
  • shouldBackgroundReloadRecord toggles the silent refresh after a cache hit
💡 Pro Tip: Recent Ember versions ship @ember/string as a separate npm package rather than a built-in, so add it to package.json explicitly if the import fails after an upgrade.
Q22

What is the RequestManager in Ember Data 5.x, and how does it differ from the adapter and serializer layer?

IntermediateEmber Data

Answer

Ember Data 5.x introduced a new request pipeline that sits beside, and eventually replaces, the adapter and serializer pair. The newer packages ship under the WarpDrive name, and the core idea is a middleware chain instead of a class hierarchy. You construct a RequestManager, register an ordered list of handlers, and each handler is a plain object with a request(context, next) method.

A handler can inspect or rewrite the outgoing request, call next to pass control down the chain, and transform the response on the way back. The last handler is usually Fetch, which actually performs the HTTP call. CacheHandler, registered with useCache, is what integrates the store's cache so responses populate records exactly as before.

Instead of store.findRecord you call store.request(builder), where builders such as findRecord, query and createRecord come from @ember-data/json-api/request and return a plain request object describing the URL, method, headers and cache key. The result is a Future that resolves to a structured document, so you read result.content rather than getting the record directly. Two practical points for an interview.

First, migration is incremental: LegacyNetworkHandler at the front of the chain lets existing adapters and serializers keep working while new call sites use builders, so a large app moves route by route. Second, the middleware model finally makes cross-cutting concerns clean. Auth headers, retry with backoff, request deduplication, tracing headers and offline queuing become handlers rather than adapter subclass overrides that fight each other.

// app/services/store.js
import Store, { CacheHandler } from '@ember-data/store';
import RequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
import { findRecord } from '@ember-data/json-api/request';

const AuthHandler = {
  async request(context, next) {
    const headers = new Headers(context.request.headers);
    headers.append('Authorization', `Bearer ${window.__token}`);
    const result = await next({ ...context.request, headers });
    return result;
  },
};

export default class AppStore extends Store {
  requestManager = new RequestManager()
    .use([LegacyNetworkHandler, AuthHandler, Fetch])
    .useCache(CacheHandler);
}

// call site
const { content } = await store.request(
  findRecord('job', '7', { include: ['company', 'applicants'] })
);

Key Points

  • RequestManager is a middleware chain of handlers, not a class hierarchy
  • Fetch performs the call; CacheHandler wires responses into the store cache
  • Builders return request objects; store.request() resolves to a document
  • LegacyNetworkHandler allows incremental migration from adapters
Q23

How do you handle record state and validation errors: hasDirtyAttributes, rollbackAttributes and record.errors?

IntermediateEmber Data

Answer

Every Ember Data record carries state flags you should be able to name on demand: isNew for a record created locally and never persisted, hasDirtyAttributes for unsaved local changes, isSaving while a request is in flight, isDeleted after deleteRecord, isValid which flips false when the server returns validation errors, and isError for a failed save. changedAttributes() returns a map of attribute names to old and new value pairs, which is the cleanest way to build a diff for an audit trail or to send a PATCH with only the changed fields. rollbackAttributes() discards local changes and returns the record to its last known server state, and on a record that isNew it removes the record from the store entirely. That last behaviour is why the classic form pattern is store.createRecord in a controller action, then rollbackAttributes in resetController or in a willTransition handler; skipping it leaves orphan unsaved records that show up in every findAll and live array in the app, which is a very common bug in Ember forms. For validation, the adapter should convert a 422 into an InvalidError with JSON:API formatted source pointers, and Ember Data then populates record.errors keyed by attribute so a template can render errors directly under each field.

Wrap save in try and catch, because a rejected save throws and an unhandled rejection kills the interaction with no user feedback. Interviewers often ask the difference between deleteRecord plus save and destroyRecord: the second is simply the two combined.

import Component from '@glimmer/component';
import { service } from '@ember/service';
import { dropTask } from 'ember-concurrency';

export default class JobForm extends Component {
  @service store;
  @service router;

  job = this.args.job ?? this.store.createRecord('job');

  @dropTask
  *save() {
    try {
      yield this.job.save();
      this.router.transitionTo('jobs.show', this.job);
    } catch (e) {
      // record.errors is populated from the InvalidError payload
      console.warn(this.job.errors.map((e) => `${e.attribute}: ${e.message}`));
    }
  }

  willDestroy() {
    super.willDestroy(...arguments);
    if (this.job.hasDirtyAttributes) this.job.rollbackAttributes();
  }
}

{{! template }}
{{#each this.job.errors.title as |error|}}
  <p class="error">{{error.message}}</p>
{{/each}}

Key Points

  • isNew, hasDirtyAttributes, isSaving, isDeleted, isValid, isError
  • changedAttributes() gives old and new values for a PATCH or audit diff
  • rollbackAttributes() unloads a record that was never persisted
  • A 422 mapped to InvalidError populates record.errors per attribute
Q24

What is the difference between setupTest, setupRenderingTest and setupApplicationTest?

IntermediateTesting

Answer

Ember ships a three-tier testing story and knowing which tier fits a scenario is a standard interview filter. setupTest gives you a container and a resolver but no DOM rendering. Use it for unit tests of services, models, utilities, adapters and serializers, reaching into the container with this.owner.lookup('service:current-user'). It is the fastest tier and the right place to test pure logic. setupRenderingTest adds a rendering context, so you can call render(hbs`<JobCard @job={{this.job}} />`) and assert against real DOM with qunit-dom's assert.dom API.

This is where the bulk of a healthy Ember suite lives, because a component test runs in milliseconds and covers actual markup, accessibility attributes and event handling. You set test context state with this.setProperties, and you can register stub services on this.owner before rendering. setupApplicationTest boots the whole application, so you use visit('/jobs'), currentURL(), and the full click and fillIn helpers against the real router and real route hooks. These are your end-to-end tests: slower, more brittle, and worth writing only for critical flows such as login, checkout or applying to a job.

All three come from ember-qunit and all three integrate with @ember/test-helpers, which provides the interaction helpers that await settled state automatically. The mistake to call out is writing application tests for things a rendering test would cover, which is how Indian teams with large legacy suites end up with a forty minute CI run that everyone ignores.

import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import Service from '@ember/service';

class StubAnalytics extends Service {
  events = [];
  track(name) { this.events.push(name); }
}

module('Integration | Component | job-card', function (hooks) {
  setupRenderingTest(hooks);

  test('applying fires an analytics event', async function (assert) {
    this.owner.register('service:analytics', StubAnalytics);
    this.set('job', { id: '1', title: 'Backend Engineer', ctc: 1800000 });

    await render(hbs`<JobCard @job={{this.job}} />`);
    assert.dom('[data-test-title]').hasText('Backend Engineer');

    await click('[data-test-apply]');
    const analytics = this.owner.lookup('service:analytics');
    assert.deepEqual(analytics.events, ['job.apply']);
  });
});

Key Points

  • setupTest: container only, for services, models, utils and serializers
  • setupRenderingTest: render() plus assert.dom, the bulk of a good suite
  • setupApplicationTest: full boot with visit() and currentURL()
  • Every @ember/test-helpers interaction awaits settled state for you
Q25

Why do Ember tests hang or go flaky, and how do settled, waitUntil, waitFor and test waiters fix it?

IntermediateTesting

Answer

Ember's test helpers are auto-waiting: every await click, await fillIn and await visit resolves only once the application is settled, meaning the runloop queues are empty, there are no pending runloop timers, no in-flight requests tracked by the framework, no pending render, and every registered test waiter reports done. This is why well-written Ember tests need almost no manual waiting, and it is also why the two classic failures happen. A test that hangs until timeout is nearly always an application that never settles: a bare setInterval polling every second, a raw setTimeout retry loop, an ember-concurrency task that loops with timeout forever, or an animation library holding a permanent rAF.

Fix the production code, or use the maybeHidden pattern where polling only runs when the document is visible, or register the loop with the test framework properly. A test that is flaky instead of hanging usually awaits the wrong thing: it clicks, then asserts immediately on something that renders after an unrelated promise, so it passes on a fast machine and fails on a loaded CI runner. waitFor('[data-test-toast]') waits for a selector to appear, waitUntil(() => predicate) waits for an arbitrary condition with a timeout, and settled() waits for full quiescence. For async that Ember cannot see, native fetch inside an addon, a third-party SDK callback, wrap it with buildWaiter from @ember/test-waiters so settled learns about it. pauseTest() in a test freezes the browser so you can inspect the DOM, and resumeTest() from the console continues.

import { buildWaiter } from '@ember/test-waiters';

const waiter = buildWaiter('gs-portal:resume-upload');

export async function uploadResume(file) {
  const token = waiter.beginAsync();
  try {
    return await fetch('/api/v2/resumes', { method: 'POST', body: file });
  } finally {
    waiter.endAsync(token); // settled() now waits for this correctly
  }
}

// in a test
import { visit, click, waitFor, waitUntil, settled } from '@ember/test-helpers';

test('upload shows a toast', async function (assert) {
  await visit('/profile');
  await click('[data-test-upload]');
  await waitFor('[data-test-toast]', { timeout: 3000 });
  await waitUntil(() => this.owner.lookup('service:uploads').queue.length === 0);
  await settled();
  assert.dom('[data-test-toast]').includesText('Resume uploaded');
});

Key Points

  • Helpers auto-await settled: runloop, timers, requests, render, waiters
  • Hangs mean the app never settles, usually a bare setInterval or rAF loop
  • waitFor for a selector, waitUntil for a predicate, settled for quiescence
  • buildWaiter from @ember/test-waiters teaches settled about hidden async
💡 Pro Tip: If a suite times out only on CI, run it locally with ember test --server and a throttled network profile. Nine times out of ten it is a poll or animation loop that never lets the app settle.
Q26

How do you mock APIs in Ember tests, and what does ember-test-selectors give you?

IntermediateTesting

Answer

ember-cli-mirage is still the default in most Ember codebases. It intercepts requests with Pretender, and unlike a plain HTTP stub it gives you a whole fake backend: factories to generate records, a schema with relationships, serializers that mirror your real payload shape, and route handlers you write once and reuse across development and tests. In development you get a working app with no backend running at all, which matters when the API team is behind, and in tests you call setupMirage(hooks) then seed data per test with this.server.createList('job', 5, { city: 'Pune' }).

You override individual endpoints inside a test to force error paths, which is the only sane way to test a 422 or a 500 branch. Set this.server.timing = 0 in tests so nothing waits artificially. Newer projects sometimes prefer msw, which intercepts at the service worker or Node level and can be shared with a non-Ember codebase, at the cost of losing the factory and relationship layer. ember-test-selectors is a small but important companion: it lets you write data-test-title style attributes in templates and strips them from production builds, so your tests never depend on CSS classes or DOM structure that a designer will change next sprint. It can also be configured to fail the build if a test uses a non data-test selector, which is a rule many teams adopt after their first big CSS refactor breaks two hundred tests.

// mirage/config.js
export default function () {
  this.namespace = 'api/v2';
  this.get('/jobs', (schema, request) => {
    const city = request.queryParams['filter[city]'];
    return city ? schema.jobs.where({ city }) : schema.jobs.all();
  });
  this.post('/applications', () => ({ errors: [{ detail: 'Already applied' }] }), 422);
}

// tests/acceptance/jobs-test.js
import { setupMirage } from 'ember-cli-mirage/test-support';

module('Acceptance | jobs', function (hooks) {
  setupApplicationTest(hooks);
  setupMirage(hooks);

  test('filters by city', async function (assert) {
    this.server.timing = 0;
    this.server.createList('job', 3, { city: 'Pune' });
    this.server.createList('job', 2, { city: 'Bengaluru' });

    await visit('/jobs?city=Pune');
    assert.dom('[data-test-job-row]').exists({ count: 3 });
  });
});

Key Points

  • Mirage gives factories, a schema and relationships, not just request stubs
  • Override endpoints per test to exercise 422 and 500 branches
  • msw is the alternative when mocks are shared outside the Ember app
  • data-test-* attributes are stripped in production by ember-test-selectors
Q27

What is the owner, and when do you use getOwner, owner.lookup, owner.register and factoryFor?

IntermediateDependency Injection

Answer

The owner is Ember's dependency injection container plus registry, one per running application or engine instance. Everything the framework instantiates, routes, controllers, components, services, adapters, is created through it and has the owner set on it, which is what makes @service injection work. getOwner(this) from @ember/owner returns it from any framework-created object. owner.lookup('service:current-user') returns the singleton, creating it on first call. owner.register('service:analytics', SomeClass) adds or replaces a registration, and passing { instantiate: false } registers a plain value rather than a class, which is how you inject configuration objects. factoryFor('component:job-card') gives you a factory whose class property is the raw class and whose create() method produces an instance with the owner already set, which is the correct way to instantiate framework objects programmatically. The place this knowledge earns money is testing and plain classes.

In a test, this.owner.register before the first lookup swaps a real service for a stub, so a component that injects a payment service never touches the network. And when you write an ordinary class that is not created by Ember, a model wrapper, a state machine, a validation object, injecting services into it fails with a missing owner error until you call setOwner(instance, getOwner(this)) after construction. Interviewers ask this because a candidate who understands the owner can debug resolution failures, and one who does not resorts to importing singletons directly, which breaks tests and FastBoot.

import { getOwner, setOwner } from '@ember/owner';
import { service } from '@ember/service';

// a plain class, not created by the framework
export class ApplicationWizard {
  @service store;
  @service router;

  constructor(context, job) {
    setOwner(this, getOwner(context)); // without this, @service throws
    this.job = job;
  }
}

// used from a component
export default class ApplyButton extends Component {
  wizard = new ApplicationWizard(this, this.args.job);
}

// container introspection and test stubbing
const owner = getOwner(this);
owner.lookup('service:current-user');
owner.register('config:app', { maxUploadMb: 5 }, { instantiate: false });
const { class: JobCardClass } = owner.factoryFor('component:job-card');

Key Points

  • One owner per app or engine instance; it powers @service resolution
  • lookup returns singletons, factoryFor returns an owner-aware factory
  • owner.register in tests swaps a real service for a stub
  • setOwner is required for plain classes that need injections
Q28

How do you implement authentication guards and page-view tracking using the RouterService?

IntermediateRouting

Answer

The RouterService, injected as @service router, is the supported public API for everything routing related outside a Route class. It exposes currentURL, currentRouteName, currentRoute as a RouteInfo tree, transitionTo, replaceWith, refresh, urlFor, isActive and recognize, plus two events. routeWillChange fires before a transition, giving you transition.from and transition.to as RouteInfo objects with params and query params, and it is where you can abort. routeDidChange fires after the transition completes and is the correct hook for analytics, because it only fires for transitions that actually succeeded, unlike the old didTransition action which people wired up in the application route. For authentication, the pattern is a guard in beforeModel on the protected route or on a shared parent route, storing the attempted transition so you can retry it after login. ember-simple-auth packages this as session.requireAuthentication(transition, 'login') plus session.handleAuthentication('index'), and its AuthenticatedRouteMixin era has been replaced by explicit calls in beforeModel, which is better because the control flow is visible.

Two subtleties worth mentioning. transition.abort() stops the transition but leaves the URL pointing at the aborted destination in some browser flows, so pair it with an explicit transitionTo or replaceWith to the login route. And transition.retry() returns a fresh transition you must return or await, otherwise you get a race where the post-login redirect competes with the default landing route.

// app/services/analytics.js
import Service, { service } from '@ember/service';
import { registerDestructor } from '@ember/destroyable';

export default class AnalyticsService extends Service {
  @service router;

  constructor() {
    super(...arguments);
    this.router.on('routeDidChange', this.onRouteDidChange);
    registerDestructor(this, () =>
      this.router.off('routeDidChange', this.onRouteDidChange)
    );
  }

  onRouteDidChange = (transition) => {
    window.dataLayer?.push({
      event: 'page_view',
      route: this.router.currentRouteName,
      url: this.router.currentURL,
      from: transition.from?.name ?? null,
    });
  };
}

// app/routes/dashboard.js
beforeModel(transition) {
  if (!this.session.isAuthenticated) {
    this.session.attemptedTransition = transition;
    transition.abort();
    return this.router.replaceWith('login');
  }
}

// after a successful login
const attempted = this.session.attemptedTransition;
attempted ? await attempted.retry() : this.router.transitionTo('dashboard');

Key Points

  • routeWillChange for guards and aborts, routeDidChange for analytics
  • transition.from and transition.to are RouteInfo objects with params
  • Store the transition and call retry() after successful login
  • Always unsubscribe router events in a destructor to avoid leaks
Q29

What does Embroider actually change, and what typically breaks when you migrate a large app to it?

AdvancedBuild Pipeline

Answer

Embroider replaces the classic Broccoli pipeline with a two-stage build: a compat stage that rewrites the app and its v1 addons into plain, spec-compliant ES modules, then a real bundler, Webpack originally and Vite in the current generation. The payoff is everything a standard bundler gives you: tree shaking, real code splitting, ES module semantics, dramatically faster rebuilds, and a dev server that starts in about a second instead of tens of seconds on a large codebase. The catch is that classic Ember resolution is dynamic.

A template that writes {{component (concat 'icons/' @name)}} means any component could be needed, so the bundler cannot drop anything. That is what the static flags control. staticComponents, staticHelpers and staticModifiers tell Embroider to resolve those references at build time, which is what unlocks tree shaking but also what breaks dynamic lookups. splitAtRoutes takes an array of route names and emits a separate lazily loaded bundle per subtree. Migration usually stalls on four things: templates using string-built component names, which you fix by passing the component itself with the {{component}} helper or by importing it in a .gjs file; app.import of vendor scripts, which becomes a normal npm import or an importSync from @embroider/macros; v1 addons that reach into other addons' broccoli trees, which need upgrading or replacing; and code doing owner.lookup with a computed string. Do it in stages: turn on staticAddonTrees first, then helpers and modifiers, then components last, running the full test suite at each step.

// ember-cli-build.js (Webpack flavour)
const EmberApp = require('ember-cli/lib/broccoli/ember-app');

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {
    'ember-cli-babel': { enableTypeScriptTransform: true },
  });

  const { Webpack } = require('@embroider/webpack');
  return require('@embroider/compat').compatBuild(app, Webpack, {
    staticAddonTrees: true,
    staticAddonTestSupportTrees: true,
    staticHelpers: true,
    staticModifiers: true,
    staticComponents: true,
    splitAtRoutes: ['recruiter', 'admin', 'reports'],
  });
};

// fixing a dynamic component name before enabling staticComponents
// before: {{component (concat 'icons/' @name)}}
// after:
import IconSearch from './icons/search';
import IconBell from './icons/bell';
const ICONS = { search: IconSearch, bell: IconBell };
get icon() { return ICONS[this.args.name]; }
// template: {{component this.icon}}

Key Points

  • Compat stage normalises to ES modules, then Webpack or Vite bundles
  • staticComponents, staticHelpers, staticModifiers unlock tree shaking
  • splitAtRoutes gives real per-route lazy bundles
  • Dynamic component names and app.import are the usual blockers
💡 Pro Tip: Recent Ember CLI blueprints can scaffold a Vite-based app directly, so for a greenfield project in 2026 you start on the modern pipeline instead of migrating to it later.
Q30

What are template tag components (.gjs and .gts), and what does strict mode change about resolution?

AdvancedTemplates

Answer

Template tag components put the template inside the JavaScript file using a <template> block, in .gjs files or .gts for TypeScript. The template compiles in strict mode, which is the real change. In classic loose mode a template name like <JobCard /> or {{format-ctc}} was resolved at runtime by the resolver walking the app's module namespace, which meant the bundler could never know what was used, error messages for typos appeared only when that branch rendered, and there was no way for TypeScript to check a template at all.

In strict mode nothing is implicitly in scope. Every component, helper and modifier must be imported, including built-ins: you import { on } from '@ember/modifier' and { fn, array, hash, get, concat } from '@ember/helper'. A typo becomes a compile error, jump to definition works in the editor, and the bundler can tree shake because usage is statically visible.

It also enables things loose mode could not express: defining several small components in one file, using a local constant directly in the template, and passing a component as an ordinary JavaScript value with no {{component}} helper. With .gts plus Glint you get full type checking across the template boundary, so passing a string where a component expects a number is caught at build time. This is the centrepiece of the Polaris-era Ember work, and in an interview it is worth saying that the migration is file by file rather than all at once, since a .gjs component and a classic .js plus .hbs pair coexist happily.

// app/components/job-list.gjs
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import JobCard from './job-card';
import formatCtc from '../helpers/format-ctc';

// a private component, never exported, never in the resolver
const Empty = <template>
  <p class="empty">No jobs match this filter.</p>
</template>;

export default class JobList extends Component {
  @tracked query = '';

  update = (event) => (this.query = event.target.value);

  get filtered() {
    const q = this.query.toLowerCase();
    return this.args.jobs.filter((j) => j.title.toLowerCase().includes(q));
  }

  <template>
    <input value={{this.query}} {{on 'input' this.update}} />
    {{#each this.filtered key='id' as |job|}}
      <JobCard @job={{job}} @onApply={{fn @apply job}}>
        {{formatCtc job.ctc}}
      </JobCard>
    {{else}}
      <Empty />
    {{/each}}
  </template>
}

Key Points

  • Strict mode: no implicit resolution, everything is imported
  • Built-ins move to @ember/modifier and @ember/helper imports
  • Enables multiple components per file and components as plain values
  • .gts plus Glint gives type checking inside templates
Q31

When would you reach for Ember Engines instead of splitAtRoutes, and how do engines share services?

AdvancedArchitecture

Answer

Both split a large app, but they solve different problems. splitAtRoutes is a bundling concern: the code for a route subtree is fetched on demand, while everything still lives in one application with one namespace, one container and one set of services. Reach for it first, because it is a one line config change and costs nothing architecturally. Engines are an isolation concern.

An engine is a mini application with its own routes, templates, components, services and resolver, mounted into the host at a URL with this.mount('recruiter-console', { path: '/recruiter' }). The isolation is real: the engine cannot see the host's services unless the host explicitly grants them, and it cannot transition to a host route unless that route is declared as an external route. That makes engines the right answer when separate teams own separate product areas and you want a hard boundary that survives code review turnover, or when you distribute a feature as an installable package to several host apps.

The cost is significant. Sharing is by explicit declaration, engine-aware addons are a smaller ecosystem, testing an engine in isolation needs its own dummy app, and debugging a broken dependency declaration is genuinely unpleasant. With lazyLoading enabled, the engine's assets download on first visit to its mount point, which is where the load time win comes from. In practice most teams should use splitAtRoutes plus disciplined folder structure, and only adopt engines when organisational boundaries, not bundle size, are the actual problem.

// host app/router.js
Router.map(function () {
  this.mount('recruiter-console', { path: '/recruiter' });
});

// host app/app.js grants what the engine may use
// lib/recruiter-console/addon/engine.js
import Engine from '@ember/engine';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';
import config from './config/environment';

export default class RecruiterConsoleEngine extends Engine {
  modulePrefix = config.modulePrefix;
  Resolver = Resolver;

  dependencies = {
    services: ['store', 'session', 'router', 'intl'],
    externalRoutes: ['login', 'billing'],
  };
}

// lib/recruiter-console/index.js
const EngineAddon = require('ember-engines/lib/engine-addon');
module.exports = EngineAddon.extend({
  name: 'recruiter-console',
  lazyLoading: { enabled: true },
});

// inside the engine, transitioning to a host route
this.router.transitionToExternal('billing');

Key Points

  • splitAtRoutes splits bundles; engines split namespaces and containers
  • Engines declare dependencies.services and dependencies.externalRoutes
  • lazyLoading defers engine assets until the mount point is visited
  • Choose engines for team boundaries, not merely for bundle size
Q32

What breaks under FastBoot server rendering, and how do the shoebox and the sandbox affect correctness?

AdvancedServer Rendering

Answer

FastBoot runs your Ember app in a Node sandbox and returns rendered HTML, which matters enormously for job boards, marketplaces and content sites in India where organic search is the main acquisition channel. Three categories of things break. First, browser globals.

There is no window, document, localStorage, navigator or IntersectionObserver, so any module-level code touching them throws during boot, and any component doing DOM measurement must be guarded. Inject @service fastboot and branch on this.fastboot.isFastBoot, or push the DOM work into an element modifier, since modifiers never run on the server. Second, double fetching.

Without help, the server fetches data to render, then the browser boots and fetches everything again, which is slower than not using FastBoot at all. The shoebox fixes it: on the server you put serialized payloads into this.fastboot.shoebox, FastBoot embeds them as script tags, and on the browser side you retrieve and push them into the store instead of refetching. Third, and most dangerous, the sandbox is reused across requests in some configurations, so module-scoped mutable state persists between users.

A cached auth token, a memoized current user, or a singleton holding request data in a module variable can leak one user's data into another user's page. Keep all per-request state in services, never in module scope. Also budget for memory: each request builds an application instance, so any leak that would take a day to matter in a browser tab takes minutes on a busy FastBoot server, and you should run it behind a process manager that recycles workers.

import Route from '@ember/routing/route';
import { service } from '@ember/service';

export default class JobsShowRoute extends Route {
  @service fastboot;
  @service store;

  async model({ job_id }) {
    const key = `job-${job_id}`;

    if (this.fastboot.isFastBoot) {
      const job = await this.store.findRecord('job', job_id);
      this.fastboot.shoebox.put(key, job.serialize({ includeId: true }));
      // set a real status code for crawlers
      if (!job) this.fastboot.response.statusCode = 404;
      return job;
    }

    const cached = this.fastboot.shoebox.retrieve(key);
    if (cached) {
      this.store.pushPayload('job', cached); // no second network call
      return this.store.peekRecord('job', job_id);
    }
    return this.store.findRecord('job', job_id);
  }
}

Key Points

  • No window, document or localStorage; guard with fastboot.isFastBoot
  • Modifiers never run on the server, which makes them the safe DOM hook
  • Shoebox transfers server-fetched payloads so the browser does not refetch
  • Module-scope state can leak between requests; keep it in services
💡 Pro Tip: If the page renders correctly on the server but flashes empty on hydration, the browser is discarding the server render because a component read a browser global during boot. Check the console for the very first error, not the loudest one.
Q33

An Ember table renders 5,000 rows and scrolling stutters. How do you diagnose and fix it?

AdvancedPerformance

Answer

Start by separating render cost from DOM cost. Open the Ember Inspector's render tree, which shows every component rendered with its timing, and the Chrome performance panel to see whether time goes into scripting or into layout and paint. Four causes account for most cases.

First, the {{#each}} key. The default is @identity, so if the backing array is replaced by a structurally equal but referentially new array, Glimmer cannot match rows and tears down and rebuilds every row. Set key='id' so rows persist across data refreshes.

Second, an uncached getter feeding the each. A get sortedRows() that sorts 5,000 items runs on every render pass and returns a new array each time, which also defeats keying; add @cached. Third, the DOM itself.

Five thousand rows times ten cells is fifty thousand nodes, and no framework makes that smooth. The real fix is occlusion, rendering only what is in the viewport, using vertical-collection or an equivalent virtual scroller, or server side pagination, which is usually the honest answer for a jobs or applicants table. Fourth, per-row work: a date formatted with Intl in every cell, a helper doing a regex, an inline {{fn}} allocating a closure per row per render.

Hoist formatting into the model layer or memoize the formatter. Beyond that, check for a re-render storm: a tracked property on a service that every row reads means one write invalidates all five thousand rows, and the fix is to scope that state per row or read it once in the parent.

import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';

const DATE = new Intl.DateTimeFormat('en-IN', { dateStyle: 'medium' });

export default class ApplicantTable extends Component {
  @cached
  get rows() {
    return this.args.applicants
      .map((a) => ({ ...a, appliedOn: DATE.format(a.appliedAt) }))
      .sort((a, b) => b.score - a.score);
  }
}

{{! stable keys plus occlusion }}
<VerticalCollection
  @items={{this.rows}}
  @key='id'
  @estimateHeight={{48}}
  @bufferSize={{5}}
  @containerSelector='.table-scroll'
  as |row|
>
  <ApplicantRow @row={{row}} />
</VerticalCollection>

Key Points

  • Ember Inspector render tree gives per-component render timings
  • key='id' stops full teardown when the array reference changes
  • @cached on the sorting or grouping getter stops repeated O(n log n) work
  • Occlusion or server pagination is the only real fix past a few thousand rows
Q34

What is the difference between a v1 and a v2 addon, and how would you ship a shared component library in 2026?

AdvancedEcosystem

Answer

A v1 addon is a build-time participant. It exports broccoli hooks such as treeForApp, treeForAddon and included, and it can register AST transforms, inject content into index.html, and even modify other addons' trees. That power is exactly why classic Ember builds were slow and hard to reason about: the build could not be understood without running it, and nothing could be tree shaken.

A v2 addon, defined by the addon format RFC, is just an npm package containing standard ES modules, prebuilt and published, with an ember-addon key in package.json declaring version 2, the addon main file, and an app-js map listing the few files that must be re-exported into the host app's namespace, typically components and helpers that still need resolver visibility. There are no build hooks at all. The consuming app's bundler treats it like any other dependency, which means tree shaking works, builds are faster, and the same package can work under Webpack or Vite.

You author one with the Embroider addon blueprint, which produces a monorepo with the addon package built by Rollup using @embroider/addon-dev, and a separate test app that consumes it, because you can no longer test an addon by rendering inside its own build. For a shared design system in 2026, that is the shape you want: a v2 addon publishing .gjs or .gts components with explicit imports, types generated for Glint consumers, and no resolver magic at all, so downstream apps get exactly the components they import and nothing more.

// packages/gs-ui/package.json (v2 addon)
{
  "name": "@gs/ui",
  "keywords": ["ember-addon"],
  "exports": {
    ".": "./dist/index.js",
    "./*": "./dist/*.js",
    "./addon-main.js": "./addon-main.cjs"
  },
  "ember-addon": {
    "version": 2,
    "type": "addon",
    "main": "addon-main.cjs",
    "app-js": { "./components/gs-button.js": "./dist/_app_/components/gs-button.js" }
  }
}

// packages/gs-ui/rollup.config.mjs
import { Addon } from '@embroider/addon-dev/rollup';
const addon = new Addon({ srcDir: 'src', destDir: 'dist' });

export default {
  output: addon.output(),
  plugins: [
    addon.publicEntrypoints(['**/*.js', 'index.js']),
    addon.appReexports(['components/**/*.js']),
    addon.dependencies(),
    addon.gjs(),
    addon.clean(),
  ],
};

Key Points

  • v1 addons run broccoli hooks at build time; v2 addons are prebuilt modules
  • package.json ember-addon version 2 plus an app-js re-export map
  • Rollup with @embroider/addon-dev; a separate test app consumes it
  • v2 plus .gts plus Glint is the modern shape for a design system
Q35

You inherit an Ember 3.x app with 900 deprecation warnings. What is your upgrade plan?

AdvancedMigration

Answer

This is the most realistic senior Ember question in the Indian market, because plenty of the Ember work here is maintaining an app someone built in 2017. The plan has five stages. First, measure and freeze the target: run the app, capture the deprecation list, and decide which LTS you are hopping to, because you upgrade LTS to LTS rather than minor by minor.

Second, audit addons before touching Ember itself. Every addon must have a version supporting the target release, and anything unmaintained needs replacing or vendoring; discovering this after the framework bump is what turns a two week upgrade into a two month one. Third, install ember-cli-deprecation-workflow, generate a workflow file from the current console output, and set every entry to silence.

Now your console is clean and CI is quiet, and you can flip entries to throw one at a time and fix that specific deprecation as a small pull request. This converts an intimidating pile into a queue of reviewable changes. Fourth, run ember-cli-update to bump the project blueprint, which produces a merge conflict per changed config file rather than an opaque rewrite, and run the relevant codemods: no-implicit-this first, then angle brackets, then native class, then tracked properties.

Fifth, bump Ember, run the full suite, and ship. Do it incrementally on the main branch behind a green test suite rather than on a long-lived branch, because a six week upgrade branch in a team of ten will never merge cleanly. If the suite is weak, writing rendering tests for the top twenty components first is a prerequisite, not a nice-to-have.

# 1. audit addons and blueprint drift
npx ember-cli-update --to 5.12.0
npx ember-cli-update --run-codemods

# 2. codemods, in this order
npx ember-no-implicit-this-codemod ./app
npx ember-angle-brackets-codemod ./app/templates
npx ember-native-class-codemod ./app/components

# config/deprecation-workflow.js
import { setupDeprecationWorkflow } from 'ember-cli-deprecation-workflow';

setupDeprecationWorkflow({
  throwOnUnhandled: true, // anything new fails loudly in CI
  workflow: [
    { handler: 'silence', matchId: 'ember-data:deprecate-non-strict-relationships' },
    { handler: 'silence', matchId: 'this-property-fallback' },
    { handler: 'throw', matchId: 'ember-component.is-visible' },
  ],
});

Key Points

  • Hop LTS to LTS, and audit every addon before bumping the framework
  • ember-cli-deprecation-workflow turns a wall of warnings into a work queue
  • ember-cli-update surfaces blueprint changes as reviewable conflicts
  • Codemod order: no-implicit-this, angle brackets, native class, tracked
💡 Pro Tip: Fix deprecations in the order the framework will remove them, not the order they appear in the console. The deprecation guide lists an until version for each id, and anything with an until matching your target release is the real blocker.

Companies Hiring Ember.js

LinkedIn
Intuit
PayPal
Apple
Heroku (Salesforce)
Yahoo
Zendesk
Discourse

Salary Insights

Average in India
₹6-18 LPA

Frequently Asked Questions

What does an Ember.js developer earn in India in 2026?

Roughly ₹6-18 LPA depending on experience and employer type. Freshers and one to two year engineers at services companies maintaining an Ember frontend typically land ₹4-8 LPA. Mid-level engineers with three to six years and real Octane, Ember Data and testing depth sit around ₹12-18 LPA. Product companies with genuine Ember codebases and Indian engineering centres, LinkedIn, Intuit, PayPal, Apple and Salesforce among them, pay well above that band, but they hire on general frontend and systems ability, and treat Ember as something you will pick up. The interesting dynamic is supply: there are far fewer Ember candidates than React candidates in India, so a strong Ember engineer often faces less competition per role even though there are fewer roles overall.

Is Ember.js worth learning in 2026, or is it a dying framework?

It is not dying, but be honest about what it is. Ember has a small, stable job market rather than a growing one, and almost nobody starts a new consumer startup on it. What it does have is very long-lived applications: the codebases at LinkedIn, Intuit, PayPal, Apple, Zendesk, Heroku and Discourse are years old and still actively developed, and those teams need engineers. It also remains actively maintained, with Octane, Embroider, Vite builds, template tag components and the WarpDrive data layer all shipping in recent years. Learn it if you are targeting one of those employers, if you are joining a team that already runs it, or if you want to work on large applications where architecture matters more than framework churn. Do not learn it as your first framework hoping for a wide job market.

How long does it take to prepare for an Ember interview if I already know React?

Two to four weeks of focused work for a mid-level role. Most of your React knowledge transfers: components, one-way data flow, derived state and testing philosophy are the same ideas. Budget your time on the parts with no React equivalent. Week one: Octane syntax, autotracking versus hooks, and Glimmer component lifecycle. Week two: the router, route hooks, query params and loading substates, which is the single biggest conceptual gap because React developers are used to choosing a router. Week three: Ember Data, the identity map, adapters and serializers, plus ember-concurrency. Week four: testing with @ember/test-helpers and Mirage, and reading about Embroider. Building one real application with authentication, a paginated list and a form covers most of it far better than reading documentation.

What do interviewers expect from a fresher versus someone with five years of Ember?

From a fresher: fluent Octane syntax, the ability to build a component with tracked state and actions, comfort with the router and the CLI generators, and at least basic Ember Data usage plus a rendering test. Nobody expects Embroider or FastBoot knowledge. From five years: architecture judgement. Why a service instead of a controller, when ember-concurrency beats a plain promise, how to debug the backtracking rerender assertion, how you would diagnose a memory leak across route transitions, how you would plan an upgrade from an old LTS, and whether you can articulate the tradeoff between engines and route-level code splitting. Senior rounds also push on testing strategy, because anyone who has maintained a large Ember app has an opinion about acceptance test runtime.

How does Ember compare with React and Vue for a frontend career in India?

React dominates Indian hiring by a wide margin and should be your default if you are optimising purely for the number of open roles. Vue is a distant second with a real presence in product startups. Ember is a specialist track: fewer roles, but they concentrate in mature product companies with large applications, which tends to mean better engineering practices and lower churn than the average React contract role. The strongest position is not either or. Learn React for market access, then learn Ember properly if you join or target an Ember team, and note that the concepts transfer both ways: autotracking maps onto signals, which the wider ecosystem is converging on, and Ember's convention-driven structure teaches architecture habits that make you better in any framework.

Do I need Ember Data, or can I just use fetch in the model hook?

You can absolutely use fetch, and for a small app with a handful of endpoints it is simpler and easier to reason about. What you lose is the identity map, so two screens showing the same record can drift apart, plus relationship management, dirty tracking, optimistic updates and per-attribute validation errors, all of which you end up reimplementing badly once the app grows. For interviews, know Ember Data regardless: nearly every established Ember codebase uses it, and questions about caching behaviour, adapters, serializers and the newer RequestManager pipeline are standard. If you are joining a team that deliberately avoids it, being able to explain exactly what tradeoff they made is a better answer than not knowing the library at all.

Introduction

Ember.js in 2026 is not the framework people remember from 2015. The Octane edition replaced classic Ember.Component and computed properties with Glimmer components, native JavaScript classes, decorators, and autotracking, and the Polaris work has pushed the ecosystem toward first-class component templates in .gjs and .gts files, Vite-based builds through Embroider, and the WarpDrive generation of Ember Data. What has not changed is the reason large teams keep choosing it: a single official router, a single official data layer, a single CLI, and file conventions that mean any engineer can open any Ember repository and know exactly where things live.

That stability is why Ember interviews look different from React interviews. Nobody asks you to pick a router or a state library. Instead they probe whether you understand autotracking invalidation, route hook ordering, query parameter refresh semantics, the Ember Data identity map, ember-concurrency task modifiers, and how you keep a long-lived single page application from leaking memory across route transitions. Indian teams at LinkedIn, Intuit, PayPal, Apple, Zendesk and Heroku run genuinely large Ember codebases, some of them eight or nine years old, so hiring managers also care a great deal about upgrade discipline: deprecation workflows, codemods, LTS strategy, and Embroider migration.

This set works through 35 Ember.js interview questions asked in 2026, ordered from fundamentals to the senior-level topics that actually decide offers. Roughly two thirds carry runnable code so you can see the exact API being discussed rather than a paraphrase of it. Start with the Octane fundamentals and routing section, then push into autotracking internals, ember-concurrency, testing with @ember/test-helpers, and the advanced block on Embroider, template tag components, FastBoot, engines and legacy upgrades. Answers call out the production failure modes interviewers use as follow-up questions.

Ready to practice Ember.js interviews?

Don't just read, practice these Ember.js questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview