Skip to content

Docs · Getting started

On the edge

Run Vectro on Cloudflare Workers and other runtimes without Node APIs, with the portable build and the precompiled WebAssembly module.

vectrojs/portable is the engine with nothing platform-specific in it: no Node APIs, one thread, plain (not shared) memory. It runs in browsers, Deno, Bun and edge runtimes. Tables, expressions, group-bys, joins, sorts and SQL all work as in Node.

import { Engine, sql } from 'vectrojs/portable';

const engine = new Engine();          // browsers, Deno, Bun

Cloudflare Workers

Workers don’t allow compiling WebAssembly while a request runs (nor eval or new Function). Vectro normally compiles a WebAssembly kernel per query, so on Workers it uses two things instead:

  • A precompiled module, vectrojs/dist/vectro.wasm (11 KB), holding the built-in kernels and the building blocks of the interpreter: elementwise math, comparisons, selects, sums, group-by scatter and compaction. Wrangler bundles a .wasm import as a WebAssembly.Module ahead of time.
  • A vectorized interpreter for expressions: it walks the expression once per block of rows, running each step over the whole block with the precompiled kernels.

Pass the module to the engine:

// src/index.js
import { Engine } from 'vectrojs/portable';
import wasmModule from 'vectrojs/dist/vectro.wasm';

let engine, orders;   // one engine per isolate, reused across requests

export default {
  async fetch(request) {
    engine ??= new Engine({ wasmModule });
    orders ??= engine.parseCSV(await (await fetch('https://example.com/orders.csv')).text());

    const summary = orders.filter("region == 'EU'").agg({
      orders: 'count()',
      revenue: 'sum(price * qty)',
      avgPrice: 'mean(price)',
    });

    const grouped = orders.groupBy('region').agg({ revenue: 'sum(price * qty)' });
    const byRegion = grouped.toRows();
    grouped.dispose();            // free the result: the isolate lives on

    return Response.json({ summary, byRegion });
  },
};
# wrangler.toml
name = "vectro-demo"
main = "src/index.js"
compatibility_date = "2026-10-01"
# no nodejs_compat needed: the portable engine uses no Node APIs

Then npx wrangler dev to try it, npx wrangler deploy to ship it. The Vectro repository has this Worker in examples/cloudflare-worker, and a self-test Worker that checks random expressions against a reference evaluator inside the Workers runtime.

Without the module, new Engine() on Workers fails with a message that says what to do:

vectro: this runtime cannot compile WebAssembly; pass the precompiled module: new Engine({ wasmModule }) with dist/vectro.wasm

The same applies to other runtimes that refuse to compile WebAssembly at run time, such as Vercel Edge. canCompileWasm() tells you which kind you are on.

The sync calls are fine here

With one thread there is nothing to hand work to, so agg, toRows and the other blocking calls are the natural ones; the …Async versions also work and give the same answers. There is no readCSV(path): load data from bytes or text you fetched.

Call From
engine.parseCSV(textOrBytes, opts) CSV from fetch, R2 or KV
engine.fromArrow(bytes) Arrow IPC bytes
engine.table(columns) Arrays you built
await openDatasetAsync(engine, url) A .vectro dataset over HTTP, read with range requests (only the chunks a query needs)

openDatasetAsync is exported by vectrojs/portable. Dataset queries use the async calls (toRowsAsync(), aggAsync()). See Datasets.

Interpreter or compiled kernels

jit decides how expressions run:

jit How Where
'auto' (default) Compiled kernels wherever the runtime allows, the interpreter otherwise Everywhere
true One compiled SIMD kernel per expression (fastest) Node, browsers, Deno, Bun
false The vectorized interpreter Anywhere; what Workers get

Both give the same answers. You can try the interpreter in Node to see that:

import { Engine } from 'vectrojs/portable';

const compiled = new Engine();                // jit where the runtime allows it
const interpreted = new Engine({ jit: false });

const data = {
  region: ['EU', 'US', 'EU', 'APAC', 'US', 'EU'],
  price: [120, 80, 45, 300, 99, 15],
  qty: [2, 1, 4, 1, 3, 10],
};
for (const engine of [compiled, interpreted]) {
  const t = engine.table(data);
  console.log(engine.jit ? 'compiled:   ' : 'interpreted:',
    t.filter("region == 'EU' && price > 20").agg({ n: 'count()', revenue: 'sum(price * qty)' }),
    t.groupBy('region').agg({ avg: 'mean(price)' }).toRows().map((r) => `${r.region} ${r.avg}`).join(', '));
  await engine.close();
}

Memory on a Worker

A Worker isolate has a memory limit of 128 MB, and the engine’s memory counts toward it. Two things keep it in bounds:

  • Dispose results. The isolate, and the engine you keep in a module variable, outlive each request. Tables made per request (groupBy().agg, collect, derive, sort) stay in engine memory until you call dispose().
  • Plain memory grows in steps. Without threads, the engine’s memory is a plain buffer that is replaced when it grows, so it grows in big steps to make that rare. It starts at 16 MB; set initialMemoryMB to start bigger and avoid the first few moves, or leave it if your tables are small.
engine ??= new Engine({ wasmModule, initialMemoryMB: 64 });
import { Engine } from 'vectrojs/portable';

const engine = new Engine({ jit: false, initialMemoryMB: 32 });
const orders = engine.table({
  region: Array.from({ length: 100_000 }, (_, i) => ['EU', 'US', 'APAC'][i % 3]),
  price: Float64Array.from({ length: 100_000 }, (_, i) => i % 250),
});

for (let request = 0; request < 3; request++) {
  const g = orders.groupBy('region').agg({ revenue: 'sum(price)' });
  console.log(request, g.toRows());
  g.dispose();            // without this, every request would leave a table behind
}
console.log(orders.memoryBytes());
await engine.close();

Timing on Workers

Workers freeze performance.now() and Date.now() while a request runs, so a query can’t time itself there. Time requests from outside (with curl -w or a load tester) instead.