← Case studies

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.

Architecture State Management Angular / NgRx Signals WebSocket Strategy Pattern

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.

Four-layer architecture: Core layer with WebSocketService and BackgroundTaskStore, Handler layer with type-specific handlers, Consumer layer with feature components. CORE LAYER WebSocketService existing · unchanged BackgroundTaskStore generic · root-scoped · NgRx signals TaskSnackbarComponent root UI · survives navigation notifications$ tasks signal HANDLER LAYER AnalysisJobHandler ExportJobHandler + future handlers CONSUMER LAYER ReportsComponent DocumentRepositoryComponent + async-aware features registerHandler() registerTask() tasksByType signal
Solid arrows = reactive signal flow. Dashed arrows = registration at bootstrap. The store is the only component that subscribes to WebSocketService directly.

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.

Sequence diagram: WebSocket notification dispatched through BackgroundTaskStore, matched by TaskTypeHandler, status updated, TaskSnackbar re-renders. WebSocketService Background TaskStore TaskTypeHandler TaskSnackbar Component notification$ emits {jobId, …} find task by jobId — O(n) handle(notification, task) TaskUpdate { status, meta } patchState: task → 'completed' tasks signal updates → UI re-renders
With a shared jobId on every notification, the store resolves the task with a direct lookup — no handler delegation needed for matching. Solid = call. Dashed = return.

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.