DEV Community

Cover image for The Cache Handshake: How I Finally Fixed Laravel Next.js Cache Invalidation
Bashar Ayyash
Bashar Ayyash

Posted on • Originally published at yabasha.dev

The Cache Handshake: How I Finally Fixed Laravel Next.js Cache Invalidation

"The cobbler's children have no shoes." We ship bulletproof APIs for clients, then watch our own portfolio sites crumble from stale ISR pages and cross-domain auth bugs. When yabasha.dev started serving stale content during every deployment, I stopped treating it like a side project and built a real protocol.

TL;DR

  • Laravel Sanctum + httpOnly cookies eliminates CSRF mysteries across domains
  • Cache Handshake Protocol: Laravel events → Horizon queues → Redis state tracking → Next.js revalidation
  • Deployment choreography: Staggered deploys with health-check gates prevent race conditions
  • Result: Zero stale-page incidents in 40+ deployments over 3 months

The Problem

Every deployment followed the same ritual:

  1. Push Next.js to production
  2. Realize half the ISR pages were stale because revalidation calls vanished during the 30-second deploy window
  3. Manually purge Redis and pray

Authentication was worse. Safari users got logged out because of cross-domain cookie quirks. CSRF tokens expired mid-session. I was babysitting cache state instead of building features.

The Stack

  • Backend: Laravel 12 (API-first)
  • Frontend: Next.js 16 (App Router + ISR)
  • Cache/Queue: Redis 7 + Laravel Horizon
  • Observability: Sentry + structured JSON logs

Authentication That Actually Works

No tokens in localStorage. No manual Authorization headers.

Laravel config (config/sanctum.php):

'stateful' => ['localhost:3000', 'yabasha.dev', '*.yabasha.dev'],
'expiration' => 720, // 12 hours

Enter fullscreen mode Exit fullscreen mode

Next.js API client:

export async function apiClient(endpoint: string, options: RequestInit = {}) {
  const res = await fetch(`${API_URL}${endpoint}`, {
    ...options,
    credentials: 'include',
    mode: 'cors',
  });

  if (res.status === 419) {
    // CSRF expired: re-fetch token and retry ONCE
    await getCsrfCookie();
    return apiClient(endpoint, options);
  }
  return res;
}

Enter fullscreen mode Exit fullscreen mode

Tradeoff: Requires disciplined CORS config. Benefit: XSS can't steal httpOnly cookies; no token refresh dance.

The Cache Handshake Protocol

Instead of fire-and-forget webhook calls, I built a distributed state machine:

Laravel Event → Horizon Queue → Redis Tracking → Next.js Revalidation

Enter fullscreen mode Exit fullscreen mode

1. Emit Domain Event

// app/Events/ContentInvalidated.php
class ContentInvalidated
{
    public function __construct(
        public string $type,
        public string $id,
        public array $tags,
        public string $revalidation_id,
    ) {}
}

Enter fullscreen mode Exit fullscreen mode

2. Queue with Idempotency

// app/Jobs/RevalidateNextJsCache.php
class RevalidateNextJsCache implements ShouldBeUnique
{
    public $tries = 5;
    public $backoff = [10, 30, 60, 120, 300];

    public function uniqueId(): string
    {
        return $this->revalidation_id; // Prevents duplicate jobs
    }

    public function handle(): void
    {
        Redis::hset('revalidation_state', $this->revalidation_id, json_encode([
            'status' => 'inflight',
            'attempt' => $this->attempts(),
        ]));

        $response = Http::post(config('services.nextjs.url') . '/api/revalidate', [
            'tags' => $this->tags,
        ]);

        if ($response->failed()) {
            // State updated before retry
            throw new Exception("Revalidation failed");
        }

        Redis::hdel('revalidation_state', $this->revalidation_id);
    }
}

Enter fullscreen mode Exit fullscreen mode

3. Next.js Revalidation Endpoint

// app/api/revalidate/route.ts
export async function POST(request: NextRequest) {
  const revalidation_id = request.headers.get('x-revalidation-id');
  const { tags } = await request.json();

  // Idempotency: Skip if already processed
  const seen = await redis.get(`revalidations:processed:${revalidation_id}`);
  if (seen) return NextResponse.json({ status: 'already_processed' });

  for (const tag of tags) {
    revalidateTag(tag);
  }

  await redis.set(`revalidations:processed:${revalidation_id}`, '1', { ex: 86400 });

  return NextResponse.json({ status: 'success' });
}

Enter fullscreen mode Exit fullscreen mode

Why this survives edge cases:

  • Next.js API down? Horizon retries with exponential backoff
  • Response lost? Idempotency prevents double-work
  • Job fails? Redis shows exact failure state

Deployment Choreography

Deploying both apps simultaneously caused cascading 500s. Solution:

  1. Deploy API first → Wait for health check
  2. Deploy frontend → Set MAX_REVALIDATION_RETRY=0 to pause invalidations
  3. Resume invalidations → Process queued backlog
# CI hook after frontend deploy
curl -X POST <https://api.yabasha.dev/api/revalidation/resume>

Enter fullscreen mode Exit fullscreen mode

This eliminates the deploy-window race condition.

The State Machine

Most guides treat revalidation as fire-and-forget. I model it as a state machine:

pending → inflight → completed
            ↓ (fail & retry)
          failed → dead-letter

Enter fullscreen mode Exit fullscreen mode

Decision matrix:

Use Sync Webhook Use Queue + State
< 10 pages to purge > 10 tags or wildcard
Dev/staging Production
Can tolerate silent failure Must audit every change

Results

Before: 2-3 stale-content incidents/week; manual Redis purges

After: Zero incidents in 40+ deployments over 3 months

Measurable outcomes:

  • Queue failure rate: ~0.3% (auto-retries resolve 95%)
  • Time-to-live for changes: p95 < 15 seconds
  • Deployment incident rate: Down from 30% to 0%

Implementation Checklist

  • [ ] Configure Sanctum with stateful domains
  • [ ] Create ContentInvalidated event + RevalidateNextJsCache job
  • [ ] Implement ShouldBeUnique with revalidation_id
  • [ ] Set up Horizon with maxJobs backpressure
  • [ ] Build idempotent Next.js revalidation API
  • [ ] Add Redis state tracking in job handle() and failed()
  • [ ] Create deploy gate: pause/resume invalidations
  • [ ] Install Sentry in both apps with shared trace IDs

The Broader Lesson

Operational excellence is a habit, not a budget. You don't need a platform team to implement idempotency keys or backpressure. You need to decide your own platform is worth the effort.


Continue reading the full blueprint for:

  • Complete monorepo setup with Bun workspaces
  • 5 failure modes & production mitigations
  • Redis backpressure configuration
  • Exact deployment scripts
  • State machine decision trees

👉 Read the complete article on yabasha.dev

Top comments (0)