Case Study
Async UX Architecture
A proposal for a generic, root-scoped background-task registry that lets any feature area register long-running operations and receive live status updates without per-feature WebSocket subscriptions, local polling, or state loss on navigation.
The Problem
Background task awareness was component-scoped. A ReportsComponent tracked
AI analysis jobs via local signals and subscribed directly to WebSocketService
notifications. When the user navigated away, the component was destroyed — signals
reset, the subscription torn down, and any in-flight WebSocket events went unheard.
The WebSocketService itself was root-scoped and stayed alive through
navigation, but nothing was listening at the application level. Each future feature
area — exports, document uploads, bulk operations — would
need to independently solve the same lifecycle problems.
Core insight
Component-scoped async state forces every feature to re-solve navigation continuity, page-reload rehydration, and WebSocket fallback. Moving task state to a root-scoped registry turns those into solved infrastructure rather than per-feature responsibilities.
Architecture Overview
The design separates into four layers: a generic core store, type-specific strategy handlers, route-scoped consumer components, and a root-scoped UI layer. Only the store and the snackbar need to be root-scoped — everything else can be lazy-loaded with its feature module.
The Generic Core: BackgroundTaskStore
The store is intentionally type-agnostic. It manages a flat task list with a shared
shape — type-specific data lives in an opaque meta bag
that only the registered handler for that type knows how to interpret.
interface BackgroundTask {
id: string; // job ID returned by the backend
type: string; // discriminator - opaque to the store
label: string; // human-readable label set by the consumer
status: 'pending' | 'running' | 'completed' | 'failed';
progress?: number;
createdAt: number;
completedAt?: number;
errorMessage?: string;
meta: Record<string, unknown>; // type-specific data, opaque to the store
}
The store exposes a small, stable API: registerTask(),
registerHandler(), getTasksByType(), and computed signals
for active, completed, and failed task lists. It subscribes to
WebSocketService.notifications$ once at the root and dispatches events
to the registered handlers.
The Strategy: Type Handlers
Adding a new job type means writing one TaskTypeHandler and registering
it at bootstrap — nothing else changes. The store resolves the task via jobId lookup, then hands off entirely to
handler.handle(notification, task). The handler owns payload interpretation,
side effects, and returns a generic TaskUpdate — the store
applies it without knowing any job-type specifics.
// What the store writes back after a handler processes a notification
interface TaskUpdate {
status: 'pending' | 'running' | 'completed' | 'failed';
progress?: number;
errorMessage?: string;
meta?: Record<string, unknown>; // handler can also update stored metadata
}
// The handler is the authority on its notification payload shape.
// The store routes to it; the handler owns all interpretation and side effects.
interface TaskTypeHandler<TPayload = NotificationPayload> {
type: string;
// Process a notification. The handler knows the exact payload schema,
// applies business rules, triggers any side effects (cache invalidation,
// analytics, etc.), and returns a generic TaskUpdate the store applies
// without knowing job-type specifics.
handle(notification: TPayload, task: BackgroundTask): TaskUpdate;
// Optional: restore in-flight tasks of this type from the BFF on page load
rehydrate?(): Promise<BackgroundTask[]>;
// Optional: poll this task when WebSocket is unreliable
pollTaskStatus?(task: BackgroundTask): Observable<TaskUpdate>;
pollCadence?: PollCadence; // defaults to PollCadence.Normal (30_000 ms)
}
enum PollCadence {
Fast = 10_000, // active progress: AI analysis, uploads
Normal = 30_000, // slower jobs: exports, document processing
Slow = 60_000, // background / low-priority
} A concrete example — an AI analysis handler that rehydrates on load, polls every ten seconds when WebSocket is unreliable, and maps three notification types to task states:
// Typed to its own payload shape - the store never sees AIAnalysisPayload
const analysisJobHandler: TaskTypeHandler<AIAnalysisPayload> = {
type: 'ai-analysis',
handle(notification, task) {
const meta = task.meta as AiAnalysisMeta;
// Side effects - only this handler knows what these notifications mean
if (notification.status === 'completed') {
// Bust the stale answers cache so the UI reloads fresh data automatically
queryClient.invalidateQueries(['answers', meta.reportId]);
}
if (notification.status === 'failed') {
analytics.track('ai_analysis_failed', {
reportId: meta.reportId,
questionCount: meta.questionIds.length,
});
}
const status =
notification.notificationType.includes('Started') ? 'running'
: notification.status === 'completed' ? 'completed'
: notification.status === 'failed' ? 'failed'
: 'running';
return {
status,
progress: notification.completedCount / notification.totalCount,
errorMessage: notification.errorMessage,
// Persist updated counts into task.meta for consumer components to read
meta: { ...meta, completedIds: notification.completedQuestionIds },
};
},
rehydrate: () => aiAnalysisService.getActiveJobs(),
pollTaskStatus: (task) =>
aiAnalysisService.getJobStatus(task.id).pipe(
map(r => ({ status: r.status, progress: r.progress }))
),
pollCadence: PollCadence.Fast,
}; Event Flow Through the Store
When a WebSocket notification arrives, the store acts as a dispatcher: it finds
calls handler.handle(notification, task) and writes the returned
TaskUpdate — status, progress, error, and any updated metadata —
back into the task in the store.
Navigation Continuity
Because the store is root-scoped, it survives Angular route changes. When a user
navigates away from a page where a task is running, the component is destroyed but
the task remains in the store. The TaskSnackbarComponent, living in the
app shell, continues to show progress from store signals.
When the user navigates back, the newly created component reads from the store on
init — filtered by its own context (e.g. reportId
from route params) — and restores UI state directly. No API
calls, no local signals to recreate.
// In ReportsComponent - no direct WebSocket subscription needed
private store = inject(BackgroundTaskStore);
activeAiTasks = computed(() =>
this.store.getTasksByType('ai-analysis')()
.filter(t =>
(t.meta as any).reportId === this.reportId() &&
(t.status === 'pending' || t.status === 'running')
)
);
processingQuestionIds = computed(() =>
this.activeAiTasks().flatMap(t => (t.meta as any).questionIds ?? [])
);
isBulkAiGenerating = computed(() => this.activeAiTasks().length > 0); Polling Fallback
WebSocket is push-only: a missed event during a brief disconnect leaves a task stuck
in running indefinitely. The handler interface includes an optional
pollTaskStatus() for these cases. The store invokes it on three triggers:
socket disconnect while tasks are active (exponential backoff, 5 s to 120 s cap),
stale task detection (any task in running past its pollCadence
threshold), and reconnect reconciliation (one immediate poll per active task to catch
events missed during the gap).
Rather than each handler polling independently — which could produce
many concurrent requests under sustained disconnection — handlers
declare a PollCadence tier and the store coordinates batched polling groups:
// Handlers declare their preferred poll cadence
const analysisHandler: TaskTypeHandler = {
pollCadence: PollCadence.Fast,
pollTaskStatus: (task) => aiService.getJobStatus(task.id),
};
const exportHandler: TaskTypeHandler = {
pollCadence: PollCadence.Normal,
pollTaskStatus: (task) => exportService.getJobStatus(task.id),
};
// Store groups tasks by PollCadence value — same enum value = same tick
// PollCadence.Fast (10 s): all Fast-cadence tasks batched, max 3 concurrent
// PollCadence.Normal (30 s): all Normal-cadence tasks batched, max 3 concurrent
// No magic numbers; two handlers can never accidentally share a tick
Handlers that omit pollTaskStatus simply have no fallback — the
store never calls a method that is not defined.
Design Tradeoffs
Four decisions shaped the final design. Each involved real options with different consequences — working through them is part of what the proposal was for.
1. Rehydration: per-handler vs. unified endpoint
Question on page load, how does the store know which tasks were in-flight before the reload?
Option A a single backend endpoint (GET /jobs/active)
returns all active jobs regardless of type. Simple, one call.
Option B each handler provides a rehydrate()
method that calls its own type-specific endpoint.
Decision per-handler. It aligns with the strategy pattern — the handler already owns the knowledge of its BFF endpoints and payload shapes. A unified endpoint would require the backend to aggregate across job types and keep that aggregation in sync as new types are added. Per-handler keeps the boundary clean: adding a new job type means writing a new handler, not modifying a shared backend endpoint.
2. Handler registration: eager vs. lazy, with activity feed
Question should all handlers load at bootstrap, or only when their feature module loads?
Eager registration ensures the snackbar can always display any task type, but loads handler code the user may never need.
Lazy registration is leaner, but creates blind spots: if the user has an export running and hasn’t visited the export module this session, the snackbar shows nothing.
Decision lazy, informed by rehydration, with a separate activity feed.
On bootstrap the store calls rehydrate() for each known job type
to discover what is currently active, then lazy-loads only the handlers
for those types. This eliminates the blind spot without paying the cost
of eager-loading everything. A separate activity feed — backed
directly by the API, independent of the handler registry — covers
historical and missed notifications without coupling the notification system
to page visitation history.
3. WebSocket envelope: type-specific payloads vs. shared jobId
Question how does the store match an incoming notification to a registered task?
Original design handlers implement matchTask(),
interpreting type-specific payload fields (reportId + questionIds
for analysis, exportId for exports). The store iterates tasks and
delegates matching to the handler.
Better approach if the BFF includes a jobId
on every notification (echoing the ID returned at job submission), the store
can look up the task directly: tasks.find(t => t.id === notification.jobId).
No delegation, no iteration, no type-specific matching logic.
Decision align on jobId envelope. The only edge case is
system-generated notifications without a user job context — those
produce no match and are ignored harmlessly. The payoff is significant:
matchTask and relevantNotifications are removed from
the handler interface entirely, and the dispatch loop becomes a single lookup.
4. Polling budget: independent vs. batched by interval group
Question under sustained WebSocket disconnection with multiple active tasks, how do you prevent a flood of concurrent status-check requests to the backend?
Independent polling (the naive approach) has each handler manage its own timer. With N active tasks across M handler types, polls can overlap arbitrarily and the backend sees unpredictable burst load.
Decision handlers declare a PollCadence; the store
groups tasks by interval and fires coordinated batches, with a
per-tick concurrency cap (e.g. max 3 concurrent HTTP requests per interval
bucket). All 10 s tasks fire together, all 30 s tasks fire together — predictable
cadence, controllable backend load, no two groups competing for the same moment.