DEV Community

Cover image for Inertia.js Silently Breaks Your App
Daniel Tofan
Daniel Tofan

Posted on • Originally published at codecrank.ai

Inertia.js Silently Breaks Your App

TL;DR: After weeks in a production Laravel 12 + React 19 + Inertia v2 app, I repeatedly hit failure modes that were expensive to diagnose: overlapping visit cancellation, deploy-time stale chunk breakage, weak default failure UX, and framework-specific workaround code. This article is blunt, but scoped: these are observed behaviors in a real stack, backed by docs/issues where available.


Scope (What This Is, What This Isn't)

This is not a claim that Inertia fails in every project. Plenty of teams run Inertia successfully for CRUD-heavy admin apps.

This is a claim that in one real production setup with active users and frequent deploys, Inertia's router abstraction created recurring operational pain and non-obvious failure patterns.

Environment referenced throughout:

  • Laravel 12
  • React 19
  • Inertia.js v2
  • Vite code splitting
  • Replace-in-place style deployments in some environments

The Pitch vs. The Reality

Inertia's core pitch is strong: build SPA-like UX without maintaining a separate public API surface for routine web navigation.

The trouble started when workflows became non-trivial: multi-step actions, deployment churn, and edge-case error handling.

1. Sequential Request Pitfall: await Does Not Serialize Inertia Router Visits

In our app, assigning a worker required two ordered operations:

const handleAssign = async () => {
  // Step 1: Assign worker
  await router.put(`/admin/tasks/${task.id}/assign`, {
    assignee_id: Number(selectedUserId)
  })

  // Step 2: Update status
  await router.put(`/admin/tasks/${task.id}/status`, {
    status: 'In Progress'
  })

  setModalOpen(false)
}
Enter fullscreen mode Exit fullscreen mode

With Promise-based clients (fetch, axios), that shape means strict sequencing.

In our case, observed outcome was:

  • status updated
  • assignment did not
  • first request showed cancelled in Network
  • no obvious app-level error surfaced by default

Why this can happen:

  • Inertia router methods are not Promise-returning in the way this code assumes
  • await therefore doesn't guarantee request completion order
  • overlapping visits can cancel previous visits (by design)

Community discussions: Promise support intentionally removed, years of requests for it.

A working pattern was callback chaining:

const handleAssign = () => {
  router.put(`/admin/tasks/${task.id}/assign`, {
    assignee_id: Number(selectedUserId)
  }, {
    onSuccess: () => {
      router.put(`/admin/tasks/${task.id}/status`, {
        status: 'In Progress'
      }, {
        onSuccess: () => setModalOpen(false)
      })
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Or manually wrapping visits in a Promise:

await new Promise((resolve, reject) => {
  router.patch(route('profile.update'), data, {
    onSuccess: resolve,
    onError: reject,
  })
})
Enter fullscreen mode Exit fullscreen mode

This is exactly where frustration spikes: code that looks like normal async/await HTTP is not normal async/await HTTP.

2. Deploy-Time Stale Chunks: Not Unique to Inertia, But Operationally Sharper with Server-Client Coupling

Any code-split SPA can suffer stale chunk issues after deploy. This is not Inertia-exclusive.

Inertia made impact broader in our setup because navigation depends on server-side component resolution plus client-side chunk import.

Representative chunk names:

assets/bookings-show-A3f8kQ2.js
assets/profile-Bp7mXn1.js
assets/schedule-Ck9pLw4.js
Enter fullscreen mode Exit fullscreen mode

After deploy:

  • server references latest component manifest
  • client tab may still hold older runtime assumptions
  • needed chunk import fails if asset no longer available
  • user perceives "dead" navigation until hard reload

Nuance that matters:

  • Immutable artifact / skew-protected platforms reduce impact.
  • Replace-in-place deployments increase risk window.
  • Cache and rollout strategy matters as much as framework choice.

References: Inertia asset versioning / 409, 409 loop report.

Important precision:

  • I am not claiming every deploy kills every tab in all environments.
  • I am claiming this was a repeated production incident pattern in our environment.

3. Failure UX Defaults to Silence

In our app, we added explicit guardrails to make failures visible/recoverable.

// Catch navigation exceptions and force reload
router.on('exception', (event) => {
  event.preventDefault();
  window.location.href = event.detail.url || window.location.href;
});

// Proactive manifest drift check
let manifest: string | null = null;
fetch('/build/manifest.json')
  .then(res => res.text())
  .then(text => { manifest = text; })
  .catch(() => {});

document.addEventListener('visibilitychange', async () => {
  if (document.visibilityState !== 'visible' || !manifest) return;
  try {
    const res = await fetch('/build/manifest.json', { cache: 'no-store' });
    if (await res.text() !== manifest) window.location.reload();
  } catch {}
});
Enter fullscreen mode Exit fullscreen mode

These mitigations worked. They are also framework-specific operational debt you must know to write.

4. Navigation Errors Vanish Without a Trace

When a JavaScript error occurs in a target page component, navigation fails silently. The previous page stays visible. No error message, no console warning, no loading indicator that stops. The user clicks a link, waits, and nothing happens.

When server errors occur, Inertia's default behavior is to render the entire error response inside a modal overlay. In development, that's the full Laravel debug page in a modal on top of your app. In production, it's a generic HTML error page — still in a modal, still bizarre UX. To fix it, you override the exception handler to return JSON, then catch it client-side with toast notifications. More workaround code.

In the codebase I work with, I found both router.reload() and window.location.href used for navigation — the latter being a sign the developers gave up on Inertia's router for certain flows. That split can be rational, but it also means engineers must learn two interaction patterns.

5. Props in HTML: Not Unique, Still a Real Discipline Requirement

This is not an Inertia-only security story. Any client-delivered data is visible client-side.

Still, with Inertia, props serialized into data-page make over-sharing easy if teams are careless.

References: props visible in page source, cached sensitive data after logout.

Defensible statement: treat every prop as public output; never include data you would not expose in client payloads.

6. "No API" Is Better Framed as a Starting Optimization, Not a Permanent Architecture

The marketing line can be useful early: fewer moving parts for web navigation.

In many real systems, teams still add explicit API endpoints for:

  • third-party integrations and webhooks
  • mobile clients
  • background workflows
  • specialized, strongly ordered interactions

Important correction for accuracy:

  • Inertia supports file uploads and FormData patterns.
  • Our team still used direct fetch() in some upload paths for local reliability/control reasons.
  • That is a project-level tradeoff, not proof that Inertia cannot upload files.

The Root Problem (In This Stack)

The recurring cost was semantic mismatch:

  • code looked like normal Promise-based HTTP flow
  • runtime behavior followed router-visit semantics
  • failure surfaced under production conditions, not in happy-path demos

That mismatch consumed debugging time and required defensive patterns beyond what most developers expect from "simple SPA routing."

The Alternative We Prefer in High-Complexity Flows

For critical ordered operations, explicit HTTP was easier to reason about:

const handleAssign = async () => {
  await fetch(`/api/tasks/${task.id}/assign`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ assignee_id: Number(selectedUserId) })
  })

  await fetch(`/api/tasks/${task.id}/status`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ status: 'In Progress' })
  })

  setModalOpen(false)
}
Enter fullscreen mode Exit fullscreen mode

This is not about fewer lines. It's about predictable behavior, standard tooling expectations, and portability across backends.

The Honest Take

I am frustrated with this framework because these incidents were real and costly. The request cancellation bug consumed a full day of debugging. The deploy issue cost another afternoon. Each was solvable — but with framework-specific defensive code that shouldn't need to exist.

The defensible conclusion is not "never use Inertia." Plenty of Laravel admin panels and internal tools run it without issues.

It is: if your system has multi-step interactions, active-user deploy churn, and strict operational reliability needs, evaluate whether explicit API + standard HTTP client semantics lower your long-term risk. In our case, the answer was unambiguous.


I build MVPs at CodeCrank. If you're evaluating tech stacks for your next project, let's talk.

Top comments (3)

Collapse
 
charlie_waddell01 profile image
Charlie Waddell

I really enjoyed this write-up - especially the “semantic mismatch” point. That resonated a lot.

One thing we’ve found helpful in our apps is thinking of Inertia as replacing navigation and form submissions, with partial reloads and co. sprinkled in, rather than replacing HTTP entirely. We still reach for fetch/axios for background tasks or strictly ordered workflows, and that hybrid approach has felt very natural.

On the sequential request section: the lack of Promises tripped us up early too, but it clicked once we internalised that router calls behave like browser navigation, not like normal HTTP requests. Redirects, cancellations, history changes, and full reload fallbacks don’t map cleanly onto a single Promise lifecycle, which explains the design choice - even if it feels surprising at first.

The deploy/stale-chunk section was interesting. We’ve had good results relying on Inertia’s built-in asset versioning (the X-Inertia-Version header via the middleware) rather than polling the Vite manifest - it forces a hard reload automatically when the asset hash changes, which removed a lot of that operational concern for us.

Totally agree on props discipline. Treating every prop as public API output was a big mindset shift for our team too. We now only pass Resources/DTOs to Inertia responses to avoid accidentally leaking model attributes. Model toArrays are the absolute devil with Inertia.

Curious if you ended up adopting any tooling or conventions around prop auditing?

Collapse
 
theminimalcreator profile image
Guilherme Zaia

The insights in your post hit home, particularly regarding the non-obvious failure patterns. It's crucial to establish boundaries in expectations around Inertia's handling of concurrent requests—especially in complex workflows. Adapting patterns like callback chaining or wrapping visits in Promises seems essential to mitigate frustrations. Have you explored using middleware to handle errors more gracefully? Managing the UX around failures explicitly could improve user experience significantly while navigating those operational gaps.

Collapse
 
apogeewatcher profile image
Apogee Watcher

When something “silently breaks,” I usually try to isolate it to navigation lifecycle vs state persistence vs asset versioning. A quick diagnostic you can run is to log response headers, version/hash changes, and whether props/state are reused across visits.