GioJSdocs
On this page

How GioJS Works

Rust owns the hot path. Node does what it is best at: rendering React.

GioJS splits responsibilities across two layers. The compiled Rust server handles everything performance-critical; Node handles React SSR and the npm ecosystem.

  • Rust - HTTP/2, TLS, routing, compression, image optimization, ISR cache, static files, middleware
  • Node - React rendering via renderToReadableStream, getServerSideProps, your app logic

A request only reaches Node if it is a dynamic SSR route that missed the cache. Everything else is served entirely from Rust.

The life of a request

Every request passes the same layers in the Rust server, in this order. A layer that can answer on its own - a refusal, a redirect, a cache hit - does, and nothing after it runs:

  1. Client identity - the client's address and scheme are resolved (through [server] trusted_proxies), the request gets its X-Request-Id, and repeated cookie fields are joined.
  2. Path hygiene - the path is made canonical; dot segments, a raw \ or a broken % escape get 400, and unknown /_gio/ paths 404.
  3. Locale - with [i18n], the locale is detected and its prefix removed from the path.
  4. Deployment skew - a client navigation from another deployment gets 409 and reloads in full.
  5. Rate limits - [[rate_limits]] rules answer 429 past their budget.
  6. CSRF - a cross-site POST, PUT, PATCH or DELETE gets 403, before its body is read.
  7. Rules - guards, redirects and rewrites from gio.toml and middleware.ts.
  8. Prefetch budget - prefetch requests past the per-client budget get 429.
  9. Routing - built-in /_gio endpoints, build assets, fonts and public/ files are served from Rust; a page or route handler goes to the page cache, and only on a miss (or for anything personal) to a Node worker.

On the way out, the response gets its security headers (and CSP nonces), its X-Gio-Cache status and its compression. Each of these protections has a switch in gio.toml; see Turning Protections On and Off.

Node workers

The Rust server spawns the Node side itself and talks to it over a local socket (a Unix socket, or a named pipe on Windows) carrying length-prefixed JSON frames. Each worker proves it was started by this server with a per-worker token before it gets any traffic. The server supervises its workers: one that crashes is respawned with backoff, and only the requests it had in flight fail (with a 503) - cached and static content keeps serving throughout.

By default there is one worker. With [server] workers = N (or "auto") the server runs a pool, so renders use several CPU cores:

  • Dispatch - each request goes to the ready worker with the fewest requests in flight (open streams included), ties taken in turn. A worker that is respawning is skipped. Everything a request starts - a streamed body, an SSE stream, a Partial Prerendering hole render - stays on its worker.
  • One build - the first worker bundles the client code and records the result in .gio/build/manifest.json; the rest start once it is ready and load that manifest, so they never race on the same files. A worker that cannot load it fails its boot and is retried; it never rebuilds under the workers serving.
  • WebSockets - each connection is pinned to one worker, which runs its handler. Rooms live in the Rust server, so broadcast(room, ...) from any worker reaches sockets on all of them.
  • Shared server state - the page cache, revalidation, middleware rules and rate limits live in Rust, so they behave the same whichever worker handles a request. Module-level state in your app is per worker, and Node plugins' onStartup / onShutdown hooks run in every worker.

See [server] for the setting and Deployment for sizing a pool.

Further reading