--- title: Implement Robust Data Requesting Patterns impact: HIGH impactDescription: Prevents request waterfalls and race conditions in data flows tags: services, data-fetching, concurrency, cancellation, reliability --- ## Implement Robust Data Requesting Patterns Use proper patterns for data fetching including parallel requests, error handling, request cancellation, and retry logic. `export default` in route/service snippets below is intentional because these modules are commonly resolved by convention and referenced from templates. In hybrid `.gjs`/`.hbs` codebases, you can pair named exports with a default alias where needed. ## Problem Naive data fetching creates waterfall requests, doesn't handle errors properly, and can cause race conditions or memory leaks from uncanceled requests. **Incorrect:** ```javascript // app/routes/dashboard.js import Route from '@ember/routing/route'; export default class DashboardRoute extends Route { async model() { // Sequential waterfall - slow! const user = await this.store.request({ url: '/users/me' }); const posts = await this.store.request({ url: '/posts' }); const notifications = await this.store.request({ url: '/notifications' }); // No error handling // No cancellation return { user, posts, notifications }; } } ``` ## Solution: Parallel Requests Use `RSVP.hash` or `Promise.all` for parallel loading: **Correct (parallelized model loading):** ```javascript // app/routes/dashboard.js import Route from '@ember/routing/route'; import { hash } from 'rsvp'; export default class DashboardRoute extends Route { async model() { return hash({ user: this.store.request({ url: '/users/me' }), posts: this.store.request({ url: '/posts?recent=true' }), notifications: this.store.request({ url: '/notifications?unread=true' }), }); } } ``` ## Error Handling Pattern Handle errors gracefully with fallbacks: ```javascript // app/services/api.js import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; export default class ApiService extends Service { @service store; @tracked lastError = null; async fetchWithFallback(url, fallback = null) { try { const response = await this.store.request({ url }); this.lastError = null; return response.content; } catch (error) { this.lastError = error.message; console.error(`API Error fetching ${url}:`, error); return fallback; } } async fetchWithRetry(url, { maxRetries = 3, delay = 1000 } = {}) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await this.store.request({ url }); } catch (error) { if (attempt === maxRetries - 1) throw error; await new Promise((resolve) => setTimeout(resolve, delay * (attempt + 1))); } } } } ``` ## Request Cancellation with AbortController Prevent race conditions by canceling stale requests: ```glimmer-js // app/components/search-results.gjs import Component from '@glimmer/component'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { restartableTask, timeout } from 'ember-concurrency'; class SearchResults extends Component { @service store; @tracked results = []; // Automatically cancels previous searches @restartableTask *searchTask(query) { yield timeout(300); // Debounce try { const response = yield this.store.request({ url: `/search?q=${encodeURIComponent(query)}`, }); this.results = response.content; } catch (error) { if (error.name !== 'TaskCancelation') { console.error('Search failed:', error); } } } } ``` ## Manual AbortController Pattern For non-ember-concurrency scenarios: ```javascript // app/services/data-fetcher.js import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { registerDestructor } from '@ember/destroyable'; export default class DataFetcherService extends Service { @service store; @tracked data = null; @tracked isLoading = false; abortController = null; constructor() { super(...arguments); registerDestructor(this, () => { this.abortController?.abort(); }); } async fetch(url) { // Cancel previous request this.abortController?.abort(); this.abortController = new AbortController(); this.isLoading = true; try { // Note: WarpDrive handles AbortSignal internally const response = await this.store.request({ url, signal: this.abortController.signal, }); this.data = response.content; } catch (error) { if (error.name !== 'AbortError') { throw error; } } finally { this.isLoading = false; } } } ``` ## Dependent Requests Pattern When requests depend on previous results: ```javascript // app/routes/post.js import Route from '@ember/routing/route'; import { hash } from 'rsvp'; export default class PostRoute extends Route { async model({ post_id }) { // First fetch the post const post = await this.store.request({ url: `/posts/${post_id}`, }); // Then fetch related data in parallel return hash({ post, author: this.store.request({ url: `/users/${post.content.authorId}`, }), comments: this.store.request({ url: `/posts/${post_id}/comments`, }), relatedPosts: this.store.request({ url: `/posts/${post_id}/related`, }), }); } } ``` ## Polling Pattern For real-time data updates: ```javascript // app/services/live-data.js import Service, { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { registerDestructor } from '@ember/destroyable'; export default class LiveDataService extends Service { @service store; @tracked data = null; intervalId = null; constructor() { super(...arguments); registerDestructor(this, () => { this.stopPolling(); }); } startPolling(url, interval = 5000) { this.stopPolling(); this.poll(url); // Initial fetch this.intervalId = setInterval(() => this.poll(url), interval); } async poll(url) { try { const response = await this.store.request({ url }); this.data = response.content; } catch (error) { console.error('Polling error:', error); } } stopPolling() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } } } ``` ## Batch Requests Optimize multiple similar requests: ```javascript // app/services/batch-loader.js import Service, { service } from '@ember/service'; export default class BatchLoaderService extends Service { @service store; pendingIds = new Set(); batchTimeout = null; async loadUser(id) { this.pendingIds.add(id); if (!this.batchTimeout) { this.batchTimeout = setTimeout(() => this.executeBatch(), 50); } // Return a promise that resolves when batch completes return new Promise((resolve) => { this.registerCallback(id, resolve); }); } async executeBatch() { const ids = Array.from(this.pendingIds); this.pendingIds.clear(); this.batchTimeout = null; const response = await this.store.request({ url: `/users?ids=${ids.join(',')}`, }); // Resolve all pending promises response.content.forEach((user) => { this.resolveCallback(user.id, user); }); } } ``` ## Performance Impact - **Parallel requests (RSVP.hash)**: 60-80% faster than sequential - **Request cancellation**: Prevents memory leaks and race conditions - **Retry logic**: Improves reliability with < 5% overhead - **Batch loading**: 40-70% reduction in requests ## When to Use - **RSVP.hash**: Independent data that can load in parallel - **ember-concurrency**: Search, autocomplete, or user-driven requests - **AbortController**: Long-running requests that may become stale - **Retry logic**: Critical data with transient network issues - **Batch loading**: Loading many similar items (N+1 scenarios) ## References - [WarpDrive Documentation](https://warp-drive.io/) - [ember-concurrency](https://ember-concurrency.com/) - [RSVP.js](https://github.com/tildeio/rsvp.js) - [AbortController MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)