The Runloop — Briefly

Why Async Tests Sometimes Need settled()

The Runloop — Briefly

Ember batches async work into a runloop to coalesce DOM updates. With tracked properties and native async/await you rarely touch it directly.

4 min read Level 3/5 #ember#runloop#async
What you'll learn
  • Recognize the schedule and run helpers
  • Know when tests need the settled and waitFor helpers
  • Prefer later from the runloop over raw setTimeout if you must schedule

The Ember runloop is a scheduler that batches work into queues (actions, routerTransitions, render, afterRender, destroy) so that a burst of updates turns into a single render pass. In Octane, this is mostly invisible.

You Rarely Touch It

With @tracked and native async/await, the framework handles scheduling for you. The runloop still runs — it just runs behind the scenes.

Where It Surfaces

The runloop becomes visible in tests. Promises must settle before assertions:

import { render, settled, click } from '@ember/test-helpers';

test('it toggles', async function (assert) {
  await render(hbs`<Toggle />`);
  await click('button');
  await settled();   // wait for any pending render + tasks
  assert.dom('[data-state]').hasText('on');
});

settled returns when every pending queue is empty. Most test helpers (click, fillIn, render) call it implicitly.

Scheduling Manually

If you need to defer work, prefer runloop helpers over raw timers — they integrate with tests:

import { later, schedule } from '@ember/runloop';

later(this, () => {
  this.poll();
}, 5000);

schedule('afterRender', this, () => {
  this.measure();
});

schedule('afterRender', ...) is the canonical way to read DOM measurements after Ember has flushed renders.

What To Avoid

  • setTimeout(fn, 0) to “wait for render” — use schedule('afterRender') or await settled().
  • Tight polling loops outside the runloop — they don’t pause for renders.

The takeaway: write idiomatic Octane code with tracked state and promises, and the runloop stays out of your way.

ember-concurrency →