Standalone performance guide for the Nitro V3 client, covering both sides of the cold-load story so a deployer doesn't have to cross- reference two repos to get from 60-90 s down to 4 s. Sections: 1. Three Nitro-side changes that matter (code split, LoadingView with real progress, remember-token URL capture) 2. Vite manualChunks — why vendor-first ordering matters, why pixi stays inlined, expected chunk sizes after yarn build 3. LoadingView state model + the 12-stage progress table + the pre-React shell template in scripts/write-asset-loader.mjs 4. Remember-token capture from URL → SetRememberLogin, with DevTools verification of nitro.auth.remember localStorage entry 5. nginx gzip (the single biggest win at ~17x for JSON5) + 30-day cache headers on gamedata + try_files manifest fallback 6. Windows + IIS equivalent — URL Rewrite + ARR reverse proxy to Node, Dynamic Compression toggle, web.config snippets, trade-offs (CPU cost, JDBC quirks, shared hosting caveat) 7. End-to-end verification probes — chunks present, gzip on, dir fallback works, progress bar renders, remember-token persisted Cross-references medievalshell/InertiaCMS:docs/PERFORMANCE.md for the matching CMS-side application config (SSO TTL, /api/auth/remember endpoint, migrations).
22 KiB
Nitro V3 — Cold-load performance
Practical recipe to take a Nitro V3 cold load from the typical 60-90 s (and intermittent "Session expired") baseline down to ~4 s. The wins compound: each section below has measurable impact, in roughly the order of cost vs benefit.
Three things matter on the client (this repo): granular code split, a real progress bar driven by boot stages, and capturing the remember-token from the iframe URL on first boot. The other three — gzip on the static server, long cache on gamedata, and a server endpoint to mint fresh SSO tickets — are documented further down under §5 (nginx) and §6 (IIS) plus a quick note in §4 about the CMS contract.
1. The three Nitro-side changes that matter
- Granular code split (
vite.config.mjs) — a 1 MB vendor bundle is replaced by ~12 smaller chunks the browser fetches in parallel via HTTP/2 multiplexing. - Loading screen with a real progress bar + per-stage labels
(
src/components/loading/LoadingView.tsx, driven bysrc/App.tsx::prepare()) so a slow boot looks like progress, not a frozen GIF. - Remember-token capture from URL (
src/App.tsx::prepare()) so that when the WS drops the existingtryRememberLogin()round can hit the CMSPOST /api/auth/rememberendpoint and get a fresh SSO ticket instead of falling through to "Session expired".
The server-side wins (gzip, cache, SSO TTL) live outside this repo — without them this client still loads, but you stay at the 60-90 s baseline.
2. Vite manualChunks — split the vendor blob
Default yarn build ships:
vendor~1 MB (react + tanstack-query + framer-motion + jodit + emoji-mart + react-icons + howler + zustand + json5 — everything merged)nitro-renderer~2.5 MB (renderer source + pixi.js inlined)src~1.7 MB (app code)
The vendor blob forces the browser to wait on the slowest dependency
before it can hydrate. Split it by domain — see
vite.config.mjs for the live version, the
intent is captured below:
manualChunks: id => {
const norm = id.replace(/\\/g, '/');
// Vendors first — pixi.js / howler / emoji-mart / jodit are aliased
// to ../Nitro_Render_V3/node_modules, so they would otherwise be
// swallowed by the `Nitro_Render_V3` branch lower down and pulled
// into the renderer chunk.
if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi';
if(norm.includes('howler')) return 'vendor-audio';
if(norm.includes('@emoji-mart')) return 'vendor-emoji';
if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor';
if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) {
if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar';
if(id.includes('/packages/communication/')) return 'nitro-renderer-comm';
if(id.includes('/packages/room/')) return 'nitro-renderer-room';
if(id.includes('/packages/assets/')) return 'nitro-renderer-assets';
return 'nitro-renderer';
}
if(id.includes('node_modules')) {
if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer';
if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react';
if(id.includes('framer-motion')) return 'vendor-motion';
if(id.includes('@tanstack')) return 'vendor-query';
if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state';
if(id.includes('react-icons')) return 'vendor-icons';
if(id.includes('json5')) return 'vendor-json5';
return 'vendor';
}
}
Two practical points the comments don't make obvious:
-
Vendor checks come first. Pixi.js, howler, emoji-mart and jodit are pulled in via an alias to
../Nitro_Render_V3/node_modules, so theiridmatchesNitro_Render_V3. If the renderer branch runs before the vendor one, those modules end up bundled into the renderer chunk instead of their own — defeating the whole point. -
Pixi often stays inlined. Rollup keeps a module in the chunk of its sole importer, and
pixi.jsis consumed only through the@nitrots/nitro-rendererumbrella. Expectvendor-pixito be near-empty until something outside the renderer also imports pixi. This is fine — pixi gets the renderer chunk's cache lifetime anyway.
Verify after yarn build:
dist/assets/nitro-renderer-*.js ~2.5 MB raw, ~765 KB gzip
dist/assets/vendor-*.js ~12 chunks, 4-430 KB each
dist/assets/src-*.js ~1.7 MB raw, ~550 KB gzip
If you see a single vendor-*.js over 800 KB raw, the chunk
function isn't matching the way you expect — log id from inside
manualChunks during build to find out what's actually being
handed in.
Also add the connection hint to index.html:
<link rel="preconnect" href="https://challenges.cloudflare.com" crossorigin />
Saves one TLS handshake on cold load — the Turnstile script tag already loads from that domain.
3. LoadingView — real progress, real labels
src/components/loading/LoadingView.tsx
renders the dark-blue boot screen the user sees before isReady
flips. It accepts a progress number (0-100) and a currentTask
string. The progress bar is hidden when progress is undefined
(error / Suspense fallback path) and animates between updates.
The state lives in src/App.tsx:
const [ loadingProgress, setLoadingProgress ] = useState(0);
const [ loadingTask, setLoadingTask ] = useState('');
const taskLabel = useCallback((key: string, fallback: string): string => {
// … reads from renderer-config so the strings are translatable
});
const bumpProgress = useCallback((value: number, task?: string) => {
setLoadingProgress(prev => (value > prev ? value : prev));
if(task !== undefined) setLoadingTask(task);
}, []);
prepare() bumps the progress through 12 stages as it goes:
| % | Stage | Default label |
|---|---|---|
| 5 | App start | Avvio in corso... |
| 10 | NitroConfig validated | Verifica sessione |
| 20 | Renderer constructed | Inizializzazione renderer |
| 25 | Config init done | Caricamento contenuti... |
| 36, 47, 58, 70 | each warmup task resolves | per-task (Sto caricando il guardaroba, …) |
| 78 | GetSessionDataManager().init() done |
Caricamento dati utente |
| 85 | GetRoomSessionManager().init() done |
Caricamento stanze |
| 92 | GetRoomEngine().init() done |
Caricamento engine grafico |
| 98 | GetCommunication().init() done |
Connessione al server |
| 100 | setIsReady(true) about to fire |
Pronto! |
The labels are config-driven — taskLabel('loading.task.boot', 'Avvio in corso...')
reads loading.task.* keys from the renderer-config and falls back
to the Italian baseline if unset. To localise, add the keys to
public/configuration/renderer-config.json (see the .example
file for the full list).
Logo and background are also configurable via the same mechanism —
loading.logo.url, loading.background, loading.progress.color.
Leaving them empty keeps the shipped dark-blue radial + Nitro V3
logo top-left.
3.1 The pre-React shell (asset-loader.js)
There is a second, tiny loading screen that the asset loader writes
into #root before React mounts. It used to be a light-blue
login-skeleton with two grey rectangles — visible for ~200 ms before
React took over, producing a hated flash. The template lives in
scripts/write-asset-loader.mjs
(renderShell) and now paints the same radial-gradient(#1d1a24,#003a6b)
as the React LoadingView, so the handoff is invisible.
Don't hand-edit public/configuration/asset-loader.js — the
prebuild hook regenerates it from the template every yarn build.
4. Remember-token capture — making reconnect work
Arcturus clears auth_ticket to '' the moment it consumes an SSO
ticket. Without a remember-token the client retries reconnect with
the same (now empty) ticket and falls through to "Session expired"
after 2-7 attempts.
The CMS issues a UUID family token when it serves /client, and
passes it on the iframe URL as &token=<uuid>&token_exp=<unix>. Nitro
captures it on first boot:
// src/App.tsx::prepare()
try {
const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token');
const tokenExpParam = urlParams.get('token_exp');
if(tokenParam && !GetRememberLogin()) {
const parsedExpiry = Number(tokenExpParam || 0);
const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0)
? parsedExpiry
: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60);
SetRememberLogin({ token: tokenParam, expiresAt });
}
} catch(e) {
console.warn('[App] failed to persist remember token from URL', e);
}
The capture is guarded by !GetRememberLogin() — if the user has
visited before, the stored token wins and the URL one is ignored.
Once stored, the existing tryRememberLogin() machinery picks it up
on every reconnect: it POSTs to
${api.url}/api/auth/remember (configurable via the
login.remember.endpoint renderer-config key), receives a fresh
SSO ticket back, and rotates the connection. See the CMS doc for the
server endpoint's contract.
Verify the stored token in browser DevTools:
Application → Local Storage → https://<your-domain>
Key: nitro.auth.remember
Value: {"token":"<uuid>","expiresAt":1781912345,"username":"<u>"}
If nitro.auth.remember is missing after a successful first load,
the CMS isn't passing token= on the iframe URL. Check
AuthController.client on the CMS side.
5. Server-side: nginx gzip + long cache (the single biggest win)
The Nitro client ships ~4.3 MB raw across the main bundle, renderer chunk and vendor splits. If the server doesn't compress and doesn't let the browser cache, every visitor pays the full price on every load — that's exactly the 60-90 s baseline you avoid by configuring nginx properly.
5.1 Enable gzip globally
Default nginx ships with the gzip block commented out. Edit
/etc/nginx/nginx.conf and replace the #gzip on; line inside the
http {} block:
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/rss+xml
application/atom+xml
image/svg+xml
font/ttf
font/otf
application/font-woff
application/vnd.ms-fontobject;
Back up the file before editing, then reload:
cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d-%H%M%S)
nginx -t # validate syntax first
systemctl reload nginx
The impact is enormous — palettes.json5 drops from 330 KB to 18 KB
on the wire (~17×), and the renderer JS bundle from 2.5 MB to 765 KB
(~3.3×). Verify:
curl -sI -H 'Accept-Encoding: gzip' \
'https://<your-domain>/nitro/assets/nitro-renderer-XXXXX.js' \
| grep -i 'content-encoding'
# expected: content-encoding: gzip
If you forget application/json from gzip_types you lose the
gamedata compression — that's the one that matters the most because
the gamedata files are by far the heaviest payload.
5.2 Long Cache-Control on gamedata
Inside the /nitro-assets/ or /nitro-assets/ location
block, the gamedata .json5 files deserve a 30-day cache because
they only change on deploy:
location /nitro-assets/ {
alias /var/www/cmsjs/public/nitro-assets/;
try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;
autoindex off;
default_type application/json;
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
location ~ \.json5?$ {
types {} default_type application/json;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
}
The outer 7-day cache covers PNG / nitro / mp3 files. The inner
location block raises the JSON5 lifetime to 30 days because the
content is effectively immutable per deploy. Cloudflare honours
Last-Modified so revalidation still works — you don't need to
cache-bust by filename.
For the JS / CSS chunks the filenames are content-hashed by Vite, so
a long cache is safe — apply the same Cache-Control: max-age=2592000
to the /nitro/assets/ location.
5.3 The try_files → manifest.json5 fallback
loadGamedata(url) in the renderer SDK can be pointed at either a
single JSON file or a directory containing manifest.json5 + tier
sub-directories. The directory pattern is what we use in production,
so requests like /nitro-assets/gamedata/figuremap/ (note the
trailing slash) need to resolve to the directory's manifest.
The try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;
above does exactly that — try the URI as-is, fall back to the
manifest.json5 inside the directory, fall back to .json for
legacy deploys, then 404. Without it nginx returns 403 (autoindex
off) on directory URLs and the loader cascades into the manifest 404
path.
6. Server-side: Windows + IIS deployment
You can reach the same 4 s cold load on Windows Server with IIS. The same three wins (gzip, long cache, JSON5 fallback) are replicable — syntax changes, performance ceiling doesn't.
6.1 Don't host Node inside IIS
IISNode is unmaintained. The current MS recommendation is to run
Node as a Windows service and let IIS reverse-proxy to it:
- Install Node 22 LTS, run the CMS app as a Windows Service (via
nssm,pm2-windows-startup, or a scheduled task on boot) bound to127.0.0.1:3003— same layout asdocker-compose.ymlon the Linux host. - Install Arcturus separately as a Windows service running
Habbo-x.y.z-jar-with-dependencies.jaragainst MariaDB. WS ports 30001 + 30002 stay on127.0.0.1. - IIS handles HTTPS termination, static file serving, compression
and reverse-proxying
/api/*+/client+ the Inertia entry point to127.0.0.1:3003.
Install these IIS features (Server Manager → Web Server → Add Roles & Features):
- URL Rewrite — proxy rules
- Application Request Routing (ARR) — lets IIS act as a forward proxy; enable proxy in the ARR feature page after install
- WebSocket Protocol — required for the Arcturus WS upgrade
- Static Content + Static Content Compression
- Dynamic Content Compression — off by default, this is the single most important toggle on a vanilla Windows Server
6.2 Enable compression site-wide
IIS Manager → site → Compression feature → tick both
"Enable dynamic content compression" and "Enable static content
compression". Equivalent of nginx's gzip on;.
Without ticking both you ship raw bytes. Static covers JS / CSS /
JSON files, Dynamic covers Node responses (HTML from the Inertia
render). Add application/json to the compressor (and .json5 to
its MIME map) in applicationHost.config or the site's web.config:
<system.webServer>
<httpCompression directory="%SystemDrive%\inetpub\temp\IIS Temporary Compressed Files">
<scheme name="gzip" dll="%Windir%\system32\inetsrv\gzip.dll"
staticCompressionLevel="6" dynamicCompressionLevel="6" />
<dynamicTypes>
<add mimeType="application/json" enabled="true" />
<add mimeType="application/javascript" enabled="true" />
<add mimeType="text/css" enabled="true" />
<add mimeType="text/javascript" enabled="true" />
<add mimeType="text/*" enabled="true" />
</dynamicTypes>
<staticTypes>
<add mimeType="application/json" enabled="true" />
<add mimeType="application/javascript" enabled="true" />
<add mimeType="text/css" enabled="true" />
<add mimeType="text/javascript" enabled="true" />
<add mimeType="image/svg+xml" enabled="true" />
<add mimeType="font/ttf" enabled="true" />
<add mimeType="font/otf" enabled="true" />
<add mimeType="application/font-woff" enabled="true" />
<add mimeType="application/vnd.ms-fontobject" enabled="true" />
</staticTypes>
</httpCompression>
</system.webServer>
Verify with PowerShell:
Invoke-WebRequest -Uri 'https://<your-domain>/nitro-assets/gamedata/figuredata/core/palettes.json5' `
-Headers @{ 'Accept-Encoding' = 'gzip' } `
-MaximumRedirection 0 | Select-Object -ExpandProperty Headers
# expected: Content-Encoding = gzip
6.3 Long cache for gamedata
Drop a web.config inside the nitro-assets/ virtual
directory (or nest under <location>):
<location path="nitro-assets">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="30.00:00:00" />
<mimeMap fileExtension=".json5" mimeType="application/json" />
<mimeMap fileExtension=".nitro" mimeType="application/octet-stream" />
</staticContent>
</system.webServer>
</location>
30.00:00:00 is the IIS TimeSpan for 30 days — same effect as
Cache-Control: public, max-age=2592000 on nginx.
Set a separate, shorter cache (e.g. 5 minutes) on index.html so
deploys propagate without forcing visitors to clear their cache.
6.4 Directory → manifest.json5 fallback
nginx's try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;
has no native IIS equivalent. Use URL Rewrite to chain two rules
inside the same <location>:
<system.webServer>
<rewrite>
<rules>
<rule name="gamedata-dir-to-manifest-json5" stopProcessing="true">
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
<conditions>
<add input="{REQUEST_FILENAME}/manifest.json5" matchType="IsFile" />
</conditions>
<action type="Rewrite" url="{R:1}/manifest.json5" />
</rule>
<rule name="gamedata-dir-to-manifest-json" stopProcessing="true">
<match url="^(nitro-assets/gamedata/[^?]+)/$" />
<conditions>
<add input="{REQUEST_FILENAME}/manifest.json" matchType="IsFile" />
</conditions>
<action type="Rewrite" url="{R:1}/manifest.json" />
</rule>
</rules>
</rewrite>
</system.webServer>
6.5 Reverse proxy to Node + WebSocket upgrade
Once ARR is installed and proxy enabled (IIS Manager → server node → ARR → Server Proxy Settings → check "Enable proxy"), add a top-level rule that forwards everything not matching a static file:
<system.webServer>
<rewrite>
<rules>
<rule name="reverse-proxy-to-node" stopProcessing="true">
<match url="(.*)" />
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="http://127.0.0.1:3003/{R:1}" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
<set name="HTTP_X_FORWARDED_PROTO" value="https" />
<set name="HTTP_X_FORWARDED_HOST" value="{HTTP_HOST}" />
</serverVariables>
</rule>
<rule name="ws-proxy" stopProcessing="true">
<match url="^ws/(.*)" />
<action type="Rewrite" url="http://127.0.0.1:30001/{R:1}" />
</rule>
</rules>
</rewrite>
</system.webServer>
ARR transparently handles the WebSocket upgrade once the WebSocket Protocol IIS feature is installed.
6.6 IIS trade-offs (honest)
- Compression CPU: IIS dynamic compression is more CPU-hungry than nginx's worker-pool gzip. On 2-vCPU droplets expect ~10-15 % extra CPU during peak concurrency.
- Docker overhead: Docker Desktop on Windows goes through the WSL2 file-system bridge. Bind-mounting Linux-style paths into a container is measurably slower than the same on a native Linux host. Recommendation: run Node + Arcturus as native Windows services, not containerised.
- Java JDBC on Windows: Arcturus's JDBC pool exhibits slightly
higher lock-wait under concurrent room load on Windows than on
Linux. Re-tune
db.pool.maxsizeif you saturate.
Browser-perceived performance is identical to nginx once the config above is in place. The 4 s cold-load target is achievable on any Windows Server 2019 / 2022 box.
The one deployment to avoid: shared Windows hosting where the hoster doesn't let you enable Dynamic Compression at the application host level. You stay stuck at the 60-90 s baseline because neither Node's gzip nor IIS's compressor can be turned on.
7. End-to-end verification
Run each probe in order — they walk the request through every layer covered above. A green light on all four means the cold load is correctly tuned.
# 1. Build artefact has the granular chunks
yarn build
ls dist/assets/ | grep -E '^(vendor|nitro-renderer)-' | wc -l
# expected: ~12-14 chunks
# 2. Server is compressing JSON5 (or JS — pick either)
curl -sI -H 'Accept-Encoding: gzip' \
'https://<your-domain>/nitro-assets/gamedata/figuredata/core/palettes.json5' \
| grep -iE 'content-encoding|cache-control'
# expected:
# content-encoding: gzip
# cache-control: public, max-age=2592000
# 3. Directory → manifest.json5 fallback
curl -sI 'https://<your-domain>/nitro-assets/gamedata/figuremap/' \
| head -1
# expected: HTTP/2 200 (not 403 or 404)
# 4. LoadingView renders the progress bar — easiest from the live site:
# DevTools → Performance → Record → reload /client
# Look for the progress bar transitioning 5→100% within 4s on a
# warm-cache load, ~10-20s on a cold one with empty CF cache.
# 5. Remember-token captured to localStorage:
# DevTools → Application → Local Storage → check nitro.auth.remember
# is populated after the first successful load.
If the build artefact is correct but the live site doesn't pick up
the new chunks, the deploy didn't replace dist/ on the server.
Wipe the target dir's assets/*.js and src/assets/*.css before
extracting the new tarball — old chunk filenames stick around
otherwise.