Skip to main content

Devlog · Phase 0 · 2/7

Octane, FrankenPHP and Inertia SSR on aubia.dev

Octane, FrankenPHP and Inertia SSR serve aubia.dev. How they work, the limits of server-side rendering and caveats about the published latency figures.

Published on July 20, 2026Updated on September 7, 20267 min read
  • laravel
  • octane
  • frankenphp
  • inertia
  • vite

On aubia.dev, Laravel stays loaded in memory between requests, and React renders page content on the server. The browser receives that HTML, then JavaScript adds interactivity.

I chose Laravel Octane with FrankenPHP to run the application, and Inertia's SSR to render the React pages. I want search engines to be able to read the content without having to execute the site's JavaScript.

The latency figures published on August 25, 2026 report two results: 75 to 280 ms when warm, and 133 requests under 615 ms after a period without traffic. Without public records and a measurement protocol to verify them, they establish neither an improvement over PHP-FPM nor a wake-up time.

Octane's Worker Mode

With PHP-FPM, Laravel is initialized for every request. PHP processes can handle several requests before being recycled, depending on their configuration, including pm.max_requests. Application startup repeats without requiring a new process each time.

The diagram shows what PHP-FPM repeats for every request.

Every following request goes through that boot again.

Laravel Octane boots Laravel once per worker. That worker handles subsequent requests with the application already loaded. Workers are occasionally recycled or restarted, during a deployment, for example.

On aubia.dev, Octane uses FrankenPHP, a PHP application server written in Go that embeds the Caddy web server.

The following environment variable selects this driver in an installation that is already configured:

# .env: the active driver on aubia.dev
OCTANE_SERVER=frankenphp

Under Octane, requests go through a single boot block.

The block does not reappear between two requests: the loaded application serves the following ones.

Keeping the application in memory means watching what it stores there. Data specific to one visitor, stored in a singleton or static property, may be reused for the next.

Octane resets framework state between requests. It does not automatically clear every global variable and static property in application code. Services that retain request-specific data must be scoped to that request or explicitly reset.

Limits of the Published Figures

The figures added to this article on August 25, 2026 report 75 to 280 ms on a warm process, already loaded in memory. They also refer to seven days of production logs: 133 requests arriving after more than twenty minutes without traffic, all served in under 615 ms.

The production configuration observed on July 11, 2026 was a Laravel Cloud flex-1gb instance with hibernation enabled. A period without traffic therefore makes a wake-up possible, but does not prove the instance was asleep for each of those requests.

Without the records and their measurement protocol, the reported duration remains difficult to interpret. Application-level timing may exclude instance wake-up, proxy handling and the network journey to the browser. These numbers therefore cannot tell us how long a visitor waits.

In its June 1, 2026 announcement, Laravel Cloud reports a wake-up time of under 500 ms for the entire stack. The provider claims a twentyfold reduction over the previous generation, with one to four vCPUs depending on demand.

The aubia.dev figures are not enough to confirm the provider's claim. No comparison of this site with PHP-FPM is available.

Inertia's Server-Side Rendering

A React page rendered only on the client depends on JavaScript loading and executing before its content appears. Not all crawlers execute it, and those that do may defer that work.

With Inertia v3's SSR, Laravel sends the page and its data to a Node process. Node renders the React components and returns HTML to Laravel, which includes it in the response through the Blade view.

The page content and metadata are then available in the initial HTML response. Rendering takes time: Laravel must wait for Node's result before sending the page.

In the browser, React hydrates the received HTML to make the buttons, form and other components interactive.

The diagram traces a first visit, from the browser request to hydration.

The waitlist counter is not part of this first render. It arrives through a later request, described below.

In development, Inertia's Vite plugin handles SSR without a separate rendering server to start. In production, the Node process remains separate from the PHP server and must be operated alongside it.

The SSR configuration is declared in config/inertia.php:

// config/inertia.php, excerpt
'ssr' => [
    'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
    'runtime' => env('INERTIA_SSR_RUNTIME', 'node'),
    'url' => env('INERTIA_SSR_URL'), // default internal address omitted
    'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
    'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
],

With ensure_bundle_exists, a missing SSR bundle stops the rendering attempt: Inertia returns null without emitting a failure event. If a rendering attempt fails, Inertia emits SsrRenderFailed. No application listener handles that event on this site.

Setting throw_on_error to false then allows a fallback to client-side rendering. The app.blade.php view retains a generic title and description, but the page content and its specific metadata depend on JavaScript.

I prefer that fallback to an error page during a transient SSR outage. It has a cost: content may appear later, and a crawler that does not execute JavaScript no longer receives the expected content. Without a listener, these failures also lack dedicated application-level monitoring.

Code Splitting with Rolldown

On the build side, aubia.dev uses Vite 8. The bundler is Rolldown, written in Rust and natively integrated into Vite.

Code splitting divides compiled JavaScript into several files that load separately. It is configured explicitly in the build settings:

// vite.config.ts
build: {
    rolldownOptions: {
        output: {
            codeSplitting: {
                minSize: 20_000,
                groups: [
                    {
                        name: 'react',
                        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
                        priority: 50,
                    },
                    {
                        name: 'motion',
                        test: /[\\/]node_modules[\\/](motion|motion-dom|motion-utils)[\\/]/,
                        priority: 40,
                    },
                    {
                        name: 'fontawesome',
                        test: /[\\/]node_modules[\\/]@fortawesome[\\/]/,
                        priority: 30,
                    },
                    {
                        name: 'inertia',
                        test: /[\\/]node_modules[\\/]@inertiajs[\\/]/,
                        priority: 20,
                    },
                    {
                        name: 'radix',
                        test: /[\\/]node_modules[\\/]@radix-ui[\\/]/,
                        priority: 10,
                    },
                ],
            },
        },
    },
},

Five groups are declared: React and its scheduler, Motion animations, FontAwesome icons, the Inertia client and Radix components. This separation makes it easier to reuse cached files across deployments if the generated files remain unchanged. It does not guarantee that dependency files will be identical after every build.

The production build adds a dedicated SSR bundle, produced by vite build --ssr.

The Counter After Initial Rendering

The waitlist counter fetches its total from aubia.cloud, with a server-side cache. If the cache is empty, that network call can delay the response.

The HandleInertiaRequests middleware therefore shares the counter as a deferred prop. The browser requests its value in an additional request after the initial render:

// app/Http/Middleware/HandleInertiaRequests.php
'waitlist' => [
    'count' => Inertia::defer(fn(): ?int => app(FetchWaitlistCountQuery::class)->run(), rescue: true),
],

The initial render does not wait for this call. If the cache itself fails, rescue: true omits the prop from the payload without failing the deferred request. The component then keeps its neutral display.

The counter's additional request goes out once the page is displayed.

Laravel answers in JSON, without going through Node: the total comes from the server cache or from aubia.cloud.

The Choice of FrankenPHP

The config/octane.php file defaults to RoadRunner when no environment variable is set:

// config/octane.php
'server' => env('OCTANE_SERVER', 'roadrunner'),

RoadRunner is another server compatible with Octane workers. Swoole is also available, but requires a compiled PHP extension.

I chose FrankenPHP for its worker mode and the integration of Caddy into the same binary. Its support for HTTP/3 and automatic certificates also featured in the project's architecture decision record.

Those last two capabilities are not used on aubia.dev: the production proxy terminates TLS and HTTP/3 upstream. The integrated binary simplifies the PHP part of the deployment, but SSR still requires its separate Node process.

Switching to RoadRunner would require installing its binary, adapting the configuration and startup, then testing the deployment. The OCTANE_SERVER variable selects the driver; it does not replace that work.

Octane avoids reinitializing Laravel for every request, and SSR provides React content in the initial HTML response. The published figures remain historical reference points: without verifiable records and a measurement protocol, they demonstrate neither an improvement over PHP-FPM nor a full wake-up time.

This build is chronicled here, article after article. What comes next depends on what you have to say about it.

Join the waitlist