◇ Guide · Coming from Node.js

From Node.js to Symmetra

Your Express app, rewritten in sym — every concept side by side, the real API, and an honest list of what doesn't work yet.

10 min read · Public beta · Examples run on a real GPU

The one-sentence version

You keep writing TypeScript with the same shape you already know — a router, route handlers, req/res-style objects — but instead of node server.js you run sym server.ts, and every handler is compiled to CUDA and executed on the GPU instead of interpreted on a CPU.

No node_modules to ship. No V8. The @symmetra/* packages are type definitions only — the implementations live inside the sym binary.

1The mental model

In Node.js, your JavaScript is interpreted by V8 on a CPU thread, one event loop at a time. In Symmetra, your TypeScript is compiled ahead of the first request into GPU machine code, and each request runs as GPU threads. The code you write looks almost identical. What changes is underneath.

 Node.jsSymmetra
LanguageJavaScript / TypeScriptThe same TypeScript
Runs onV8 → CPU, one event loopCUDA → GPU, thousands of threads
How code executesJIT-interpreted per requestCompiled to PTX once at startup, then GPU-native
Start commandnode server.jssym server.ts
Dependenciesnpm install, ship node_modules/Types only; no runtime deps to ship
CPU doesEverythingOnly TCP accept / recv / send
Keep this in mind The CPU never leaves entirely. Accepting the socket and reading bytes off the wire is still a CPU job — that part is unavoidable. But the moment your bytes are in memory, they're handed to the GPU, and your handler logic runs there.

2Hello world, both ways

Here is the smallest possible server in each. Read them side by side — the shape is the same: make an app, define a route, start listening.

Node.js + Express — server.js
const express = require('express')
const app = express()

app.get('/ping', (req, res) => {
  res.json({ message: 'pong' })
})

app.listen(8080)
Symmetra — server.ts
import { Router, serve } from '@symmetra/http'

const app = new Router()

app.get('/ping', ctx => {
  ctx.json({ message: 'pong' })
})

serve(app, { port: 8080 })

Three differences, and that's the whole story of hello-world:

Running it

Node.js
$ node server.js
# (silent — it's just listening)

$ curl localhost:8080/ping
{"message":"pong"}
Symmetra
$ sym server.ts

  Listening   http://localhost:8080
  Routes      1 compiled
  GPU grid    256 slots · persistent async
  GET /gpu-stats for live metrics

$ curl localhost:8080/ping
{"message":"pong"}

On boot, sym prints a hexagon banner, your GPU model, VRAM, and the compile pipeline. The response your client gets is byte-for-byte what Express would send.

3The translation cheat-sheet

Most of migrating is muscle memory. Here's the direct mapping for the things you reach for every day.

What you wantNode / ExpressSymmetra
Create the appexpress()new Router()
A GET routeapp.get('/x', (req,res)=>…)app.get('/x', ctx =>…)
A POST routeapp.post('/x', …)app.post('/x', …)
Send JSONres.json(obj)ctx.json(obj)
Set a status coderes.status(404).json(…)ctx.status(404).json(…)
Read a route paramreq.params.idctx.params.id
Read the JSON bodyreq.body (after body-parser)await ctx.json<T>()
Start the serverapp.listen(8080)serve(app, { port: 8080 })
Environment variableprocess.env.FOOprocess.env.FOO
Run itnode server.jssym server.ts

Node built-ins are auto-remapped

You don't have to rewrite your imports. The compiler recognises common Node built-ins and points them at the GPU-native equivalent automatically:

Your Node importBecomes
import fs from 'fs'@symmetra/fs
import path from 'path'built-in path utilities
import { createHash } from 'crypto'@symmetra/crypto
import { EventEmitter } from 'events'@symmetra/core event emitter
import { fetch } from 'node-fetch'@symmetra/net
import os from 'os'GPU system-info API

4A realistic route: params, body, status

A CRUD-style handler with a URL param, a JSON body, and a 404 path. This is the shape 90% of API code takes — notice how little actually changes.

Node.js + Express
app.get('/user/:id', (req, res) => {
  const id = req.params.id
  const user = lookup(id)
  if (!user) {
    return res.status(404)
              .json({ error: 'not found' })
  }
  res.json(user)
})

app.post('/user', (req, res) => {
  const body = req.body
  save(body)
  res.json({ ok: true })
})
Symmetra
app.get('/user/:id', ctx => {
  const id = ctx.params.id
  const user = lookup(id)
  if (!user) {
    return ctx.status(404)
              .json({ error: 'not found' })
  }
  ctx.json(user)
})

app.post('/user', async ctx => {
  const body = await ctx.json()
  save(body)
  ctx.json({ ok: true })
})
The only real difference Reading the body is await ctx.json() instead of req.body. There's no separate body-parser middleware to install — parsing is built in.

5Databases, cache, crypto, HTTP

The everyday service libraries have direct equivalents. They import as normal TypeScript; the implementation runs inside sym, so there's nothing to npm install at runtime.

A key-value cache — in VRAM, not Redis

server.ts — @symmetra/kv
import { Router, serve } from '@symmetra/http'
import { KvStore } from '@symmetra/kv'

const kv  = new KvStore({ maxMb: 512 })   // lives in GPU memory
const app = new Router()

app.get('/item/:id', async ctx => {
  const item = await kv.get(ctx.params.id)  // no DRAM round-trip
  if (!item) { ctx.status(404).json({ error: 'not found' }); return }
  ctx.json(item)
})

app.put('/item/:id', async ctx => {
  const body = await ctx.json()
  await kv.set(ctx.params.id, body, { ttlMs: 60_000 })
  ctx.json({ ok: true })
})

serve(app, { port: 8080 })

In Node this would be ioredis + a Redis server. Here the cache is a slab of VRAM the GPU reads directly.

Postgres, auth, and an outbound HTTP call

server.ts — @symmetra/db · @symmetra/crypto · @symmetra/net
import { postgres } from '@symmetra/db'
import { jwt, password } from '@symmetra/crypto'
import { http } from '@symmetra/net'

const pg = postgres({ host: 'localhost', database: 'myapp', user: 'u', password: 'p' })

app.get('/users', async ctx => {
  const r = await pg.query('SELECT id, name FROM users')
  ctx.json(r.rows)
})

app.post('/login', async ctx => {
  const { email, pass } = await ctx.json()
  if (!await password.verify(pass, user.passwordHash)) {
    ctx.status(401).json({ error: 'invalid credentials' }); return
  }
  const token = await jwt.sign({ sub: String(user.id) }, process.env.JWT_SECRET!, { expiresIn: 86400 })
  ctx.json({ token })
})

app.get('/proxy', async ctx => {
  const res = await http.get('https://api.example.com/data', { timeout: 5000 })
  ctx.json(res.json())
})
How outbound I/O works A GPU thread can't open a socket. When your handler calls ctx.fetch(…), kv, or pg, the GPU hands that one operation back to the CPU, the CPU performs it, and the result is handed back to the GPU — a cooperative round-trip that keeps your handler code looking like plain async/await.

6The part Node can't do: data-parallel handlers

This is the reason to be here. Array operations on a GpuBuffer.map(), .filter(), .reduce() — compile to real CUDA kernels, one GPU thread per element. The TypeScript reads like an ordinary array method; the machine code is massively parallel.

Your TypeScript
app.post('/transform', async ctx => {
  // already in VRAM, zero-copy
  const data = await ctx.body<Float32Array>()

  // one thread per element
  const out = data.map(x => x*x + Math.sin(x))

  ctx.json(out)
})
Generated CUDA (for intuition)
__global__ void transform(
    const float* in,
    float* out, int n) {
  int tid = blockIdx.x*blockDim.x
          + threadIdx.x;
  if (tid >= n) return;
  float x = in[tid];
  out[tid] = x*x + sinf(x);
}

In Node, that .map() is a sequential loop on one core. In Symmetra it's thousands of elements computed at once. You didn't write CUDA — you wrote .map().

7What you can't do (yet) — the honest part

Symmetra is public beta. It runs on a GPU, and GPUs are not CPUs — some things you take for granted in Node either don't exist or have sharp edges. Read this before porting anything real.

FeatureStatusWhat to do
Express middleware ecosystem✗ NoNo app.use(...) plugin chain. Write logic directly in handlers.
npm packages at runtime✗ NoOnly the built-in @symmetra/* libraries run. Arbitrary npm modules don't.
Long-running loops with dynamic bounds△ CarefulThe compiler prefers loops with fixed, known bounds. See the coverage matrix.
Full JS/TS language surface△ SubsetA large subset compiles; some dynamic patterns don't. Check the matrix below.
WebSockets / streaming responses△ LimitedThe model is request/response today. Confirm your protocol is supported.
Runs without an NVIDIA GPU✗ NoNeeds an NVIDIA GPU (compute capability 7.5+) and a recent driver.
Before you port Check the JS/TS coverage matrix for exactly which language features compile, and Constraints & trade-offs for the GPU limits that shape how you write handlers. These pages are the real contract — this article is the friendly overview.

8Monitoring: one endpoint, always on

There are no default routes — not even /health. You define your own routes (including any health check you want). But the runtime always exposes one endpoint for live diagnostics, no matter what your server.ts contains:

Live server metrics — served by the runtime, not your code
$ curl localhost:8080/gpu-stats

{
  "gpu": { "name": "NVIDIA GeForce RTX 5070", "sm_arch": "sm_120",
           "cuda_cores": 6144, "vram_total_gb": 12.82 },
  "deployed": { "blocks": 32, "threads_total": 4096, "slots_total": 256 },
  "usage": { "slots_in_flight": 0, "slots_free": 256 },
  "live": { "power_w": 37.4, "temp_c": 34 },
  "throughput": { "uptime_sec": 134.6, "total_requests": 0, "avg_rps": 0 }
}

It reports GPU topology, how much of the device you've deployed, live power/temperature, and cumulative throughput — the equivalent of a Prometheus exporter, built in and always reachable.

9Scaling: you don't configure it

In Node you'd reach for the cluster module or PM2 to use more than one core. In Symmetra, the runtime sizes itself to your GPU at startup — it detects the number of streaming multiprocessors, your OS, and lays out GPU blocks, threads, and request slots automatically. A bigger GPU is used more fully with no code change.

Honest ceiling Auto-scaling removes the GPU as the bottleneck — but sustained requests/sec is gated by the network and OS, not the GPU (which sits idle >95% of the time at these scales). "Millions of req/s" is a Linux + multi-GPU + real-NIC claim, not a laptop-loopback one. See the flagship article for the measured numbers.

10Your migration, step by step

  1. Install the runtime Download an engine from the catalog (Windows installer, .zip, or Linux .tar.gz) and run sym doctor to confirm your GPU and driver.
  2. Rename and adjust one route Copy a single Express route into a server.ts, change (req,res) to ctx, res.json to ctx.json, and app.listen to serve(app, …).
  3. Run it sym server.ts, then curl your route. The boot banner tells you how many routes compiled.
  4. Check the matrix for the rest For each remaining route, glance at the coverage matrix to confirm its language features compile before porting.
  5. Swap services to @symmetra/* Replace ioredis@symmetra/kv, pg@symmetra/db, etc. Node built-ins remap automatically.
  6. Point your monitoring at /gpu-stats and define whatever custom health route your infra expects.