Inside Inertia SSR: How the Server and Client Work Together
So you’re working with Inertia.js and thinking about adding SSR? You’re in good company. Server-side rendering can seriously improve performance and SEO, and with Vue 3 in the mix, it’s surprisingly powerful. But it can also be a bit of a black box if you’re not sure what’s going on behind the scenes.
In this article, I’ll walk you through exactly what happens when you start the Inertia SSR server and build your client bundle - what gets loaded, what gets rendered, and how everything syncs up between client and server.
If you’re trying to wrap your head around how Inertia handles SSR or just want to make sure you’re not leaking state between users, this one’s for you.
What is SSR (and Why Should You Care)?
SSR stands for Server-Side Rendering, and at its core, it means rendering your frontend views (like your Vue components) into HTML on the server, before sending them to the browser.
Normally, with a single-page app (SPA), the server just sends a blank HTML shell and a big chunk of JavaScript. The browser then downloads that JavaScript, runs it, and finally renders the actual UI - this is called client-side rendering. It works, but it means users have to wait a bit before they see anything useful on the screen.
SSR flips that around: the server does the heavy lifting first. It generates a full HTML page based on your Vue components and sends that straight to the browser. The browser can show content immediately, and then Vue “hydrates” (what is hydration) the page - making it interactive - once the JavaScript finishes loading.
When to use SSR
Here’s what you get when you use SSR with Inertia and Vue:
- Faster first paint: Users see content quicker, especially on slower networks or devices.
- Better SEO: Since search engines see a full page of content right away, your pages are easier to crawl and rank.
- Improved performance: The browser doesn’t have to do as much work upfront, which can lead to a more pleasant experiences.
- Great for previews and sharing: Links shared on social platforms or used for link previews will show actual content instead of a blank SPA shell.
SSR also adds complexity. You have to think about state management, avoid global variables, and make sure your app behaves the same on both server and client. But when it’s set up right - like with Inertia - the benefits can be well worth it.
Laravel vs Inertia Daemon: What Actually Handles the Request?
Now that we understand the difference between SPA and SSR, let’s talk about the two different places where a request can be handled in an SSR-enabled Inertia app:
- Laravel (your backend framework)
- The Inertia SSR daemon (a persistent Node.js process)
At a glance, it might seem like they behave the same - but under the hood, they’re quite different. Especially when it comes to global state.
Solely Laravel Requests
Every time a request hits Laravel, it:
- Spins up the full application from scratch (bootstrapping Laravel, loading service providers, etc.)
- Handles the request
- Tears everything down after the response is sent
This means that global variables don’t persist between requests.
$GLOBALS['randomNumber'] = rand(0, 5);
That variable is gone as soon as the request ends. There’s no risk of cross-request data leakage, even if 100 users are hitting your app at the same time. Every user will receive a new random number on every request.
Inertia SSR Daemon
The Inertia SSR daemon is a long-running Node.js server that your Laravel app talks to when it needs HTML rendered for a Vue component.
This long running Node.js server is available once you run:
php artisan inertia:start-ssr
Because it stays alive across multiple requests:
- Any variable you define in module/global scope stays in memory.
- This can lead to cross-request pollution if you’re not careful.
For example:
// server.js (Inertia SSR daemon)
let randomNumber = Math.random()
app.get('/', (req, res) => {
return { randomNumber }
})
Once we start the server, randomNumber
gets evaluated and it is kept in memory, which means that every user will receive the same number until we restart the server.
This is important to know because you can easily introduce bugs in your VueJS/React application without knowing it. Let’s say you’ve got the following setup to handle real-time events:
// services/websockets.js
import Echo from 'laravel-echo';
export const broadcaster = new Echo({
broadcaster: 'pusher',
client: window.pusherInstance,
})
// App.vue
<script setup>
import {broadcaster} from '@/services/websockets'
import {onMounted} from 'vue'
onMounted(() => {
broadcaster.on('email:verified', () => {
// Handle real-time event
})
})
</script>
In an SSR setup, this code is quietly causing a memory leak and cross-request side effects.
- The file
services/websockets.js
is imported once and kept in memory across all SSR requests. - The
broadcaster
instance is shared across all incoming requests. - Every time a user visits the page (and SSR kicks in), a new
.on('email:verified', ...)
listener is added to the same instance.
That means:
- User A hits the server → adds a listener.
- User B hits the server → adds another listener.
- User C → another one…
- Now every SSR request is stacking up duplicate listeners on the same broadcaster.
- Worse: all users start responding to each other’s events.
⚠️ Memory grows. Listeners stack up. Things get weird.
Fixing this issue is quite easy, we should move the creation of the Echo
instance inside a function, so that each request or component creates its own instance.
// services/websockets.js
import Echo from 'laravel-echo'
export function createBroadcaster(pusherClient) {
return new Echo({
broadcaster: 'pusher',
client: pusherClient,
})
}