Async Request-Reply Pattern: Decoupling Long-Running Operations
Async Request-Reply Pattern: Decoupling Long-Running Operations
HTTP's synchronous request-response model breaks when operations take minutes instead of milliseconds. Report generation, video transcoding, batch processing—these can't complete within typical timeout windows. The Async Request-Reply Pattern decouples request submission from result retrieval, letting clients check back when the work is done.
The Problem with Synchronous Long-Running Operations
┌─────────────────────────────────────────────────────────────────┐
│ SYNCHRONOUS LONG-RUNNING FAILURES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Server │
│ │ │ │
│ │ POST /reports │ │
│ │─────────────────────────▶ │
│ │ │ Start processing... │
│ │ │ Processing... │
│ │ │ Processing... │
│ │ 30s │ Processing... │
│ │ │ │ Processing... │
│ │ │ │ Processing... │
│ │ ▼ │ Processing... │
│ │ ⚡ TIMEOUT ⚡ │ Processing... │
│ │ │ ...done! (nobody listening) │
│ │ │ │
│ │
│ Problems: │
│ │
│ 1. Connection timeouts (30s default, often lower) │
│ 2. Load balancer timeouts (60s typical) │
│ 3. CDN/proxy timeouts (varies) │
│ 4. Client retry = duplicate work │
│ 5. User closes browser = wasted resources │
│ 6. Server crash during processing = lost work │
│ 7. No progress indication │
│ 8. Scaling limited by connection count │
│ │
└─────────────────────────────────────────────────────────────────┘
Async Request-Reply Architecture
┌─────────────────────────────────────────────────────────────────┐
│ ASYNC REQUEST-REPLY FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Phase 1: Submit Request │
│ ─────────────────────── │
│ │
│ Client API Worker │
│ │ │ │ │
│ │ POST /reports │ │ │
│ │ { params } │ │ │
│ │────────────────────▶│ │ │
│ │ │ Validate, create job│ │
│ │ │────────────────────▶│ (queued) │
│ │ │ │ │
│ │ 202 Accepted │ │ │
│ │ Location: /jobs/123│ │ │
│ │◀────────────────────│ │ │
│ │ │ │ │
│ │
│ Phase 2: Check Status (Polling) │
│ ─────────────────────────────── │
│ │
│ │ GET /jobs/123 │ │ (processing) │
│ │────────────────────▶│ │ │
│ │ 200 { status: │ │ │
│ │ "processing", │ │ │
│ │ progress: 45% } │ │ │
│ │◀────────────────────│ │ │
│ │ │ │ │
│ │
│ Phase 3: Retrieve Result │
│ ──────────────────────── │
│ │
│ │ GET /jobs/123 │ │ (completed) │
│ │────────────────────▶│ │ │
│ │ 303 See Other │ │ │
│ │ Location: /results/│ │ │
│ │ 123.pdf │ │ │
│ │◀────────────────────│ │ │
│ │ │ │ │
│ │ GET /results/123.pdf │ │
│ │──────────────────────────────────────────▶│ │
│ │ 200 <pdf content> │ │ │
│ │◀──────────────────────────────────────────│ │
│ │
└─────────────────────────────────────────────────────────────────┘
Implementation
// async-request-reply.ts
import { v4 as uuid } from 'uuid';
// Job state machine
enum JobStatus {
PENDING = 'pending',
PROCESSING = 'processing',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled'
}
interface Job<TInput = unknown, TOutput = unknown> {
id: string;
type: string;
status: JobStatus;
input: TInput;
output?: TOutput;
error?: {
code: string;
message: string;
retryable: boolean;
};
progress?: {
current: number;
total: number;
message?: string;
};
createdAt: Date;
updatedAt: Date;
startedAt?: Date;
completedAt?: Date;
expiresAt?: Date;
metadata: {
userId: string;
correlationId?: string;
priority?: number;
};
}
// Job repository
interface JobRepository {
create(job: Job): Promise<void>;
get(id: string): Promise<Job | null>;
update(id: string, updates: Partial<Job>): Promise<void>;
listByUser(userId: string, options?: {
status?: JobStatus[];
limit?: number;
offset?: number;
}): Promise<Job[]>;
}
// Redis-based job repository
class RedisJobRepository implements JobRepository {
constructor(
private readonly redis: Redis,
private readonly ttlSeconds: number = 7 * 24 * 60 * 60 // 7 days
) {}
async create(job: Job): Promise<void> {
const key = `job:${job.id}`;
await this.redis.multi()
.set(key, JSON.stringify(job), 'EX', this.ttlSeconds)
.zadd(`jobs:user:${job.metadata.userId}`, Date.now(), job.id)
.exec();
}
async get(id: string): Promise<Job | null> {
const data = await this.redis.get(`job:${id}`);
return data ? JSON.parse(data) : null;
}
async update(id: string, updates: Partial<Job>): Promise<void> {
const job = await this.get(id);
if (!job) {
throw new Error(`Job not found: ${id}`);
}
const updated: Job = {
...job,
...updates,
updatedAt: new Date()
};
await this.redis.set(
`job:${id}`,
JSON.stringify(updated),
'EX',
this.ttlSeconds
);
}
async listByUser(
userId: string,
options: { status?: JobStatus[]; limit?: number; offset?: number } = {}
): Promise<Job[]> {
const { limit = 20, offset = 0 } = options;
const jobIds = await this.redis.zrevrange(
`jobs:user:${userId}`,
offset,
offset + limit - 1
);
const jobs = await Promise.all(
jobIds.map(id => this.get(id))
);
let filtered = jobs.filter((j): j is Job => j !== null);
if (options.status) {
filtered = filtered.filter(j => options.status!.includes(j.status));
}
return filtered;
}
}
// API Controller
class AsyncJobController {
constructor(
private readonly jobRepository: JobRepository,
private readonly jobQueue: JobQueue,
private readonly validators: Map<string, JobValidator>,
private readonly resultStore: ResultStore
) {}
// POST /jobs
async submitJob(req: Request, res: Response): Promise<void> {
const { type, input } = req.body;
const userId = req.user.id;
// Validate job type
const validator = this.validators.get(type);
if (!validator) {
res.status(400).json({ error: `Unknown job type: ${type}` });
return;
}
// Validate input
const validationResult = await validator.validate(input);
if (!validationResult.valid) {
res.status(400).json({
error: 'Invalid input',
details: validationResult.errors
});
return;
}
// Create job
const job: Job = {
id: uuid(),
type,
status: JobStatus.PENDING,
input,
createdAt: new Date(),
updatedAt: new Date(),
metadata: {
userId,
correlationId: req.headers['x-correlation-id'] as string,
priority: this.calculatePriority(userId)
}
};
await this.jobRepository.create(job);
// Queue for processing
await this.jobQueue.enqueue(job);
// Return 202 Accepted with location header
res.status(202)
.header('Location', `/api/jobs/${job.id}`)
.json({
jobId: job.id,
status: job.status,
statusUrl: `/api/jobs/${job.id}`,
estimatedWaitTime: await this.estimateWaitTime(type)
});
}
// GET /jobs/:id
async getJobStatus(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const userId = req.user.id;
const job = await this.jobRepository.get(id);
if (!job) {
res.status(404).json({ error: 'Job not found' });
return;
}
// Verify ownership
if (job.metadata.userId !== userId && !req.user.isAdmin) {
res.status(404).json({ error: 'Job not found' });
return;
}
// Handle different statuses
switch (job.status) {
case JobStatus.PENDING:
case JobStatus.PROCESSING:
res.status(200).json({
jobId: job.id,
status: job.status,
progress: job.progress,
createdAt: job.createdAt,
estimatedCompletion: this.estimateCompletion(job)
});
break;
case JobStatus.COMPLETED:
const resultUrl = await this.resultStore.getUrl(job.id);
// Option 1: Redirect to result
// res.redirect(303, resultUrl);
// Option 2: Return result URL
res.status(200).json({
jobId: job.id,
status: job.status,
resultUrl,
completedAt: job.completedAt,
expiresAt: job.expiresAt
});
break;
case JobStatus.FAILED:
res.status(200).json({
jobId: job.id,
status: job.status,
error: job.error,
retryUrl: job.error?.retryable ? `/api/jobs/${job.id}/retry` : undefined
});
break;
case JobStatus.CANCELLED:
res.status(200).json({
jobId: job.id,
status: job.status,
cancelledAt: job.completedAt
});
break;
}
}
// DELETE /jobs/:id (cancel)
async cancelJob(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const job = await this.jobRepository.get(id);
if (!job || job.metadata.userId !== req.user.id) {
res.status(404).json({ error: 'Job not found' });
return;
}
if (job.status === JobStatus.COMPLETED || job.status === JobStatus.FAILED) {
res.status(409).json({ error: 'Job already completed' });
return;
}
await this.jobQueue.cancel(job.id);
await this.jobRepository.update(job.id, {
status: JobStatus.CANCELLED,
completedAt: new Date()
});
res.status(200).json({
jobId: job.id,
status: JobStatus.CANCELLED
});
}
// POST /jobs/:id/retry
async retryJob(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const job = await this.jobRepository.get(id);
if (!job || job.metadata.userId !== req.user.id) {
res.status(404).json({ error: 'Job not found' });
return;
}
if (job.status !== JobStatus.FAILED || !job.error?.retryable) {
res.status(409).json({ error: 'Job cannot be retried' });
return;
}
// Create new job with same input
const newJob: Job = {
...job,
id: uuid(),
status: JobStatus.PENDING,
error: undefined,
progress: undefined,
createdAt: new Date(),
updatedAt: new Date(),
startedAt: undefined,
completedAt: undefined,
metadata: {
...job.metadata,
correlationId: job.id // Link to original
}
};
await this.jobRepository.create(newJob);
await this.jobQueue.enqueue(newJob);
res.status(202)
.header('Location', `/api/jobs/${newJob.id}`)
.json({
jobId: newJob.id,
originalJobId: job.id,
status: newJob.status
});
}
private calculatePriority(userId: string): number {
// Higher priority for premium users
return 0;
}
private async estimateWaitTime(type: string): Promise<number> {
// Estimate based on queue depth and historical processing time
return 30000;
}
private estimateCompletion(job: Job): Date | undefined {
if (!job.startedAt || !job.progress) {
return undefined;
}
const elapsed = Date.now() - job.startedAt.getTime();
const progressPercent = job.progress.current / job.progress.total;
if (progressPercent === 0) {
return undefined;
}
const estimatedTotal = elapsed / progressPercent;
return new Date(job.startedAt.getTime() + estimatedTotal);
}
}
Webhook Callbacks
┌─────────────────────────────────────────────────────────────────┐
│ WEBHOOK NOTIFICATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Alternative to polling: Server pushes completion notification │
│ │
│ Client API Worker │
│ │ │ │ │
│ │ POST /reports │ │ │
│ │ { callbackUrl: │ │ │
│ │ "https://my.app/│ │ │
│ │ webhook" } │ │ │
│ │────────────────────▶│ │ │
│ │ │ Create job │ │
│ │ │────────────────────▶│ │
│ │ 202 Accepted │ │ │
│ │◀────────────────────│ │ │
│ │ │ │ │
│ │ │ │ (processing) │
│ │ │ │ │
│ │ │ Job complete │ │
│ │ │◀────────────────────│ │
│ │ │ │ │
│ │ POST callback │ │ │
│ │ { jobId, status, │ │ │
│ │ resultUrl } │ │ │
│ │◀────────────────────│ │ │
│ │ 200 OK │ │ │
│ │────────────────────▶│ │ │
│ │
│ Benefits: │
│ • No polling overhead │
│ • Immediate notification │
│ • Better for long-running jobs │
│ │
│ Challenges: │
│ • Callback URL must be reachable │
│ • Need retry logic for failed callbacks │
│ • Security: validate callback origin │
│ │
└─────────────────────────────────────────────────────────────────┘
// webhook-notifier.ts
interface WebhookConfig {
url: string;
secret?: string; // For HMAC signature
events: ('completed' | 'failed' | 'progress')[];
}
interface WebhookPayload {
event: string;
jobId: string;
timestamp: string;
data: {
status: JobStatus;
resultUrl?: string;
error?: Job['error'];
progress?: Job['progress'];
};
}
class WebhookNotifier {
constructor(
private readonly retryConfig: {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
}
) {}
async notify(config: WebhookConfig, payload: WebhookPayload): Promise<void> {
const body = JSON.stringify(payload);
const signature = config.secret
? this.createSignature(body, config.secret)
: undefined;
for (let attempt = 1; attempt <= this.retryConfig.maxAttempts; attempt++) {
try {
const response = await fetch(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature ?? '',
'X-Webhook-Event': payload.event,
'X-Webhook-Delivery': uuid()
},
body,
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (response.ok) {
return; // Success
}
// 4xx errors are not retryable
if (response.status >= 400 && response.status < 500) {
console.error(`Webhook rejected: ${response.status}`);
return;
}
// 5xx errors - retry
throw new Error(`Webhook returned ${response.status}`);
} catch (error) {
if (attempt === this.retryConfig.maxAttempts) {
console.error(`Webhook failed after ${attempt} attempts:`, error);
// Store for manual retry or dead letter
await this.storeFailedWebhook(config, payload, error as Error);
return;
}
const delay = Math.min(
this.retryConfig.baseDelayMs * Math.pow(2, attempt - 1),
this.retryConfig.maxDelayMs
);
await this.sleep(delay);
}
}
}
private createSignature(body: string, secret: string): string {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(body);
return `sha256=${hmac.digest('hex')}`;
}
private async storeFailedWebhook(
config: WebhookConfig,
payload: WebhookPayload,
error: Error
): Promise<void> {
// Store for manual retry or investigation
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Worker integration
class JobWorker {
constructor(
private readonly jobRepository: JobRepository,
private readonly webhookNotifier: WebhookNotifier
) {}
async processJob(job: Job): Promise<void> {
await this.jobRepository.update(job.id, {
status: JobStatus.PROCESSING,
startedAt: new Date()
});
try {
const result = await this.executeJob(job);
await this.jobRepository.update(job.id, {
status: JobStatus.COMPLETED,
output: result,
completedAt: new Date()
});
// Notify via webhook if configured
if (job.metadata.webhookConfig) {
await this.webhookNotifier.notify(job.metadata.webhookConfig, {
event: 'completed',
jobId: job.id,
timestamp: new Date().toISOString(),
data: {
status: JobStatus.COMPLETED,
resultUrl: await this.getResultUrl(job.id)
}
});
}
} catch (error) {
await this.jobRepository.update(job.id, {
status: JobStatus.FAILED,
error: {
code: (error as Error).name,
message: (error as Error).message,
retryable: this.isRetryable(error as Error)
},
completedAt: new Date()
});
if (job.metadata.webhookConfig) {
await this.webhookNotifier.notify(job.metadata.webhookConfig, {
event: 'failed',
jobId: job.id,
timestamp: new Date().toISOString(),
data: {
status: JobStatus.FAILED,
error: {
code: (error as Error).name,
message: (error as Error).message,
retryable: this.isRetryable(error as Error)
}
}
});
}
}
}
async updateProgress(
jobId: string,
current: number,
total: number,
message?: string
): Promise<void> {
const job = await this.jobRepository.get(jobId);
if (!job) return;
await this.jobRepository.update(jobId, {
progress: { current, total, message }
});
// Optionally notify progress
if (job.metadata.webhookConfig?.events.includes('progress')) {
await this.webhookNotifier.notify(job.metadata.webhookConfig, {
event: 'progress',
jobId,
timestamp: new Date().toISOString(),
data: {
status: JobStatus.PROCESSING,
progress: { current, total, message }
}
});
}
}
private async executeJob(job: Job): Promise<unknown> {
// Execute the actual job
return {};
}
private async getResultUrl(jobId: string): Promise<string> {
return `/api/jobs/${jobId}/result`;
}
private isRetryable(error: Error): boolean {
const retryableErrors = ['NetworkError', 'TimeoutError', 'ServiceUnavailable'];
return retryableErrors.includes(error.name);
}
}
Server-Sent Events for Real-Time Updates
// sse-progress.ts
class SSEProgressController {
private readonly connections: Map<string, Set<Response>> = new Map();
// GET /jobs/:id/events
async streamProgress(req: Request, res: Response): Promise<void> {
const { id } = req.params;
const job = await this.jobRepository.get(id);
if (!job || job.metadata.userId !== req.user.id) {
res.status(404).json({ error: 'Job not found' });
return;
}
// Set up SSE
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no' // Disable nginx buffering
});
// Send initial state
this.sendEvent(res, 'status', {
status: job.status,
progress: job.progress
});
// Register connection
if (!this.connections.has(id)) {
this.connections.set(id, new Set());
}
this.connections.get(id)!.add(res);
// Handle client disconnect
req.on('close', () => {
this.connections.get(id)?.delete(res);
if (this.connections.get(id)?.size === 0) {
this.connections.delete(id);
}
});
// Keep connection alive
const keepAliveInterval = setInterval(() => {
res.write(':keepalive\n\n');
}, 30000);
req.on('close', () => {
clearInterval(keepAliveInterval);
});
}
// Called by worker to broadcast updates
broadcastProgress(jobId: string, progress: Job['progress']): void {
const connections = this.connections.get(jobId);
if (!connections) return;
for (const res of connections) {
this.sendEvent(res, 'progress', progress);
}
}
broadcastCompletion(
jobId: string,
status: JobStatus,
data: { resultUrl?: string; error?: Job['error'] }
): void {
const connections = this.connections.get(jobId);
if (!connections) return;
for (const res of connections) {
this.sendEvent(res, 'complete', { status, ...data });
res.end();
}
this.connections.delete(jobId);
}
private sendEvent(res: Response, event: string, data: unknown): void {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
}
// Client usage (JavaScript)
const eventSourceExample = `
const eventSource = new EventSource('/api/jobs/123/events');
eventSource.addEventListener('status', (e) => {
const { status, progress } = JSON.parse(e.data);
updateUI(status, progress);
});
eventSource.addEventListener('progress', (e) => {
const progress = JSON.parse(e.data);
updateProgressBar(progress.current / progress.total);
});
eventSource.addEventListener('complete', (e) => {
const { status, resultUrl, error } = JSON.parse(e.data);
if (status === 'completed') {
window.location.href = resultUrl;
} else {
showError(error);
}
eventSource.close();
});
eventSource.onerror = () => {
// Reconnect logic
eventSource.close();
setTimeout(connectToEvents, 1000);
};
`;
Idempotency for Job Submission
// idempotent-job-submission.ts
class IdempotentJobService {
constructor(
private readonly jobRepository: JobRepository,
private readonly idempotencyStore: IdempotencyStore
) {}
async submitJob(
idempotencyKey: string,
jobRequest: JobRequest,
userId: string
): Promise<{ job: Job; created: boolean }> {
// Check for existing job with same idempotency key
const existing = await this.idempotencyStore.get(idempotencyKey);
if (existing) {
const job = await this.jobRepository.get(existing.jobId);
if (job && job.metadata.userId === userId) {
return { job, created: false };
}
}
// Create new job
const job: Job = {
id: uuid(),
type: jobRequest.type,
status: JobStatus.PENDING,
input: jobRequest.input,
createdAt: new Date(),
updatedAt: new Date(),
metadata: { userId }
};
// Store idempotency mapping
await this.idempotencyStore.set(idempotencyKey, {
jobId: job.id,
createdAt: new Date()
}, 24 * 60 * 60); // 24 hour TTL
await this.jobRepository.create(job);
return { job, created: true };
}
}
// Controller using idempotency
async submitJob(req: Request, res: Response): Promise<void> {
const idempotencyKey = req.headers['idempotency-key'] as string;
if (!idempotencyKey) {
res.status(400).json({ error: 'Idempotency-Key header required' });
return;
}
const { job, created } = await this.jobService.submitJob(
idempotencyKey,
req.body,
req.user.id
);
if (created) {
res.status(202)
.header('Location', `/api/jobs/${job.id}`)
.json({ jobId: job.id, status: job.status });
} else {
// Return existing job (idempotent response)
res.status(200).json({
jobId: job.id,
status: job.status,
note: 'Duplicate request, returning existing job'
});
}
}
Polling Best Practices
┌─────────────────────────────────────────────────────────────────┐
│ POLLING STRATEGIES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. EXPONENTIAL BACKOFF │
│ ─────────────────────── │
│ │
│ Initial: 1s → 2s → 4s → 8s → 16s → 30s (cap) │
│ │
│ Reduces server load for long-running jobs │
│ │
│ 2. ADAPTIVE POLLING │
│ ─────────────────── │
│ │
│ Server returns hint: │
│ { "retryAfter": 10, "estimatedCompletion": "2024-01-15T..." } │
│ │
│ Client adjusts polling interval based on progress │
│ │
│ 3. LONG POLLING │
│ ──────────────── │
│ │
│ Client: GET /jobs/123?wait=30 │
│ Server: Hold connection until status changes or timeout │
│ │
│ Reduces polling overhead while providing quick updates │
│ │
│ 4. HYBRID (Poll + SSE) │
│ ────────────────────── │
│ │
│ Start with SSE for real-time updates │
│ Fall back to polling if SSE connection fails │
│ │
└─────────────────────────────────────────────────────────────────┘
// client-polling.ts
class JobPoller {
private readonly minInterval = 1000;
private readonly maxInterval = 30000;
private currentInterval = 1000;
async pollUntilComplete(
jobId: string,
onProgress?: (job: JobStatus) => void
): Promise<JobResult> {
while (true) {
const response = await fetch(`/api/jobs/${jobId}`);
const job = await response.json();
onProgress?.(job);
if (job.status === 'completed') {
return { success: true, resultUrl: job.resultUrl };
}
if (job.status === 'failed') {
return {
success: false,
error: job.error,
retryUrl: job.retryUrl
};
}
if (job.status === 'cancelled') {
return { success: false, cancelled: true };
}
// Use server hint if available
if (job.retryAfter) {
await this.sleep(job.retryAfter * 1000);
} else {
await this.sleep(this.currentInterval);
this.currentInterval = Math.min(
this.currentInterval * 1.5,
this.maxInterval
);
}
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
Decision Framework
| Approach | Best For | Latency | Server Load | Complexity |
|---|---|---|---|---|
| Polling | Simple clients, short jobs | Medium | Higher | Low |
| Long Polling | Medium jobs, reduced calls | Low-Medium | Medium | Medium |
| Webhooks | Server-to-server, long jobs | Very Low | Low | Medium |
| SSE | Real-time progress, web clients | Very Low | Medium | Medium |
| WebSocket | Bidirectional, complex UX | Very Low | Medium-High | High |
Common Anti-Patterns
┌─────────────────────────────────────────────────────────────────┐
│ ASYNC REQUEST-REPLY ANTI-PATTERNS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ANTI-PATTERN 1: No Idempotency │
│ ──────────────────────────── │
│ Problem: Retry creates duplicate jobs │
│ Solution: Idempotency key per request │
│ │
│ ANTI-PATTERN 2: Infinite Job Retention │
│ ─────────────────────────────────── │
│ Problem: Jobs and results stored forever │
│ Solution: TTL on jobs and results │
│ │
│ ANTI-PATTERN 3: No Progress Updates │
│ ───────────────────────────────── │
│ Problem: User has no idea if job is progressing │
│ Solution: Progress field with percentage/steps │
│ │
│ ANTI-PATTERN 4: Polling Without Backoff │
│ ───────────────────────────────────── │
│ Problem: Client hammers server with rapid polls │
│ Solution: Exponential backoff, server hints │
│ │
│ ANTI-PATTERN 5: No Cancellation Support │
│ ──────────────────────────────────── │
│ Problem: User can't stop unwanted jobs │
│ Solution: DELETE endpoint, cancellation tokens │
│ │
│ ANTI-PATTERN 6: Blocking on Job Creation │
│ ───────────────────────────────────── │
│ Problem: POST /jobs waits for job to complete │
│ Solution: Return 202 immediately, process async │
│ │
└─────────────────────────────────────────────────────────────────┘
The Async Request-Reply Pattern transforms long-running operations into manageable, observable workflows. Immediate 202 responses with status URLs keep connections short while enabling progress tracking. Whether using polling, webhooks, or SSE for status updates, the pattern decouples request submission from result retrieval—essential for operations that exceed HTTP timeout windows. Combined with idempotency keys, the pattern handles retries gracefully and provides a robust foundation for batch processing, report generation, and any operation that can't complete in milliseconds.
What did you think?