You don't need ember-data to call an API from an Ember app โ a Glimmer component class and fetch will do. But Octane's auto-tracking has two famous traps waiting: fetch from a getter and you get a self-perpetuating request storm with zero warnings; push() onto a @tracked array and the DOM โ and even {{this.items.length}} โ freezes while the real array silently grows.
This guide pairs a stock ember-cli new app (Ember 7.3, the Vite blueprint) with a hosted mock API and walks through each trap โ every one reproduced and measured in a real Chrome on Ember 7.3.0 + ember-concurrency 5.2.1 before publishing. All numbers below (99 fetches in 5s, a DOM frozen at 3 items while the array holds 5, the stale search that rendered) are observed counts from an instrumented window.fetch, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:4200, Vite's dev server, StackBlitz, anywhere โ no proxy config. When you want your own schema it's one curl or one click โ see ยง10.
"Just fetch the data where the template reads it" turns a getter into an infinite loop. The template reads the getter; the getter starts a fetch and returns tracked state; the response assigns that tracked state; the assignment invalidates whatever read it โ so Glimmer re-renders, re-reads the getter, and fetches again:
// THE BUG: fetch inside a getter the template consumes
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class ProductList extends Component {
@tracked items = [];
get products() {
fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
.then((r) => r.json())
.then((d) => (this.items = d)); // set -> invalidate -> re-render -> getter -> fetch...
return this.items;
}
}
// template: {{#each this.products as |p|}}<li>{{p.name}}</li>{{/each}}
Measured: mounted once in a real Chrome, this component fired 39 fetches in the first 2 seconds and 99 by the 5-second mark, still climbing โ it never stops. Worse than the equivalent React/Lit bug: because the write happens asynchronously in a .then(), Ember's backtracking-rerender assertion never fires. Zero console errors, zero warnings โ the page even looks fine (the 3 products render). Against a real backend that's a self-inflicted DDoS you'll first notice on a billing dashboard.
The rule: getters in Octane must be pure derivations of tracked state. Data loading belongs in a constructor, an ember-concurrency task, or a resource โ anywhere that runs a controlled number of times.
export default class ProductList extends Component {
@tracked items = [];
constructor(owner, args) {
super(owner, args);
fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
.then((r) => r.json())
.then((d) => (this.items = d));
}
}
Measured: the same component with the fetch moved to the constructor made exactly 1 request and rendered the same 3 items. Glimmer components are instantiated once per mount, so this is the honest "on mount" hook โ there is no componentDidMount, and you don't need one. (If the component should re-fetch when an argument changes, that's what a task with args or a resource is for โ not a getter.)
@tracked push() that never renders โ and lies about length@tracked tracks assignments to the field, not mutations inside the object it points at:
@tracked items = ['a', 'b', 'c'];
addBroken(x) { this.items.push(x); } // BUG: no assignment -> invisible
addFixed(x) { this.items = [...this.items, x]; } // new reference -> renders
// or: import { TrackedArray } from 'tracked-built-ins';
items = new TrackedArray(['a', 'b', 'c']); // .push() just works
Measured: two push() calls grew the real array to 5 while the DOM stayed at 3 <li>s โ and {{this.items.length}} still rendered "3". That second part surprises people: the length read isn't tracked either, so your item count UI confidently displays a stale number. Then one spread-reassignment rendered 6 items at once โ the two "lost" pushes were in the array all along and reappeared together, which is exactly the confusing behavior users report as "my list updates sometimes".
With TrackedArray from tracked-built-ins (4.1.2 in our harness), two push() calls rendered immediately: 3 โ 5 <li>s, no reassignment needed. Pick one idiom per codebase; mixing them is how the half-updating bugs above happen.
If you add ember-concurrency to a modern Vite-blueprint Ember app and write the current task(async () => ...) syntax, the first render throws:
Assertion Failed: It appears you're attempting to use the new
task(async () => { ... }) syntax, but the async arrow task function
you've provided is not being properly compiled by Babel.
The classic addon pipeline injected this transform for you; the Vite blueprint doesn't. The fix is one line in babel.config.mjs:
export default {
plugins: [
'ember-concurrency/async-arrow-task-transform', // โ add this
['babel-plugin-ember-template-compilation', { /* ... */ }],
// ...rest of the generated config
],
};
We hit this exact assertion in the harness (Ember 7.3 + ember-concurrency 5.2.1 + the stock Vite blueprint) and this one-liner fixed it. Restart the dev server after โ babel config isn't hot-reloaded.
The demo API simulates every state your template branches on, straight from the URL:
| State | URL | What we measured |
|---|---|---|
| loading | ?limit=5&mock_delay=2000 | rendered the loading branch for the full 2s, then data with 5 items |
| data | ?limit=5 | 5 items |
| error | ?limit=5&mock_status=500 | error branch with HTTP 500 and a JSON body naming the simulated status |
| empty | ?category=nonexistent-cat | [] โ style your empty state, it's not the error state |
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_status=500"
curl -w "%{time_total}s\n" -o /dev/null -s \
"https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_delay=2000"
With ember-concurrency you don't hand-roll the flags: task.isRunning is your loading branch, task.last.error the error branch, task.lastSuccessful.value the data. mock_delay and mock_status work on any endpoint โ no separate fixtures.
restartableType "life", results are slow; type "way", results are instant. The slow response lands last and overwrites the fast one:
// naive: one async function per keystroke โ last RESPONSE wins, not last QUERY
naiveSearch = async (q) => {
const r = await fetch(`.../m/demo/products?q=${q}`);
this.results = await r.json(); // stale response overwrites fresh one
};
// ember-concurrency: last PERFORM wins, prior runs are cancelled
import { task } from 'ember-concurrency';
searchTask = task({ restartable: true }, async (q) => {
const r = await fetch(`.../m/demo/products?q=${q}`);
return r.json(); // read via this.searchTask.lastSuccessful.value
});
Measured (naive): we issued a slow query (q=life + 1.5s simulated latency via mock_delay) then a fast one (q=way) 150ms later. Final rendered result: life's response โ stale results under a search box that says "way".
Measured (restartable task): same sequence, same latency. Both requests still hit the network, but the first run was cancelled at its next await โ its response was discarded, and lastSuccessful.value held way's response. Correct, with one option flag. (restartable is one of four built-in strategies โ drop, enqueue, keepLatest cover the button-mashing and queueing cases the same way.)
?mock_delay=1500 is how you make this reproducible instead of "sometimes flaky on hotel wifi": pin the latency on the first request, race it deliberately, assert the right result renders.
X-Total-Countimport { TrackedArray } from 'tracked-built-ins';
items = new TrackedArray([]);
@tracked total = null;
@tracked page = 0;
get done() { return this.total !== null && this.items.length >= this.total; }
loadMore = async () => {
const p = this.page + 1;
const r = await fetch(`.../m/demo/products?page=${p}&limit=10`);
this.total = Number(r.headers.get('X-Total-Count')); // CORS-exposed
for (const x of await r.json()) this.items.push(x); // TrackedArray: renders live
this.page = p;
};
// template: <button disabled={{this.done}} {{on "click" this.loadMore}}>more</button>
Measured: three clicks loaded 10 โ 20 โ 30 of 30 in exactly 3 requests, the button's disabled attribute flipped at the boundary (a 4th click was impossible), and an id-set check found 0 duplicate records across pages. X-Total-Count is exposed via CORS so r.headers.get() works cross-origin โ many real APIs forget that and the header silently reads null.
?mock_seqRetry logic tested against a healthy API is untested. mock_seq serves an exact status script โ fail, fail, succeed:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://mockbird.mockbird.workers.dev/m/demo/products?limit=2&mock_seq=503,503,200&mock_seq_key=me1"
Measured: a retry task using ember-concurrency's timeout() for backoff observed exactly [503, 503, 200] โ 3 requests total, with measured gaps of ~290ms and ~520ms matching its 250ms/500ms backoff schedule, then rendered the data. Deterministic, not "randomly fails sometimes" like chaos flags. On the shared demo the counter is scoped per client IP (and mock_seq_key isolates parallel workers), so the link above starts fresh for you too. Statuses โฅ400 are simulated before processing, so a failed write is never half-applied โ safe to point real retry logic at.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments โ or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is an env-var change in config/environment.js.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) โ build optimistic updates and verify against reality.POST /m/<project>/auth/login returns a real signed JWT for any email+password โ test login flows and 401 handling without a backend (guide).GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod) โ typed task results for free in an Ember + TS app.beforeEach โ deterministic ember-qunit/Playwright runs (guide).| Tool | Good at | Where this differs |
|---|---|---|
| ember-cli-mirage | The canonical Ember mock: in-browser server with route handlers, factories, and an ORM that mirrors ember-data models | Mirage lives inside your build โ nothing else can call it. No curl, no second app, no teammate's machine, no real network tab entries, and it needs per-route handler code. Mockbird is a hosted URL any client can hit with zero build integration; Mirage remains the better fit when you specifically need ember-data model factories in acceptance tests. |
| MSW (Mock Service Worker) | In-process request interception for unit tests; no network at all | MSW mocks live inside each test runner. A hosted mock is the same URL for your browser, CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared. |
| json-server | Local full-featured fake REST; huge ecosystem | Needs Node running on every machine that wants the API. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted โ and adds auth simulation, failure injection, snapshots. |
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements โ rerun them in your own Chrome with the snippets as written.