Back to Blog
4 min read

Ensuring Reliable PDF Downloads in Hybrid Next.js + Livewire Apps with Alpine.js Delays

The Silent Race Condition That Broke PDF Downloads

Last week, I hit a maddening bug on AustinsElite — a project where we’re using a Laravel 12 frontend to serve content while leaning on a robust Laravel 12 + Livewire backend for dynamic actions like generating and downloading PDFs. The flow seemed simple: user clicks a button, we set some session data via Livewire, then trigger a route that generates a PDF using that session state.

But sometimes, the PDF would generate with stale or missing data. No errors. No failed requests. Just… wrong output.

After digging through network tabs and Livewire’s debug logs, I realized the issue wasn’t in the PHP or the PDF generation — it was timing. The moment the user clicked the button, we were calling a Livewire action to update session state and immediately redirecting to the PDF route. But Livewire hadn’t finished syncing that state back to the server. The redirect happened too fast. The backend route picked up the old session data. Race condition: frontend wins, user loses.

This is the kind of bug that makes you question your sanity. It worked in local dev (slower environment), but failed intermittently in production. Classic async gotcha.

The Fix: A Deliberate 100ms Delay with Alpine.js

Since the UI layer is handled in Next.js but the state logic lives in Livewire, we needed a way to ensure the Livewire action completed before the browser moved on. We couldn’t rely on promises or async/await here — Livewire’s x-data and x-on directives in Alpine.js don’t expose a clean promise interface when called from outside Livewire components (which we aren’t using directly in this hybrid setup).

So I reached for a small, surgical fix:

<button
  x-data
  x-on:click="
    $wire.call('setReportData', reportId),
    setTimeout(() => {
      window.location.href = `/generate-pdf/${reportId}`;
    }, 100)
  "
>
  Download PDF
</button>

That 100ms setTimeout gives Livewire just enough time to send the setReportData call and update the session before the browser navigates. It’s not polling. It’s not a full refactor. It’s a pragmatic pause.

And it works — consistently. No more ghostly PDFs with missing data.

Was I hesitant to add a magic-number delay? Absolutely. But in this case, the alternative would’ve been over-engineering a webhook, a polling endpoint, or moving the entire UI into Livewire — none of which made sense for our hybrid architecture. Sometimes, a small delay is the least fragile solution.

When Artificial Delays Are Okay (Yes, Really)

I know what you’re thinking: “Adding a delay? That’s not a fix, that’s a hack.”

And usually, I’d agree. But context matters. In high-frequency trading or real-time collaboration tools, even 50ms can be catastrophic. Here? We’re triggering a PDF download. Users expect a brief pause. The delay is imperceptible — they don’t notice the extra fraction of a second before the download starts.

More importantly, this isn’t a workaround for broken code — it’s a recognition of network latency and async boundaries in hybrid apps. Livewire batches updates, and those take time. The browser doesn’t wait unless you tell it to.

Still, I applied this fix judiciously:

  • Only where session state must be set and immediately used in a separate request.
  • Only when the user action is explicit (like clicking 'Download').
  • Only after confirming the race condition couldn’t be solved via Livewire’s native $next() or redirect methods (which don’t work cleanly when initiating from a non-Livewire frontend).

This isn’t a pattern to sprinkle everywhere. But in the right context, a tiny, intentional delay can be the difference between a flaky feature and a reliable one.

Hybrid apps — mixing modern frontend frameworks with server-driven tooling like Livewire — are becoming more common. And with them, we need to rethink assumptions about synchronization. Just because two lines of code are sequential in JavaScript doesn’t mean their effects are sequential on the server.

At AustinsElite, this small tweak stabilized a critical user flow. And it reminded me: sometimes, the best fix isn’t the most elegant — it’s the one that respects the real-world constraints of the stack you’re actually using.

Newer post

Fixing Session Data Race Conditions in Laravel Livewire After Form Submissions

Older post

How a One-Word Fix Revealed a Critical Policy Miscommunication in Our Scheduling Flow