ReviewOS

stacks/lastsoulultra

First paint on an unvisited page is about 2.5s, and the router prefetches sibling documents while it waits

#22
Open glennmichael123 opened this 3 days ago ·
3 days ago

A page that has not been visited before takes about two and a half seconds to paint anything. Measured on training.lsu.stacksjs.com/dashboard, signed in, from the Navigation Timing and Resource Timing APIs rather than by feel.

server wait (TTFB)        880 ms
HTML transfer             708 ms
DOM interactive          2337 ms
first contentful paint   2556 ms

The document itself is 662 KB decoded, of which 311 KB is serialised DOM, across 29 inline scripts and a 31 KB inline stylesheet.

Where the time goes, apportioned

Worth separating, because not all of this is one component's fault.

Not stx: the server. 880 ms of TTFB is spent before the browser has a single byte. Nothing on the client can improve that.

Not stx: third party analytics. analyticshq.org/script.js took 988 ms and analyticshq.org/collect another 470 ms, on the critical path during first paint. That is roughly 1.4 s of connection time for something no user is waiting for. It wants async/defer at minimum, and arguably to be loaded after first paint.

Probably stx: the router prefetches sibling documents during the initial load. While /dashboard was still painting, the same navigation also fetched /home and /performance as full HTML documents. At this document size that is real contention, and it is happening while the page the person asked for has not painted yet. Prefetching is the right idea; doing it before the current page is interactive is not. Deferring it to idle would cost nothing and give the first paint the bandwidth.

Probably stx: module chunk size. _stx/modules.35be52f50cefdd92.js took 1151 ms and _stx/modules.d1d6fc3eb98317d1.js 975 ms. Five module chunks plus a runtime and a router chunk are requested on this page.

Small, separate: /api/auth/me is requested twice in the same page load.

Compression is fine, so that is not it

Checked, because it would be the obvious suspect. Content-Encoding: br is set and working: /login is 36,285 bytes on the wire against 182,421 raw.

Suggested order

  1. Defer the analytics script. Cheapest change, roughly 1.4 s off the critical path, no behaviour lost.
  2. Hold router prefetch until the current page is interactive, or behind requestIdleCallback.
  3. Then look at the 662 KB document and the module chunking, which are the larger pieces of work.

Numbers above are one sample on one machine. Worth repeating before and after each step rather than trusting a single run.

3 days ago

Went back and separated network latency from server time, which the measurements above did not do. The headline: the server is not slow, and almost none of this is application code.

The server thinks for about 20 ms

/api/deployment is a 175 byte static JSON response. Timed with curl:

dns   3 ms
tcp 238 ms      <- one round trip
tls 480 ms      <- a second round trip
ttfb 737 ms     <- a third
total 737 ms

Stable across samples: connect 238, 240, 237 ms.

So the round trip is about 238 ms, and the 737 ms is three of them. Subtracting the handshakes and the request round trip leaves roughly 20 ms of actual server work. The backend is fine. The distance is not.

That is expected: the installation is deliberately in Falkenstein (enterprise/deployment.json, residency: { country: 'DE', location: 'fsn1' }), and it was measured from Asia.

The distance is being paid far more often than it needs to be

The origin does not support HTTP/2.

curl --http2 training.lsu.stacksjs.com  ->  negotiated 1.1
openssl s_client -alpn h2,http/1.1      ->  No ALPN negotiated

Under HTTP/1.1 the browser opens around six parallel connections, and each one pays TCP plus TLS before it can send a byte: roughly 480 ms of pure handshake per connection at this distance. The dashboard pulls 20 resources, so that cost is paid repeatedly rather than once.

Nothing is being served from an edge. There is no cf-ray, no cf-cache-status and no Server header on any response, so although the DNS is managed through Cloudflare, the traffic is not proxied through it. Every byte comes from Germany, including the immutable assets.

The control is in the same estate: stacksjs.com negotiates HTTP/2, because it is proxied. training.lsu.stacksjs.com does not, because it is not.

Caching itself is set up correctly, it just has nowhere near to live: _stx/* is Cache-Control: public, max-age=31536000, immutable, and the HTML is no-store, both right.

Revised order of work

  1. Put the origin behind the proxy, or terminate TLS at an edge. This is the whole game. The handshake happens near the person instead of 238 ms away, the immutable _stx/* assets are served from cache, and HTTP/2 and HTTP/3 come free. Everything else on this list is small by comparison.
  2. Enable HTTP/2 on the origin if the proxy is not an option, so 20 resources share one connection instead of six.
  3. Defer the third party analytics. analyticshq.org is a separate origin, so it pays its own ~480 ms of DNS, TCP and TLS before it does anything, on the critical path. That matches the 988 ms measured for its script.
  4. Then the application level items already in this issue: router prefetch timing, the 662 KB document, module chunking, and the duplicate /api/auth/me.

One caveat on step 1, which is the reason to decide rather than just do it

This installation exists to be German. Routing it through a global edge means TLS is terminated outside Germany and requests transit other regions, which may be exactly what the residency requirement forbids. If so, the options are a regional service tier that pins termination and caching to the EU, or an edge of your own in the EU, or accepting the latency for non-EU reviewers while EU users, who are the real audience, never see it.

Worth noting that the people this installation is actually for are a short hop from Falkenstein. The 238 ms is my measurement from Asia, not theirs. That may reorder this entire list.

Sign in to comment on this issue.