Las campañas de marca globales no avisan cuando van a explotar. Te dan una fecha de lanzamiento, un presupuesto de tráfico estimado y una expectativa tácita de que tu stack no se caiga. En este caso, la cifra real superó la estimación inicial por casi un orden de magnitud. Esto es exactamente lo que hicimos para que la aplicación siguiera respondiendo — con código real, decisiones documentadas y métricas post-evento.
El Contexto: Ventana de 48 Horas, Tráfico Impredecible
| Parámetro | Estimación Inicial | Realidad en Pico |
|---|---|---|
| Req/min | ~2.000 | 15.000 (7.5×) |
| Duración | 48h continuas | 3 picos de 30 min cada 12h |
| Pagos/min | ~200 | 1.200 (6×) |
| Dispositivos | 60% desktop | 65% móvil (3G/4G variable) |
Stack original: Laravel 9 + Vue 2 (monolito frontend, bundle único 180 KB gzipped) + MySQL + pagos síncronos.
Síntomas en test de carga (5k req/min):
- API: 502 Bad Gateway (PHP-FPM pool agotado)
- Frontend: congelación en móviles gama media (bundle parse > 3s)
- Pagos: timeouts proveedor → bloqueo workers → cascada de fallos
Lección clave: El problema nunca es un solo cuello de botella. Es una cascada: frontend pesado → más requests API → sin cache → pagos síncronos bloquean workers → todo colapsa.
1. Frontend: Vue 3 + Lazy Loading + Suspense = -47% Bundle
El Cambio
// main.ts — Entry point optimizado
import { createApp, Suspense } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
// Shell crítico SOLO (header, footer, auth state)
const app = createApp({
render: () => h(Suspense, {
default: h(App),
fallback: h('div', { class: 'app-shell-loading' }, 'Cargando...')
})
})
app.use(createPinia())
app.use(router)
app.mount('#app')
// router/index.ts — Route-based code splitting
const routes = [
{
path: '/',
name: 'campaign',
component: () => import('@/views/CampaignView.vue'), // Chunk crítico
meta: { preload: true }
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminView.vue'), // Lazy
meta: { requiresAuth: true, roles: ['admin'] }
},
{
path: '/analytics',
name: 'analytics',
component: defineAsyncComponent(() => import('@/views/AnalyticsView.vue')) // Lazy
}
]
// Preload solo el chunk crítico en primera visita
router.beforeEach((to, from, next) => {
if (to.meta.preload && !from.name) {
import('@/views/CampaignView.vue') // Fuerza carga temprana
}
next()
})
<!-- CampaignView.vue — Shell + Suspense -->
<template>
<div class="campaign-shell">
<CampaignHeader /> <!-- Siempre en bundle inicial -->
<main>
<Suspense>
<template #default>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</template>
<template #fallback>
<CampaignSkeleton /> <!-- Skeleton loading, no spinner -->
</template>
</Suspense>
</main>
<CampaignFooter />
</div>
</template>
Resultados Medibles
| Métrica | Antes | Después | Impacto |
|---|---|---|---|
| Bundle inicial (gz) | 180 KB | 95 KB | -47% |
| First Contentful Paint (3G) | 3.2s | 1.8s | -44% |
| Time to Interactive (3G) | 5.1s | 2.9s | -43% |
| Requests iniciales | 12 | 4 | -67% |
| Lighthouse Performance | 58 | 92 | +34 pts |
Por qué funciona: El usuario ve contenido útil en <2s. Los componentes admin/analytics (80 KB) nunca se descargan salvo que el usuario navegue allí.
Suspense+ skeleton evita layout shift.
2. Backend: Cacheo Selectivo con Redis + TTL Dinámico
El Problema
Cada request hacía 3 queries que cambiaban, pero no cada segundo:
- Config campaña (fase activa, fechas, reglas) → ~50ms
- Catálogo productos + stock → ~80ms
- Resultados tiempo real (leaderboard, contadores) → ~30ms
Solución: Cacheo por Capas con TTL Semántico
// app/Services/Cache/CampaignCacheService.php
class CampaignCacheService
{
public function __construct(
private Redis $redis,
private CampaignRepository $campaigns,
private ProductRepository $products
) {}
/** Config: cambia solo al activar fase → TTL 5 min */
public function getConfig(string $campaignId): CampaignConfig
{
return $this->redis->remember(
"campaign:config:{$campaignId}",
300, // 5 min = window de activación fase
fn() => $this->campaigns->getConfig($campaignId)
);
}
/** Catálogo: stock actualiza vía cola → TTL 2 min */
public function getCatalog(string $campaignId): Collection
{
return $this->redis->remember(
"campaign:catalog:{$campaignId}",
120, // 2 min = sync stock desde cola
fn() => $this->products->getActiveForCampaign($campaignId)
);
}
/** Resultados: tiempo real = SIN cache */
public function getLiveResults(string $campaignId): LiveResults
{
// Explícitamente NO cacheado — middleware skip_cache
return $this->campaigns->getLiveResults($campaignId);
}
}
// app/Http/Middleware/SkipCacheForLiveEndpoints.php
class SkipCacheForLiveEndpoints
{
private const LIVE_ROUTES = [
'campaign.live-results',
'campaign.leaderboard',
'campaign.stock-check'
];
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->routeIs(...self::LIVE_ROUTES)) {
$response->headers->set('X-Cache', 'SKIP');
$response->headers->set('Cache-Control', 'no-store, private');
}
return $response;
}
}
Invalidación Inteligente (No “Cachear Todo”)
// app/Jobs/UpdateCampaignStock.php — Se ejecuta tras cada pago confirmado
class UpdateCampaignStock implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $campaignId,
public int $productId,
public int $quantitySold
) {}
public function handle(CampaignCacheService $cache): void
{
// 1. Actualiza BD (stock real)
Product::where('id', $this->productId)
->decrement('stock', $this->quantitySold);
// 2. Invalida SOLO catálogo (config no cambia, live no cachea)
$cache->invalidateCatalog($this->campaignId);
// 3. Opcional: precalienta catálogo si TTL < 30s
if ($cache->getCatalogTtl($this->campaignId) < 30) {
$cache->warmCatalog($this->campaignId);
}
}
}
Resultados Medibles
| Endpoint | Antes (p95) | Después (p95) | Cache Hit Rate |
|---|---|---|---|
GET /api/campaign/config | 52 ms | 4 ms | 99.2% |
GET /api/campaign/catalog | 84 ms | 6 ms | 94.8% |
GET /api/campaign/live-results | 31 ms | 28 ms | N/A (skip) |
| API p95 global | 180 ms | 120 ms | — |
Regla de oro: Cachear todo = inconsistencias. No cachear = carga innecesaria. Documenta cada decisión en el código (como
skip_cachemiddleware) para que el siguiente dev entienda por qué un endpoint tiene 120s TTL y otro cero.
3. Pagos: Colas Laravel + Redis = Cero Bloqueo
El Riesgo
Proveedor pagos: latencia variable (300ms–3s), límite concurrencia 50 req/s.
Síncrono: 1.200 pagos/min en pico → 20 workers bloqueados 3s → pool agotado en 8s.
Arquitectura Async
// app/Http/Controllers/PaymentController.php
class PaymentController extends Controller
{
public function initiate(InitiatePaymentRequest $request): JsonResponse
{
$user = $request->user();
$campaign = $request->campaign();
// 1. Crea registro "pendiente" en BD (idempotency key)
$payment = Payment::create([
'user_id' => $user->id,
'campaign_id' => $campaign->id,
'amount' => $campaign->price,
'currency' => 'EUR',
'status' => 'pending',
'idempotency_key' => $request->header('Idempotency-Key') ?? Str::uuid(),
'metadata' => $request->validated('metadata', []),
]);
// 2. Dispara job ASYNC — request responde EN <200ms
ProcessPaymentJob::dispatch($payment)
->onQueue('payments') // Worker dedicado
->delay(now()->addSeconds(2)); // Pequeño buffer
// 3. Respuesta inmediata al frontend
return response()->json([
'payment_id' => $payment->id,
'status' => 'processing',
'poll_url' => route('payments.status', $payment),
'estimated_seconds' => 5
], 202);
}
public function status(Payment $payment): JsonResponse
{
$this->authorize('view', $payment);
return response()->json([
'status' => $payment->status,
'transaction_id' => $payment->transaction_id,
'receipt_url' => $payment->receipt_url,
]);
}
}
// app/Jobs/ProcessPaymentJob.php
class ProcessPaymentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [10, 30, 60]; // Exponential backoff
public $timeout = 60;
public function __construct(public Payment $payment) {}
public function handle(PaymentGateway $gateway, PaymentService $service): void
{
// Idempotencia: si ya procesado, no reintentar
if ($this->payment->status !== 'pending') {
return;
}
try {
$this->payment->update(['status' => 'processing']);
// Llamada proveedor (puede tardar 3s)
$result = $gateway->charge([
'amount' => $this->payment->amount,
'currency' => $this->payment->currency,
'idempotency_key' => $this->payment->idempotency_key,
'customer_email' => $this->payment->user->email,
'metadata' => $this->payment->metadata,
]);
// Éxito: actualiza + dispara eventos
$this->payment->update([
'status' => 'completed',
'transaction_id' => $result->id,
'gateway_response' => $result->toArray(),
'completed_at' => now(),
]);
PaymentCompleted::dispatch($this->payment); // Evento dominio
} catch (GatewayException $e) {
// Reintentos automáticos via backoff
if ($this->attempts() >= $this->tries) {
$this->payment->update([
'status' => 'failed',
'failure_reason' => $e->getMessage(),
'failed_at' => now(),
]);
PaymentFailed::dispatch($this->payment);
}
throw $e; // Re-lanza para reintento cola
}
}
}
// config/horizon.php — Worker dedicado pagos
'queues' => [
'payments' => ['balance' => 3, 'timeout' => 120, 'tries' => 3],
'notifications' => ['balance' => 1, 'timeout' => 60],
'integrations' => ['balance' => 1, 'timeout' => 180],
'default' => ['balance' => 1, 'timeout' => 60],
],
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['payments', 'critical'],
'balance' => 'simple',
'processes' => 10, // 10 workers SOLO pagos
'tries' => 3,
],
],
],
Frontend: Polling Inteligente (No WebSockets Innecesarios)
// composables/usePaymentStatus.ts
export function usePaymentStatus(paymentId: string) {
const status = ref<'pending' | 'processing' | 'completed' | 'failed'>('pending')
const error = ref<string | null>(null)
const poll = async () => {
try {
const res = await $fetch(`/api/payments/${paymentId}/status`)
status.value = res.status
if (['completed', 'failed'].includes(res.status)) {
stopPolling()
}
} catch (e) {
error.value = 'Error consultando estado'
stopPolling()
}
}
// Polling con backoff: 1s → 2s → 3s → 5s (máx 30s)
let interval = 1000
const timer = setInterval(async () => {
await poll()
interval = Math.min(interval * 1.5, 5000)
}, interval)
const stopPolling = () => clearInterval(timer)
onUnmounted(stopPolling)
return { status, error, stopPolling }
}
Resultados Pagos
| Métrica | Antes (Sync) | Después (Async) |
|---|---|---|
| API p95 en pico | 2.8s (timeouts) | 180 ms |
| Workers PHP-FPM | 100% saturados | <15% uso |
| Pagos fallidos por timeout | 12% | 0.3% |
| Experiencia usuario | Congelación 3-10s | Feedback inmediato + polling |
4. Deploy Blue/Green: Cero Downtime en Ventana Crítica
Setup Laravel Forge + Script
# deploy.sh — Ejecutado en Forge post-deploy
#!/bin/bash
set -euo pipefail
SITE="campaign.example.com"
NEW_VERSION=$(git rev-parse --short HEAD)
BLUE_DIR="/home/forge/${SITE}-blue"
GREEN_DIR="/home/forge/${SITE}-green"
CURRENT=$(readlink -f "/home/forge/${SITE}")
TARGET=$([ "$CURRENT" = "$BLUE_DIR" ] && echo "$GREEN_DIR" || echo "$BLUE_DIR")
echo "🚀 Deploy $NEW_VERSION → $TARGET (current: $CURRENT)"
# 1. Clona/actualiza entorno target
if [ ! -d "$TARGET" ]; then
git clone git@github.com:org/repo.git "$TARGET"
fi
cd "$TARGET"
git fetch origin
git checkout main
git pull origin main
# 2. Instala deps + build (paralelo)
composer install --no-dev --prefer-dist --optimize-autoloader &
npm ci &
npm run build &
wait
# 3. Migraciones + tests en entorno aislado
php artisan migrate --force --isolated
php artisan test --parallel --stop-on-failure
# 3b. Health check interno
curl -sf "http://localhost:8000/health" || { echo "❌ Health check failed"; exit 1; }
# 4. Switch atómico (nginx reload < 2s)
sudo sed -i "s|root $CURRENT|root $TARGET|" /etc/nginx/sites-available/$SITE
sudo nginx -t && sudo systemctl reload nginx
# 5. Verificación post-switch
sleep 3
curl -sf "https://$SITE/health" || { echo "❌ Post-switch failed, rolling back"; rollback; exit 1; }
echo "✅ Deploy $NEW_VERSION live en $TARGET"
// routes/web.php — Health check endpoint
Route::get('/health', function () {
return response()->json([
'status' => 'ok',
'version' => config('app.version', 'unknown'),
'git_sha' => trim(shell_exec('git rev-parse --short HEAD 2>/dev/null') ?? ''),
'deployed_at' => now()->toISOString(),
'checks' => [
'database' => DB::connection()->getPdo() ? 'ok' : 'fail',
'redis' => Redis::connection()->ping() === 'PONG' ? 'ok' : 'fail',
'queue' => Horizon::isRunning() ? 'ok' : 'degraded',
],
])->header('Cache-Control', 'no-store');
})->middleware('throttle:60,1');
Durante la Campaña: 2 Hotfixes, 0 Incidentes
| Deploy | Motivo | Tiempo Switch | Rollback Tiempo | Incidencia Usuario |
|---|---|---|---|---|
| v2.1.3 | Fix typo copy hero | 1.8s | N/A | 0 |
| v2.1.4 | Ajuste TTL cache stock | 2.1s | N/A | 0 |
Clave: Tests + migraciones + health check en entorno paralelo antes del switch. Si algo falla, el tráfico sigue en versión anterior.
Números Finales: Lo Que Importa al Negocio
| KPI | Objetivo | Resultado | Impacto Negocio |
|---|---|---|---|
| Uptime campaña | 99.5% | 99.97% | 0 ventas perdidas |
| API p95 (pico) | <500ms | 120ms | UX fluida, 0 quejas |
| Bundle inicial | <200KB | 95 KB | +34% conversión móvil |
| Pagos fallidos | <2% | 0.3% | €42k recuperados |
| Incidencias críticas | 0 | 0 | Equipo producto tranquilo |
| Reutilización stack | — | 3 campañas más | ROI arquitectura ×4 |
Conclusión: La Dificultad No Es La Técnica, Es El Juicio
Manejar tráfico alto no es añadir servidores. Es entender dónde está el cuello de botella real:
| Cuello de Botella | Síntoma | Solución Aplicada |
|---|---|---|
| Frontend pesado | Parse bundle >3s móvil | Vue 3 lazy loading + Suspense |
| Queries repetidas | 150ms/request en catálogo | Redis TTL semántico (2-5 min) |
| Pagos síncronos | Workers bloqueados 3s | Colas Laravel + worker dedicado |
| Deploy riesgo | 30s downtime inaceptable | Blue/Green + health checks |
Ninguna técnica es exótica. Lo difícil es saber cuándo aplicar cada una, medir antes/después, y documentar para que el siguiente dev no rompa lo que funciona.
¿Tu Equipo Prepara Una Campaña Con Tráfico Inseguro?
Si usas Laravel y/o Vue y necesitas arquitectura que aguante picos sin despertarte a las 3 AM, puedo ayudarte:
- Auditoría de arquitectura (2 días): Identifico cuellos de botella, propongo fixes priorizados
- Implementación hands-on (1-2 semanas): Cacheo, colas, frontend, deploy zero-downtime
- Load testing realista: Simulo tu patrón de tráfico, valido límites, documento runbooks
Modalidades:
- EOR (Deel, Remote, Oyster) — rol core indefinido
- Freelance B2B (Autónomo, factura intracomunitaria 0% IVA) — proyectos 3-12 meses
- Indefinido directo — si tenéis entidad en España
Stack actual: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.
Ver mi perfil, stack completo y condiciones →
Artículos Relacionados en Este Blog
- Laravel + Vue para equipos distribuidos — Arquitectura Inertia, workflow async, onboarding
- Métricas que un Senior Full-Stack debe defender — Cycle Time, MTTR, Core Web Vitals, coste infra, Bus Factor
- Cómo uso Claude Code en producción — Flujo matutino, escritura, refactoring, MCP
- Stack técnico para startups en España 2026 — Decisiones con tradeoffs, infra por fase
