Skip to content
10 Jul 2026

The 40 milliseconds hiding in Inertia SSR

Disabling Inertia SSR made my Laravel Toolbar 40 milliseconds faster. What looked like normal development overhead turned out to be a TCP interaction from 1984.

I was working on Laravel Toolbar, a development toolbar that shows where a Laravel request spends its time. It made one slow step hard to miss: the first visit to an Inertia page was about 40 milliseconds slower with server-side rendering (SSR) on than with it off. The difference showed up on every run.

Some overhead seemed normal. In development, Laravel asks a separate process to render the React application. But the delay was almost always the same. That did not look like React doing more work or Vite being busy. It looked like a wait, so I decided to find out what was waiting.

Laravel Toolbar timings panel showing a 16 ms total render with no Inertia SSR cost.
Laravel Toolbar timings panel showing a 59 ms total render, including an Inertia SSR step of 45.27 ms.
With the development SSR request enabled, Inertia SSR accounts for a large share of render time. Without that path, the same page finishes much faster.

Asking one more question

I asked AI to inspect the request path and suggest causes for a delay of that size. One suggestion was Nagle's algorithm, an old TCP rule that holds back small packets. The request stayed on my development machine, so no remote server or internet connection could explain the pause. That made a TCP delay seem surprising. Still, the delay matched what Nagle's algorithm can cause, so it was worth testing.

First, I mapped the SSR request. It only runs on the initial full-page visit. Laravel builds the Inertia page data and posts it to Vite's SSR endpoint. Vite renders the page and returns it to Laravel, which sends the completed HTML to the browser. Later Inertia navigation runs in the client and skips this extra request. The request is easy to miss because inertia-laravel's HttpGateway makes it with one ordinary-looking line:

$response = Http::post($url, $page);

That $url points to Vite's development server, which ran over HTTPS in my setup. Vite enables TLS and HTTP/2 for its HTTPS server, but it also accepts HTTP/1.1 connections. Laravel uses Guzzle, whose default request version is HTTP/1.1. That meant the SSR request used Vite's HTTP/1.1 fallback. That detail turned out to be the culprit.

An algorithm from 1984 on localhost

Nagle's algorithm reduces the number of small TCP packets. After sending one small segment, it may hold later small writes until the first data is acknowledged or enough data is ready for a full segment. At the other end, delayed ACK may wait briefly for another segment so one acknowledgment can cover both.

These rules are useful on their own, but together they can create a pause. Vite could send the first part of the SSR response and hold the next small write. Laravel could receive that first part and wait before acknowledging it. On my Linux machine, that interaction regularly added about 40 milliseconds. That is no coincidence: Linux's minimum delayed-ACK timeout is 40 milliseconds.

Turning a good theory into evidence

I changed one condition at a time. First, I built a small reproduction and benchmark command around the Vite SSR endpoint. Then I varied the size of the rendered response:

  • A response of roughly 1.8 KB took a median of 43.07 ms.

  • A response of roughly 34 KB still took around 43 ms.

  • A response of roughly 132 KB completed in around 3.7 ms.

The largest response was much faster. That ruled out rendering time: more HTML should not make React render ten times faster. Instead, the extra data changed how Node, TLS and TCP sent the response. Enough data was queued to send full-sized segments and trigger an acknowledgment without waiting for the delayed-ACK timer.

TCP trace / Laravel ⇄ Vite SSR
Measured response profile1.8 KB SSR response

Laravel makes one SSR request. Vite returns one HTTP response as TCP segments — not two requests.

  • 1st segment
  • 2nd segment

Nagle enabled

43.07 ms

Segment #1 can arrive while #2 is waiting for ACK.

Laravel requests SSR

Nagle disabled

3.38 ms

Segment #2 may depart whenever it's ready.

Laravel requests SSR

Reproduction results

Measured Inertia Vite SSR response times
SocketResponseResult
Nagle enabled1.8 KB43.07 ms
Nagle enabled34 KB43.00 ms
Nagle enabled132 KB3.70 ms

The results suggest a cutoff somewhere between 34 KB and 132 KB, but there is no fixed size. Nagle decides per write: full-sized segments can always go out, while small ones wait as long as earlier bytes are unacknowledged. Linux follows that rule in its Nagle check. The receiver also counts full-sized segments when deciding to acknowledge. For an HTTPS response, where those limits fall depends on TLS record sizes, write boundaries and the connection's maximum segment size (MSS). Next, I forced Laravel's HTTPS request to use HTTP/2:

$options = str_starts_with($url, 'https://')
    ? ['version' => '2.0']
    : [];

$response = Http::withOptions($options)->post($url, $page);

The delay disappeared. HTTP/2 avoided the HTTP/1.1 fallback, but changing Laravel's request version felt like patching the client to hide a server problem. For a more direct test, I kept HTTP/1.1 and disabled Nagle on the socket accepted by Vite. The same small response dropped from 43.07 ms to 3.38 ms. The HTTP version and React application stayed the same. Only the TCP wait was gone. The benchmark output shows it — the socket setting changed, while the HTTP protocol did not:

{
    "before": {
        "vite_set_no_delay_detected": false,
        "summary": {
            "median_wall_ms": 43.07,
            "guzzle_http_protocol": "HTTP/1.1"
        }
    },
    "after": {
        "vite_set_no_delay_detected": true,
        "summary": {
            "median_wall_ms": 3.38,
            "guzzle_http_protocol": "HTTP/1.1"
        }
    }
}

Inertia also ships a standalone SSR server, which is what production uses. On Linux, it stayed fast over HTTP/1.1. That narrowed the problem to Vite's HTTPS development endpoint, not SSR in general.

Why macOS did not show the delay

I could not reproduce the same 40-millisecond wait on macOS, even though the request still used HTTP/1.1. That pointed to a platform difference in how acknowledgments were sent. XNU, the macOS kernel, is open source, so I could check. It sets net.inet.tcp.delayed_ack to 3 by default, a mode its ACK implementation calls “streaming detection”. Instead of applying one fixed delay to every packet, it decides when to acknowledge based on connection state, receive-window growth and unacknowledged data.

That policy fits the macOS result, but my benchmark did not capture enough XNU state to prove which condition sent the ACK. I therefore use Linux runs as the main evidence. Linux reproduced the delay consistently; macOS helped identify receiver ACK policy as a likely reason for the difference.

The missing fallback

One question remained: why was Nagle enabled on Vite's socket in the first place? Node creates TCP connections with Nagle enabled. Applications can call socket.setNoDelay(true) when low latency matters more than combining small writes. Normal Node HTTP/1.1 servers disable Nagle by default. Node also calls setNoDelay() when it creates an HTTP/2 session.

Vite's HTTPS development server is a node:http2 secure server with allowHTTP1: true. When a client chooses HTTP/1.1, Node passes the socket to an HTTP/1.1 listener. That fallback socket never gets an HTTP/2 session, and it never goes through the normal HTTP/1.1 server path either. Neither code path disables Nagle.

The root fix arguably belongs in Node itself: every other HTTP path already turns Nagle off, and this fallback simply missed the same default. But a Node fix would only ship in future Node versions. A fix in Inertia's Vite plugin works today, on every Node version people run. It only needed one extra line:

configureServer(server) {
    devServer = server

    server.httpServer?.on('connection', (socket) => {
        socket.setNoDelay(true)
    })

    // ...
}

Now Nagle is disabled on every socket the development server accepts. Laravel can keep its normal HTTP/1.1 request, and the fallback gets the same low-latency behavior as Node's other HTTP server paths. I sent the fix with the measurements and reproduction repository. The pull request was merged into Inertia 3.x.

What AI did, and what it did not do

I would not have checked TCP first. The delay appeared when I enabled SSR, so React, Inertia and Vite all looked more likely. AI pointed me toward a lower layer of the stack.

That suggestion was only a hypothesis. A delay near 40 milliseconds can fit Nagle and delayed ACK, but the timing alone proves nothing. The controlled tests supplied the proof. I changed the response size, forced HTTP/2, disabled Nagle while keeping HTTP/1.1, and checked the standalone SSR server separately. Each test removed another possible cause. AI suggested where to look; the measurements showed which fix was real.

Forty milliseconds was enough

The delay did not make the application unusable. It only affected the first visit in development, and the page still felt reasonably fast. But Laravel Toolbar showed time the SSR renderer could not account for. I wanted to know where it went.

What looked like framework overhead was an interaction between two old TCP optimizations in a modern Laravel, Inertia and Vite workflow. The fix was one line. Finding the right layer took almost all the work.