Skip to content
Hoody.com

Every Hoody Exec script declares an execution mode. The mode decides whether the script gets one long-lived JavaScript context or a fresh context 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 long-lived JavaScript context per exec instance 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 Hoody Exec restarts: a container restart, a restart through POST /api/v1/exec/system/restart, or the automatic recovery after a crash or a script that blocks the service.

  • Long-lived context: built 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 your own ws handlers (the remote attach socket works in both modes)
  • Unlimited concurrency by default: simultaneous requests are handled in parallel; cap parallelism with @concurrent N when you need to (@concurrent works in both modes for HTTP requests, cron fires and remote send and call; messages to an ordinary script WebSocket are not held back by it, so serialize that work yourself when it matters)
  • Pre/post middleware: pre.ts and post.ts wrap HTTP requests matching a script in the same directory (middleware runs in both worker and serverless mode; a WebSocket upgrade runs only pre.ts, unless @websocket-pre false turns it off, and a remote attach socket runs neither)
  • No context build: the context is already warm, so only script loading and compilation happen per request
  • State is lost on container restart (use SQLite for persistence)
  • Higher memory usage, from keeping that context alive
  • WebSocket servers (chat, live dashboards, real-time updates)
  • Session management and caching
  • High-traffic APIs, which skip the context build 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

// Or omit the directive; serverless is the default
// @mode serverless

Serverless mode creates a fresh JavaScript context for every single request, in the style of AWS Lambda, Vercel Functions, and Cloudflare Workers. Context-local state does not survive: variables in the script start empty on every request. Functions a script registers for remote messages and calls are the exception: they keep the variables, and the context, of the run that registered them. With @remote-eval (and a script that does not bind or assign to eval, use with, or replace the global eval, see remote), runs still in progress are kept so they can be inspected (a run counts as finished once its handler returns, even if a stream it returned is still being sent). So is one finished run per room, the most recently started one, for up to 32 rooms per instance. This is not a history: a run that started later replaces the room’s kept run when it ends. A request that hit its @timeout before sending anything counts as ended, even if its code is still running. What does survive is everything outside the context, and that is most of what matters for safety: the per-instance shared object is the same one worker mode uses, modules you load with require or import are cached and keep their own state, and files written, subprocesses started and network calls made all persist.

  • Fresh context: a new JavaScript context for every request
  • No leftover context state: variables in your script do not reach the next request. Modules you require or import are cached and can keep their own state
  • Concurrency control: @concurrent 5 limits parallel execution (also available in worker mode)
  • Lower memory: no long-lived context to keep around, except contexts kept for remote handlers or @remote-eval, or kept alive by your own timers and callbacks (a setInterval is not cancelled when the request ends)
  • Lower idle cost than keeping a worker context alive
  • No ws handlers of your own, since there are no persistent connections (the remote attach socket still works)
  • No per-request isolation of side effects: files written, subprocesses started and network calls made persist, and the per-instance shared object is the same one worker mode uses
  • Slight per-request overhead from building the context

Both modes run with the Exec service’s own authority: scripts can use the filesystem, spawn subprocesses and reach the network, so both need code you trust. Give mutually untrusted workloads separate containers.

Neither mode gives a script its own process either. Every script of an exec instance, in both modes, runs in one server process with one event loop. While one script runs synchronous code (a long loop, a synchronous file read or hash), every other script and request on that instance waits for it. @timeout cannot stop synchronous code. If the block lasts longer than the watchdog limit (30 seconds by default), the server is restarted: every running script ends, in-memory state such as shared is cleared, and requests fail for several seconds while it comes back. Move heavy synchronous work into a subprocess or break it up with await.

  • Webhook receivers (Stripe, GitHub, Slack)
  • Independent tasks (data processing, API calls)
  • Sporadic traffic, on a pay-per-execution model
  • Stateless microservices
  • Scripts that keep no module-level state worth preserving between requests
  • Anything that gains nothing from a warm context between requests

The shared object is the same in both modes, with the same scope and lifetime; what differs is the context around it:

// @mode worker
// Example inputs
const userId = metadata.parameters.id;
const userData = { seenAt: Date.now() };
// 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 is the same per-instance object, so it persists between requests here too; what resets is context-local state, such as variables in your script. Modules you require keep their own state. Use ordinary local variables for request-scoped 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 instance: each exec instance has its own shared object, and two instances under the same subdomain never see each other’s values. There is no automatic TTL. Values persist until Hoody Exec restarts (see above) or the service evicts an idle instance’s state, which it does to stay within its cache limit:

  • Data stays in shared until you delete it, the service evicts that instance’s idle state, or Hoody Exec 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 (key and data are whatever you want to cache)
const key = 'greeting';
const data = 'hello';
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 builds a fresh context on every request, so every request pays a small overhead for building it and compiling the script. That is the cold start cost.

Worker mode builds its context on the first request only. After that the context stays warm and later requests skip the context build. The script is still loaded and compiled on every request. The context stays warm while it is cached. The service can evict an idle instance’s context and its shared state when it is over its cache limit, and clearing the cache or restarting Hoody Exec also removes it. Writing, moving or deleting a .env file the script reads through the management API can drop the context too; shared is kept.

First requestSubsequent requests
ServerlessContext build + compilationContext build + compilation (same cost every time)
WorkerContext build + compilationCompilation only (context already warm)

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


Worker mode’s long-lived context keeps its state alive between requests; the script itself is still read and compiled on every request. What survives is narrower than it looks:

  • shared persists, and so does anything reachable from it, or from a module you required
  • Globals persist too: a property you set on globalThis, or a variable you assign without declaring it, stays in the context, and every worker script of the same instance sees it
  • Ordinary variables in your script do not. Your code is wrapped in a function that runs again for each request, so a top-level let count = 0 is back at 0 every time in both modes. Variables captured by a WebSocket or remote handler are the exception: they live as long as that handler, across messages and connections. Put counters and caches on shared
  • Memory accumulates: every Map, Set, array, or object you add to shared stays in memory until you remove it, and is not garbage collected while shared references it
  • 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,
"rss": 188743680,
"external": 3145728
}
}

used, total and percentage describe the JavaScript heap, so percentage is heap use, not the share of the container’s memory. To watch the memory of the whole process, including Buffers held outside the heap, track rss.

// @mode worker
// 1. Cap collection sizes
if (shared.logs && shared.logs.length > 1000) {
shared.logs = shared.logs.slice(-500); // Keep last 500
}
// 2. Clear the context cache and shared state
// Packages loaded with require() keep their own state until Hoody Exec restarts (POST /api/v1/exec/system/restart)
// (a reinstall reloads a CommonJS package for later require() calls; ES modules stay loaded)
// POST /api/v1/exec/cache/clear with body { "clearAll": true, "clearState": true } for every hostname, or { "hostname": "default", "execId": "1", "clearVm": true, "clearState": true } for one exec instance (public URLs always use the hostname "default"; leave out "execId" to clear every instance)
// 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 instead. Both set the same single handler,
// so the last one you register wins:
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)
// fetchUserFromDatabase is your own function; Exec does not provide it
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, a fresh context each time:

api/users/[id].ts
// @mode serverless
// @concurrent 10
// @cors reflective
// @timeout 5000
// @log-level standard
const userId = metadata.parameters.id;
// Validate input
if (typeof userId !== 'string' || !/^[0-9a-fA-F]{24}$/.test(userId)) {
res.statusCode = 400;
return {
error: 'Invalid user ID format',
expected: '24-character hex string'
};
}
// Fresh database query every time (no cache)
// fetchUserFromDatabase is your own function; Exec does not provide it
const user = await fetchUserFromDatabase(userId);
if (!user) {
res.statusCode = 404;
return {
error: 'User not found',
userId
};
}
// Clean response
return {
user,
requestId: metadata.executionId
};