Async Processing: Offloading Work to Improve Response Times

Philip Rehberger Apr 3, 2026 8 min read

Synchronous request handlers that do too much work make every user wait. Async processing separates the response from the work, letting you respond instantly and process in the background.

Async Processing: Offloading Work to Improve Response Times

The simplest rule in web performance: don't make users wait for things they don't need to wait for.

When a user submits an order, they need to know the order was received. They don't need to wait for the confirmation email to be sent, the inventory count to be recalculated, the warehouse system to be notified, and the analytics event to be tracked — all before seeing a success page.

Async processing separates the response (immediate) from the work (background). Done well, it makes your application feel fast while doing more.

What Belongs in Async Processing

Good Candidates for Async

Email and notification sending
  - User doesn't need this before seeing a response
  - Network call to SMTP/SES/SendGrid

File processing
  - Image resizing, format conversion, thumbnail generation
  - PDF generation
  - CSV export for large datasets

Third-party webhook calls
  - Notifying downstream systems
  - Analytics events (Mixpanel, Segment)

Report generation
  - Complex aggregations over large datasets
  - Scheduled weekly/monthly reports

Search index updates
  - Elasticsearch document updates
  - Full-text search re-indexing

Cache warming
  - Rebuilding complex caches after data changes

Data synchronization
  - Syncing to CRM, billing system, etc.

What Should Stay Synchronous

Payment processing
  - User needs confirmation before leaving checkout
  - Failure must be communicated immediately

Inventory reservation
  - Must confirm item is available before accepting order

Authentication and authorization
  - Must validate before returning data

Data reads
  - User is waiting for the response content

Validation
  - Must validate input before showing success

Queue Architectures

Redis-Based Queues (Laravel)

Laravel's queue system abstracts several backends. Redis is the most common for production:

// Create a queued job
php artisan make:job SendWelcomeEmail
// app/Jobs/SendWelcomeEmail.php
class SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;          // Retry 3 times before failing
    public int $backoff = 30;       // Wait 30s between retries
    public int $timeout = 60;       // Kill job after 60 seconds
    public bool $deleteWhenMissingModels = true;  // Don't fail on deleted users

    public function __construct(
        public readonly User $user
    ) {}

    public function handle(Mailer $mailer): void
    {
        $mailer->to($this->user)->send(new WelcomeEmail($this->user));
    }

    public function failed(Throwable $exception): void
    {
        // Notify team of permanent failure
        Log::error('Welcome email failed permanently', [
            'user_id'   => $this->user->id,
            'exception' => $exception->getMessage(),
        ]);
    }
}
// Dispatch from controller — returns immediately
public function store(RegisterRequest $request)
{
    $user = User::create($request->validated());

    // This returns immediately — email sends in background
    SendWelcomeEmail::dispatch($user);

    // You can also delay:
    SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));

    // Or send to specific queue:
    SendWelcomeEmail::dispatch($user)->onQueue('emails');

    return response()->json(['id' => $user->id], 201);
}

Queue Priority with Multiple Queues

Not all jobs are equal. Use separate queues for different priority levels:

// High priority: user-visible operations
ProcessPayment::dispatch($order)->onQueue('critical');

// Normal: user-initiated but not blocking
SendOrderConfirmation::dispatch($order)->onQueue('default');

// Low: background maintenance
RebuildSearchIndex::dispatch()->onQueue('low');
# Worker processes queues in priority order
php artisan queue:work redis \
    --queue=critical,default,low \
    --max-jobs=1000 \
    --max-time=3600

By listing critical first, the worker always checks for critical jobs before processing lower-priority ones. If critical jobs are waiting, they won't be starved by a flood of low-priority work.

Job Design Patterns

Idempotent Jobs

Jobs should be safe to run multiple times. Networks fail, workers restart, queues replay. Design jobs to handle being called twice with the same arguments:

class ChargeSubscription implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(): void
    {
        // Check if we already charged this period
        $alreadyCharged = Invoice::where('subscription_id', $this->subscription->id)
            ->where('period', $this->billingPeriod)
            ->whereIn('status', ['paid', 'pending'])
            ->exists();

        if ($alreadyCharged) {
            Log::info('Subscription already charged, skipping', [
                'subscription_id' => $this->subscription->id,
                'period'          => $this->billingPeriod,
            ]);
            return;  // Safe to exit — work already done
        }

        // Proceed with charge...
    }
}

Chunking Large Jobs

A job that processes 100,000 records might run for hours, consuming a worker and risking timeouts. Break it into smaller chunks:

class ProcessBulkExport implements ShouldQueue
{
    public function handle(): void
    {
        $totalRecords = Order::where('status', 'completed')->count();
        $chunkSize    = 1000;

        for ($offset = 0; $offset < $totalRecords; $offset += $chunkSize) {
            // Dispatch a child job for each chunk
            ProcessExportChunk::dispatch(
                $this->exportId,
                $offset,
                $chunkSize
            );
        }

        // Dispatch a finalization job
        FinalizeExport::dispatch($this->exportId)
            ->delay(now()->addMinutes(30));  // Allow time for chunks to complete
    }
}

Each chunk job is small, fast, and independently retriable. If chunk 47 fails, only chunk 47 retries.

Job Batching

Laravel's batch feature tracks a group of jobs and fires a callback when they all complete:

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;

$batch = Bus::batch([
    new ProcessOrderRegion('north-america'),
    new ProcessOrderRegion('europe'),
    new ProcessOrderRegion('asia-pacific'),
])->then(function (Batch $batch) {
    // All jobs completed successfully
    SendDailyReport::dispatch($batch->id);
})->catch(function (Batch $batch, Throwable $e) {
    // First failure
    Log::error('Batch processing failed', ['exception' => $e->getMessage()]);
})->finally(function (Batch $batch) {
    // Always runs, success or failure
    Cache::forget('daily-processing-lock');
})->dispatch();

// Check batch status later
$status = Bus::findBatch($batchId);
echo $status->progress();  // 0-100

Message Queues: Beyond Job Queues

For high-throughput async processing, dedicated message queue systems offer more capability.

RabbitMQ / SQS for Decoupled Services

When multiple services need to react to an event, a pub/sub model is more appropriate than directly dispatching jobs:

# Producer: order service publishes event
import boto3
import json

sqs = boto3.client('sqs', region_name='us-east-1')

def on_order_completed(order):
    # Publish to SNS topic — all subscribers receive it
    sns = boto3.client('sns')
    sns.publish(
        TopicArn='arn:aws:sns:us-east-1:123456789:order-events',
        Message=json.dumps({
            'event':    'order.completed',
            'order_id': order['id'],
            'user_id':  order['user_id'],
            'total':    order['total'],
            'timestamp': datetime.utcnow().isoformat()
        }),
        MessageAttributes={
            'event_type': {
                'StringValue': 'order.completed',
                'DataType': 'String'
            }
        }
    )
# Consumer 1: email service listens on its own SQS queue
def process_order_event(message):
    event = json.loads(message['Body'])
    if event['event'] == 'order.completed':
        send_order_confirmation_email(event['order_id'], event['user_id'])

# Consumer 2: inventory service listens on its own SQS queue
def process_inventory_event(message):
    event = json.loads(message['Body'])
    if event['event'] == 'order.completed':
        update_inventory_counts(event['order_id'])

# Consumer 3: analytics service listens on its own SQS queue
def process_analytics_event(message):
    event = json.loads(message['Body'])
    track_order_event(event)

Each consumer has its own queue. If the email service is slow, it doesn't affect the inventory service. Each can scale independently.

Dead Letter Queues

When messages fail repeatedly, move them to a dead letter queue (DLQ) rather than dropping them or retrying forever:

// SQS Queue configuration with DLQ
{
  "QueueName": "order-processing",
  "RedrivePolicy": {
    "deadLetterTargetArn": "arn:aws:sqs:us-east-1:123:order-processing-dlq",
    "maxReceiveCount": 5
  }
}
# Monitor DLQ depth
import boto3

def check_dlq_depth():
    sqs = boto3.client('sqs')
    attrs = sqs.get_queue_attributes(
        QueueUrl=DLQ_URL,
        AttributeNames=['ApproximateNumberOfMessages']
    )
    depth = int(attrs['Attributes']['ApproximateNumberOfMessages'])

    if depth > 10:
        notify_oncall(f'DLQ has {depth} failed messages — investigation needed')

Alerts on DLQ depth prevent silent data loss. Messages in the DLQ can be inspected, debugged, and replayed after fixing the underlying issue.

Monitoring Async Processing

Async jobs fail silently unless you watch them. Essential metrics:

Queue depth        → Messages waiting to be processed
Processing lag     → Time between enqueue and completion
Job failure rate   → % of jobs that fail
DLQ depth          → Failed jobs that gave up
Worker utilization → Are workers busy or idle?
Job duration       → How long individual jobs take
// Laravel Horizon: Redis queue monitoring
composer require laravel/horizon
php artisan horizon:install
php artisan horizon

// Provides:
// - Real-time queue metrics dashboard
// - Job failure tracking
// - Throughput per queue
// - Pause and resume individual queues
# Kubernetes: scale workers based on queue depth
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: queue-worker-scaler
spec:
  scaleTargetRef:
    name: queue-worker
  minReplicaCount: 1
  maxReplicaCount: 20
  triggers:
    - type: redis
      metadata:
        address: redis:6379
        listName: queues:default
        listLength: "20"  # 1 worker per 20 pending jobs

Patterns for User Feedback

Async processing creates a UX challenge: the user needs to know when their work is done.

Polling

Simple: client polls an endpoint until the job is complete:

async function waitForExport(exportId) {
  const pollInterval = 2000;  // 2 seconds
  const maxAttempts  = 60;    // 2 minutes max

  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(`/api/exports/${exportId}`);
    const data     = await response.json();

    if (data.status === 'completed') {
      window.location.href = data.download_url;
      return;
    }

    if (data.status === 'failed') {
      showError('Export failed. Please try again.');
      return;
    }

    await sleep(pollInterval);
    // Exponential backoff for long-running jobs:
    // pollInterval = Math.min(pollInterval * 1.5, 10000);
  }

  showError('Export is taking longer than expected. You\'ll receive an email when ready.');
}

WebSocket Push

For real-time feedback, push the completion event via WebSocket:

// Job fires an event when complete
class GenerateExport implements ShouldQueue
{
    public function handle(): void
    {
        $downloadUrl = $this->buildExport();

        // Broadcast to the user's browser via WebSocket
        broadcast(new ExportReady($this->user, $downloadUrl))->toOthers();
    }
}
// Browser listens for the event
Echo.private(`user.${userId}`)
    .listen('ExportReady', (event) => {
        showSuccess('Your export is ready!');
        window.location.href = event.downloadUrl;
    });

Async processing is one of the most impactful architectural changes you can make to a synchronous application. Identify your longest synchronous operations, measure their impact on response times, and systematically move them to background queues. Users will notice the difference.

Building something that needs to scale? We help teams architect systems that grow with their business. scopeforged.com

Share this article

Related Articles

Reading Database EXPLAIN Plans: From Mystery to Mastery

Reading Database EXPLAIN Plans: From Mystery to Mastery

EXPLAIN plans tell you exactly what your database engine is doing to execute a query — whether it's using indexes, how many rows it's scanning, and what it costs. Learning to read them is essential for database performance work.

Apr 2, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.