Global brand campaigns don’t warn you before they explode. You get a launch date, an estimated traffic budget, and a tacit expectation that your stack won’t fall over. In this case, the real number exceeded the initial estimate by almost an order of magnitude. Here’s what we did to keep the application responding.
The Context: A 48-Hour Window
The campaign had a 48-hour activation window with concentrated spikes in the first 30 minutes of each phase. The original stack was Laravel + Vue 2 with a monolithic frontend that loaded the entire bundle upfront. Under normal load it worked. Under 15,000 concurrent requests per minute, the server started returning 502s and the frontend froze on mid-range mobile devices.
The problem wasn’t a single bottleneck. It was a cascade: the heavy frontend generated more API requests, the API had no strategic caching, and payment transactions blocked workers synchronously.
1. Reducing Initial Bundle with Vue 3 + Suspense
The first change was migrating the frontend to Vue 3 with route-based lazy loading. We split the application into chunks by route and used Suspense to handle loading states without blocking the main render.
<!-- App.vue - Critical shell only -->
<script setup lang="ts">
import { Suspense } from 'vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
// Lazy routes — only loaded when visited
const CampaignHero = defineAsyncComponent(() => import('@/pages/CampaignHero.vue'))
const ProductCatalog = defineAsyncComponent(() => import('@/pages/ProductCatalog.vue'))
const AdminDashboard = defineAsyncComponent(() => import('@/pages/AdminDashboard.vue'))
const Analytics = defineAsyncComponent(() => import('@/pages/Analytics.vue'))
</script>
<template>
<AppHeader />
<main>
<Suspense fallback="Loading campaign…">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</Suspense>
</main>
<AppFooter />
</template>
// router/index.ts — Route-based code splitting
const routes = [
{
path: '/',
name: 'hero',
component: () => import('@/pages/CampaignHero.vue'), // Critical: in initial bundle
},
{
path: '/catalog',
name: 'catalog',
component: () => import('@/pages/ProductCatalog.vue'), // Lazy chunk
},
{
path: '/admin',
name: 'admin',
component: () => import('@/pages/AdminDashboard.vue'), // Lazy — most users never see
meta: { requiresAuth: true, roles: ['admin'] }
},
{
path: '/analytics',
name: 'analytics',
component: () => import('@/pages/Analytics.vue'), // Lazy
},
]
Results:
- Initial bundle: 180 KB → 95 KB gzipped (47% reduction)
- 3G connections: >1s improvement in perceived load time
- Browser no longer downloads admin/analytics components that 95% of users never touch
- First paint occurs before rest of app hydrates
2. Smart Caching with Redis + Dynamic TTL
The second bottleneck was backend. Every visit queried campaign config, product catalog, and stock status. All changed, but not every second.
// app/Services/Cache/CampaignCacheService.php
class CampaignCacheService
{
public function __construct(
private Redis $redis,
private CampaignConfigRepository $configRepo,
private ProductCatalogRepository $catalogRepo,
) {}
public function getConfig(string $campaignId): CampaignConfig
{
return $this->redis->remember(
"campaign:{$campaignId}:config",
300, // 5 min TTL — only changes when marketing activates new phase
fn() => $this->configRepo->findActive($campaignId)
);
}
public function getCatalog(string $campaignId): ProductCollection
{
return $this->redis->remember(
"campaign:{$campaignId}:catalog",
120, // 2 min TTL — stock updated from queues, not user request
fn() => $this->catalogRepo->getActiveWithStock($campaignId)
);
}
public function invalidateCatalog(string $campaignId): void
{
// Called from StockUpdatedJob after queue processes stock change
$this->redis->del("campaign:{$campaignId}:catalog");
}
}
// app/Http/Middleware/SkipCacheMiddleware.php — Explicit opt-out for real-time
class SkipCacheMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Endpoints that MUST be real-time (results, payments, user-specific)
if ($this->isRealTimeEndpoint($request)) {
$response->headers->set('X-Cache', 'SKIP');
$response->headers->set('Cache-Control', 'no-store, private');
}
return $response;
}
private function isRealTimeEndpoint(Request $request): bool
{
$realTimePatterns = [
'api/v1/campaigns/*/results',
'api/v1/payments/*',
'api/v1/user/*',
];
return collect($realTimePatterns)->contains(fn($pattern) =>
$request->is($pattern)
);
}
}
Cache Strategy Summary:
| Data | TTL | Invalidation | Why |
|---|---|---|---|
| Campaign config | 5 min | Manual (phase change) | Changes only when marketing activates phase |
| Product catalog | 2 min | StockUpdatedJob event | Stock updated from queues, not user request |
| Real-time results | None | N/A | Must reflect live state |
| User session/data | None | N/A | Personalized, never cache |
Key principle: Be selective. Caching everything creates inconsistencies. Caching nothing creates unnecessary load. Document every decision in code so the next developer understands why one endpoint has 120s TTL and another has zero.
3. Laravel Queues for Payments + Ticket Generation
The third-party payment integration was the most fragile point. The provider had variable latency and a concurrency limit. Processing payments synchronously meant a traffic spike could saturate workers and block requests from users just browsing the catalog.
// app/Http/Controllers/PaymentController.php
class PaymentController extends Controller
{
public function initiate(InitiatePaymentRequest $request): JsonResponse
{
$user = $request->user();
$campaign = $request->validated('campaign_id');
// 1. Create pending payment record (fast, no external calls)
$payment = Payment::create([
'user_id' => $user->id,
'campaign_id' => $campaign,
'amount' => $request->validated('amount'),
'currency' => 'EUR',
'status' => 'pending',
'idempotency_key' => $request->header('Idempotency-Key') ?? Str::uuid(),
]);
// 2. Dispatch to queue — IMMEDIATE response to user
ProcessPaymentJob::dispatch($payment)
->onQueue('payments') // Dedicated queue, dedicated workers
->delay(now()->addSeconds(1)); // Small delay for DB consistency
return response()->json([
'payment_id' => $payment->id,
'status' => 'processing',
'message' => 'Your payment is being processed. You\'ll receive confirmation shortly.',
], 202);
}
}
// app/Jobs/ProcessPaymentJob.php
class ProcessPaymentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [30, 60, 120]; // Exponential backoff: 30s, 60s, 120s
public $timeout = 60;
public function __construct(
public Payment $payment,
) {}
public function handle(
PaymentProvider $provider,
NotificationService $notifications,
): void {
DB::transaction(function () use ($provider, $notifications) {
// Refresh to get latest state
$this->payment->refresh();
if ($this->payment->status !== 'pending') {
return; // Already processed (idempotency)
}
try {
// 3. Call external provider (variable latency, rate limited)
$result = $provider->charge([
'amount' => $this->payment->amount,
'currency' => $this->payment->currency,
'customer' => $this->payment->user->stripe_customer_id,
'idempotency_key' => $this->payment->idempotency_key,
'metadata' => [
'campaign_id' => $this->payment->campaign_id,
'payment_id' => $this->payment->id,
],
]);
$this->payment->update([
'status' => 'completed',
'provider_transaction_id' => $result->id,
'completed_at' => now(),
]);
// 4. Async side effects — also queued
GenerateTicketJob::dispatch($this->payment)->onQueue('documents');
SendPaymentConfirmationEmail::dispatch($this->payment)->onQueue('notifications');
} catch (ProviderRateLimitException $e) {
// Re-queue with delay — don't fail the job
$this->release($e->retryAfter ?? 60);
} catch (ProviderException $e) {
$this->payment->update([
'status' => 'failed',
'failure_reason' => $e->getMessage(),
'failed_at' => now(),
]);
$notifications->notifyUser($this->payment->user, 'payment_failed', [
'payment_id' => $this->payment->id,
'reason' => $e->getMessage(),
]);
throw $e; // Let Laravel handle retry/backoff
}
});
}
public function failed(Throwable $exception): void
{
$this->payment->update([
'status' => 'failed',
'failure_reason' => $exception->getMessage(),
'failed_at' => now(),
]);
// Alert team — critical path failure
report($exception);
}
}
// config/horizon.php — Dedicated queue for payments
'queues' => [
'critical' => ['payments', 'billing'], // balance=3, timeout=60
'default' => ['integrations', 'emails'], // balance=2, timeout=120
'low' => ['reports', 'cleanup'], // balance=1, timeout=300
],
'environments' => [
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['payments', 'billing'],
'balance' => 'simple',
'processes' => 5, // 5 dedicated workers for payments
'tries' => 3,
'timeout' => 60,
'memory' => 256,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['integrations', 'emails', 'documents', 'notifications'],
'balance' => 'simple',
'processes' => 3,
],
],
],
Why This Was the Highest-Impact Decision:
| Before (Sync) | After (Async Queue) |
|---|---|
| User waits 3-5s for payment | User gets 202 Accepted in <200ms |
| Provider 3s latency → all workers blocked | Dedicated 5 workers absorb latency |
| 502s under load when provider slow | System stable, queue buffers spikes |
| No retry logic | Exponential backoff + idempotency |
| No observability | Horizon dashboard + Sentry tracing |
4. Blue/Green Deploy with Zero Downtime
In a 48-hour campaign, you can’t afford 30 seconds of downtime for a migration or hotfix.
# .github/workflows/deploy-campaign.yml
name: Deploy Campaign (Blue/Green)
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag to deploy'
required: true
environment:
description: 'Target environment'
required: true
type: choice
options: [staging, production]
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment }}
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, pdo, pdo_mysql
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Build frontend
run: |
npm ci
npm run build
- name: Deploy to Forge (parallel environment)
env:
FORGE_API_TOKEN: ${{ secrets.FORGE_API_TOKEN }}
run: |
# Deploy to NEW server (blue/green)
forge deploy:server \
--server= ${{ secrets.FORGE_SERVER_NEW }} \
--site= ${{ secrets.FORGE_SITE }} \
--commit= ${{ github.sha }} \
--branch= main
- name: Run migrations on new server
run: |
forge ssh ${{ secrets.FORGE_SERVER_NEW }} \
"cd /home/forge/${{ secrets.FORGE_SITE }} && php artisan migrate --force --isolated"
- name: Health check new server
run: |
for i in {1..10}; do
if curl -sf "https://new.${{ secrets.FORGE_SITE }}/health"; then
echo "✅ New server healthy"
exit 0
fi
sleep 3
done
echo "❌ Health check failed"
exit 1
- name: Switch traffic (atomic nginx reload)
run: |
forge site:update ${{ secrets.FORGE_SERVER_NEW }} ${{ secrets.FORGE_SITE }} \
--root-directory=/home/forge/${{ secrets.FORGE_SITE }}/current/public
forge nginx:reload ${{ secrets.FORGE_SERVER_NEW }}
- name: Verify post-switch
run: |
sleep 5
curl -sf "https://${{ secrets.FORGE_SITE }}/health" || exit 1
echo "✅ Deploy complete"
- name: Cleanup old server (after 30 min)
if: success()
run: |
sleep 1800
forge server:delete ${{ secrets.FORGE_SERVER_OLD }} --force
// routes/web.php — Health check with dependency verification
Route::get('/health', function () {
$checks = [
'database' => fn() => DB::connection()->getPdo() ? 'ok' : 'fail',
'redis' => fn() => Redis::connection()->ping() === 'PONG' ? 'ok' : 'fail',
'queue' => fn() => Horizon::isRunning() ? 'ok' : 'degraded',
'storage' => fn() => Storage::disk('local')->exists('.health') ? 'ok' : 'fail',
];
$results = collect($checks)->map(fn($check) => $check())->toArray();
$status = collect($results)->every(fn($v) => $v === 'ok') ? 'ok' : 'degraded';
return response()->json([
'status' => $status,
'version' => config('app.version', 'unknown'),
'git_sha' => trim(shell_exec('git rev-parse --short HEAD 2>/dev/null') ?? ''),
'deployed_at' => now()->toISOString(),
'checks' => $results,
])->header('Cache-Control', 'no-store, private');
})->middleware('throttle:60,1');
During Campaign: 2 Hotfixes, 0 Incidents
| Deploy | Reason | Switch Time | Rollback Time | User Incidents |
|---|---|---|---|---|
| v2.1.3 | Fix hero copy typo | 1.8s | N/A | 0 |
| v2.1.4 | Adjust stock cache TTL | 2.1s | N/A | 0 |
Key: Tests + migrations + health check in parallel environment BEFORE switch. If anything fails, traffic stays on previous version.
Final Numbers: What Matters to Business
| KPI | Target | Result | Business Impact |
|---|---|---|---|
| Campaign uptime | 99.5% | 99.97% | 0 lost sales |
| API p95 (peak) | <500ms | 120ms | Smooth UX, 0 complaints |
| Initial bundle | <200KB | 95 KB | +34% mobile conversion |
| Failed payments | <2% | 0.3% | €42k recovered |
| Critical incidents | 0 | 0 | Product team slept well |
| Stack reuse | — | 3 more campaigns | Architecture ROI ×4 |
Conclusion: The Hard Part Isn’t The Technique
Handling high traffic isn’t adding servers. It’s understanding where the real bottleneck lives:
| Bottleneck | Symptom | Solution Applied |
|---|---|---|
| Heavy frontend | Parse bundle >3s mobile | Vue 3 lazy loading + Suspense |
| Repeated queries | 150ms/request catalog | Redis semantic TTL (2-5 min) |
| Sync payments | Workers blocked 3s | Laravel queues + dedicated workers |
| Deploy risk | 30s downtime unacceptable | Blue/Green + health checks |
None of these techniques are exotic. The hard part is knowing when to apply each, measuring before/after, and documenting so the next dev doesn’t break what works.
Is Your Team Preparing a Campaign With Uncertain Traffic?
If you use Laravel and/or Vue and need architecture that handles spikes without waking you at 3 AM, I can help:
- Architecture audit (2 days): Identify bottlenecks, propose prioritized fixes
- Hands-on implementation (1-2 weeks): Caching, queues, frontend, zero-downtime deploy
- Realistic load testing: Simulate your traffic pattern, validate limits, document runbooks
Modalities:
- EOR (Deel, Remote, Oyster) — core indefinite role
- Freelance B2B (Autónomo, intra-community invoice 0% VAT) — 3-12 month projects
- Direct permanent — if you have Spanish entity
Current stack: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.
View my profile, full stack & terms →
Related Articles on This Blog
- Laravel + Vue for Distributed Teams — Inertia architecture, async workflow, onboarding
- Metrics a Senior Full-Stack Should Defend — Cycle Time, MTTR, Core Web Vitals, infra cost, Bus Factor
- How I Use Claude Code in Production — Morning flow, writing, refactoring, MCP
- Tech Stack for Spanish Startups 2026 — Decisions with tradeoffs, infra by phase
