Skip to content
Hoody.com

Every Hoody Exec script declares an execution mode. The mode decides whether the script gets one persistent VM or a fresh VM per request, and that in turn determines what state it can keep, how it performs, how it handles concurrency, and which features are available to it.


// @mode worker

Worker mode creates one persistent VM that stays alive across all requests and does not restart between them. The shared object is its in-memory store: write to it once and read it back on later requests, until it is cleared through POST /api/v1/exec/shared-state/clear or the container restarts.

  • Persistent VM: created once, then reused for every request
  • Shared state: the shared object survives from one request to the next
  • WebSockets: worker mode is the only mode that supports them
  • Unlimited concurrency by default: simultaneous requests are handled in parallel; cap parallelism with @concurrent N when you need to (@concurrent works in both modes)
  • Pre/post middleware: pre.ts and post.ts wrap requests matching a script in the same directory (middleware runs in both worker and serverless mode)
  • No cold start: the VM is already warm
  • State is lost on container restart (use SQLite for persistence)
  • Higher memory usage, from the persistent VM overhead
  • WebSocket servers (chat, live dashboards, real-time updates)
  • Session management and caching
  • High-traffic APIs, which pay no cold start after the first request
  • Rate limiting with per-IP tracking
  • Hoody AI interception, an MITM proxy for controlling AI requests
  • Anything that needs state to survive from one request to the next

// @mode serverless // or omit (default)

Serverless mode creates a brand new VM for every single request, the model used by AWS Lambda, Vercel Functions, and Cloudflare Workers. Requests are fully isolated from each other: state from one request is never visible to the next, memory is not shared, and side effects do not carry over.

  • Fresh context: a brand new VM for every request
  • Complete isolation: no state leaks between requests
  • Concurrency control: @concurrent 5 limits parallel execution (also available in worker mode)
  • Lower memory: no persistent VM to keep around
  • Stronger isolation guarantees for code you do not fully trust
  • No WebSocket support, since there are no persistent connections
  • No shared state across requests
  • Slight per-request overhead from creating the VM
  • Webhook receivers (Stripe, GitHub, Slack)
  • Isolated tasks (data processing, API calls)
  • Sporadic traffic, on a pay-per-execution model
  • Stateless microservices
  • Untrusted user scripts, which get better isolation here
  • Anything where isolation matters more than performance

The shared object exists in both modes, but behaves differently in each:

// @mode worker
// Initialize on first request
if (!shared.requestCount) {
shared.requestCount = 0;
shared.users = new Map();
shared.sessions = new Set();
}
shared.requestCount++; // persists across all requests
shared.users.set(userId, userData);
return {
count: shared.requestCount, // Increments: 1, 2, 3, 4...
cachedUsers: shared.users.size, // Accumulates over time
activeSessions: shared.sessions.size
};

Key differences:

  • Worker: shared persists between requests, which is what makes caching, counters, and sessions work
  • Serverless: shared resets on every request, so it buys you no persistence; use it for request-scoped temporary data
  • Both modes: shared is lost on container restart or reboot; use SQLite for permanent storage

Shared state is in-memory only and scoped per hostname, so each exec instance has its own shared object. There is no automatic TTL, and values persist for the entire lifetime of the server process:

  • Data stays in shared until you delete it or the container restarts
  • There is no expiration mechanism, so implement your own if you need one
  • On long-running workers, clean up stale data explicitly or memory will grow without bound
// @mode worker
// Explicit cleanup pattern for long-running workers
if (!shared.cache) shared.cache = new Map();
// Add with timestamp
shared.cache.set(key, { value: data, createdAt: Date.now() });
// Periodic cleanup (e.g., remove entries older than 1 hour)
const ONE_HOUR = 3600000;
for (const [k, v] of shared.cache) {
if (Date.now() - v.createdAt > ONE_HOUR) shared.cache.delete(k);
}

The shared object lives entirely in memory, so a container restart clears it. That is by design. If you need data to survive restarts:

  • SQLite: use the bundled Database (bun:sqlite) for structured persistent storage
  • External databases: connect to an external service for critical data
  • File system: write to /hoody/storage/ for simple persistence
// @mode worker
// Persist critical data to SQLite
const db = new Database('/hoody/databases/app.db');
db.run('CREATE TABLE IF NOT EXISTS sessions (id TEXT, data TEXT, created INTEGER)');
// Use shared for fast access, SQLite for durability
if (!shared.sessions) {
// Restore from SQLite on first request after restart
shared.sessions = new Map();
const rows = db.query('SELECT id, data FROM sessions').all();
for (const row of rows) {
shared.sessions.set(row.id, JSON.parse(row.data));
}
}

The execution mode determines startup latency:

Serverless mode creates a fresh VM on every request, so every request pays a small overhead for VM creation and script compilation. That is the cold start cost.

Worker mode pays that cost on the first request only. After that the persistent VM stays warm and subsequent requests have no cold start at all. The VM remains cached until it is cleared through the cache-clear API or the container restarts.

First requestSubsequent requests
ServerlessVM creation + compilationVM creation + compilation (same cost every time)
WorkerVM creation + compilationZero overhead (VM already warm)

For latency-sensitive endpoints under frequent traffic, worker mode removes that overhead from every request after the first, for as long as the VM stays cached.


Worker mode’s persistent VM retains every variable between requests:

  • Memory accumulates: every Map, Set, array, or object added to shared (or to any persistent variable) stays in memory
  • Shared data is not garbage collected: only unreferenced local variables are collected between requests
  • Growing an array or map indefinitely will eventually consume all available memory

Use the monitoring endpoint to track memory consumption:

Terminal window
curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/api/v1/exec/monitor/stats"

Response includes:

{
"memory": {
"used": 52428800,
"total": 268435456,
"percentage": 19.5
}
}
// @mode worker
// 1. Cap collection sizes
if (shared.logs && shared.logs.length > 1000) {
shared.logs = shared.logs.slice(-500); // Keep last 500
}
// 2. Clear VM cache and shared state (full reset)
// POST /api/v1/exec/cache/clear with body { "clearAll": true } (or { "clearVm": true, "clearState": true })
// 3. Avoid patterns that grow indefinitely
// BAD: shared.allRequests.push(req) grows forever
// GOOD: shared.recentRequests = shared.recentRequests.slice(-100)

Two magic comments enable real-time bidirectional communication:

// @mode worker
// @websocket
// @cors reflective
// Serve HTML UI for HTTP requests (optional)
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>WebSocket Server</h1>');
// WebSocket handlers (direct assignment pattern)
ws.message = (socket, data) => {
console.log('Received:', data);
ws.broadcast(data); // Send to all clients
};
ws.close = (socket, code, reason) => {
console.log('Client disconnected');
};
// Or use the event emitter pattern:
ws.on('message', (socket, data) => {
socket.send('Echo: ' + data);
});

Connection tracking:

console.log('Active connections:', ws.connections.size);

Worker mode, with shared state caching:

api/users/[id].ts
// @mode worker
// @cors reflective
// @timeout 5000
// @log-level standard
// Initialize cache on first request
if (!shared.usersCache) {
shared.usersCache = new Map();
shared.cacheHits = 0;
shared.cacheMisses = 0;
}
const userId = metadata.parameters.id;
// Check cache first
if (shared.usersCache.has(userId)) {
shared.cacheHits++;
return {
user: shared.usersCache.get(userId),
cached: true,
cacheHitRate: shared.cacheHits / (shared.cacheHits + shared.cacheMisses)
};
}
// Fetch from database (cache miss)
shared.cacheMisses++;
const user = await fetchUserFromDatabase(userId);
// Cache for next request
shared.usersCache.set(userId, user);
return {
user,
cached: false,
cacheHitRate: shared.cacheHits / (shared.cacheHits + shared.cacheMisses)
};

Serverless mode, fresh VM and no state:

api/users/[id].ts
// @mode serverless
// @concurrent 10
// @cors reflective
// @timeout 5000
// @log-level standard
const userId = metadata.parameters.id;
// Validate input
if (!userId || userId.length !== 24) {
res.statusCode = 400;
return {
error: 'Invalid user ID format',
expected: '24-character hex string'
};
}
// Fresh database query every time (no cache)
const user = await fetchUserFromDatabase(userId);
if (!user) {
res.statusCode = 404;
return {
error: 'User not found',
userId
};
}
// Clean response (isolated execution)
return {
user,
requestId: metadata.executionId
};