How to Avoid a Surprise Bill When Running Code on Vercel Sandbox
Running code generated by an LLM on your own server is always a bit nerve-wracking. To wrap unpredictable code safely, an isolated environment is essential. While Vercel Sandbox conveniently packages up Firecracker microVMs for you, using it blindly can hit you twice: once with an unexpected bill, and once with high latency.
Here is a practical guide covering real-world solutions—from billing structures that can quickly drain your bank account if left unmonitored, to 16-second cold start delays, and MCP server issues that devour context windows.
Reading Between the Lines of Pricing and Eliminating 16-Second Cold Starts
When looking at the Vercel Sandbox price table, Active CPU and Provisioned Memory behave quite differently. On the Pro plan, Active CPU costs $0.128 per vCPU-hour, measured in milliseconds. Interestingly, during I/O Wait time—such as waiting for an external LLM response or querying a database—CPU usage is billed at $0.
The real issue is memory. Provisioned Memory charges $0.0212 per GB-hour based on allocated capacity over the entire wall-clock time the sandbox stays alive. Billing is rounded up in 1-minute increments, meaning even if you use it for just 5 seconds and leave it idle, you get billed for 1 minute. On top of that, there is an instance creation fee of $0.60 per 1,000,000 requests.
[User Request] -> [Create Sandbox ($0.60/1M calls)] | +--> Active CPU: $0.128 per vCPU-hour (ms precision, $0 for I/O Wait) +--> Provisioned Memory: $0.0212 per GB-hour (Wall-clock time, 1-min rounded up) | [Task Complete] -> [Explicit call to sandbox.stop()] -> [Memory billing stops immediately]
If you spin up an unconfigured, raw container and install TypeScript, Zod, and Axios using npm, booting takes a staggering 16.49 seconds. For a real-time service, users would have already abandoned the application. You need to combine instance cascading and snapshots to bring that time down under 500ms.
- Pin a Base Snapshot: Install dependencies beforehand in a container and run
sandbox.snapshot() to freeze the filesystem.
- Acquire Warm Instances: Pass this
snapshotId when a new request comes in. This reduces boot time down to 410ms.
- Explicitly Stop Instances: Immediately call
sandbox.stop() as soon as code execution finishes. If you don't, idle memory fees keep piling up. Establishing this pattern alone cuts compute costs by around 40%.
`typescript
import { Sandbox, Snapshot } from '@vercel/sandbox';
import { config } from 'dotenv';
config({ path: '.env.local' });
export class SandboxWarmPoolManager {
private static cachedSnapshotId: string | null = null;
public async getOrCreateBaseSnapshot(): Promise {
if (SandboxWarmPoolManager.cachedSnapshotId) {
return SandboxWarmPoolManager.cachedSnapshotId;
}
const sandbox = await Sandbox.create({
runtime: 'node24',
timeout: 5 * 60 * 1000,
});
try {
await sandbox.runCommand({
cmd: 'npm',
args: ['install', 'typescript', 'zod', 'axios', 'lodash'],
});
const snapshot = await sandbox.snapshot({
expiration: 14 * 24 * 60 * 60 * 1000,
});
SandboxWarmPoolManager.cachedSnapshotId = snapshot.snapshotId;
return snapshot.snapshotId;
} finally {
await sandbox.stop();
}
}
public async acquireWarmSandbox(tenantId: string): Promise {
const snapshotId = await this.getOrCreateBaseSnapshot();
const sandbox = await Sandbox.create({
source: { type: 'snapshot', snapshotId },
timeout: 2 * 60 * 1000,
resources: { vcpus: 2 },
tags: { tenantId },
});
return sandbox;
}
public async safeRelease(sandbox: Sandbox): Promise {
try {
await sandbox.stop();
} catch (error) {
console.error(Sandbox termination failed: ${sandbox.name}, error);
}
}
}
`
Reversing Prompt Structures That Choke as MCP Tools Grow
When using Model Context Protocol (MCP), you quickly hit a wall. Every time you add a tool to the server, JSON Schemas begin clogging the top of your prompt. A single tool can consume anywhere from 550 to 1,400 tokens. Once you connect a few MCP servers and exceed 50 tools, 55,000 tokens vanish before the user even asks a question. Aside from token costs, the LLM starts getting confused and hallucinating invalid parameters.
| Comparison Item |
Traditional Upfront Pre-injection |
Single Orchestrator Code Mode |
| Pre-consumed Context |
30,000 ~ 55,000+ tokens |
~800 tokens reserved for Meta Tool Schemas |
| Multi-step Round-trips |
12 ~ 19 sequential LLM calls |
Under 4 executions via a single code block |
| Response Speed & Cost |
High token costs, 3~5s latency |
58% reduction in token usage, 2s+ faster |
| Parameter Inference Accuracy |
Hallucinations due to dense schemas |
Accurate delivery via TypeScript type checking |
Listing dozens of schemas directly in the prompt is inefficient. Instead, host a TypeScript orchestrator inside the sandbox and expose minimal interfaces to the model.
- Expose Only 3 Meta Tools: List modules, collect TypeScript type definitions, and execute generated code. Provide only these 3 meta tools in the prompt.
- Hot-load On Demand: The runtime inside the sandbox receives the request and dynamically imports only the required module types when needed.
- Compress Loops into Code: Have the LLM write TypeScript code containing conditional statements and loops. The chaotic process of making 15 back-and-forth LLM and API calls is reduced to a single code execution. Token consumption drops by 58%, and response times improve by over 2 seconds.
`typescript
import { Sandbox } from '@vercel/sandbox';
interface OrchestrationRequest {
userQuery: string;
targetModules: string[];
generatedCode: string;
}
export class MCPCodeOrchestrator {
private sandbox: Sandbox | null = null;
public async initializeOrchestrator(): Promise {
this.sandbox = await Sandbox.create({
runtime: 'node24',
timeout: 3 * 60 * 1000,
resources: { vcpus: 2 },
});
await this.sandbox.writeFiles([
{
path: '/vercel/sandbox/runner.ts',
content: Buffer.from(`
const moduleRegistry = new Map<string, any>();
export async function executeTask(code: string, activeModules: string[]) {
for (const mod of activeModules) {
if (!moduleRegistry.has(mod)) {
const loaded = await import(\`./modules/\${mod}.js\`);
moduleRegistry.set(mod, loaded);
}
}
const asyncFn = new Function('registry', 'console', \`
return (async () => {
\${code}
})();
\`);
return await asyncFn(moduleRegistry, console);
}
`),
},
]);
}
public async dispatchExecution(request: OrchestrationRequest) {
if (!this.sandbox) throw new Error('Orchestrator not initialized');
const executionScript = `
import { executeTask } from './runner.js';
executeTask(${JSON.stringify(request.generatedCode)}, ${JSON.stringify(request.targetModules)})
.then(result => console.log('__RESULT__:' + JSON.stringify(result)))
.catch(err => console.error('__ERROR__:' + err.message));
`;
await this.sandbox.writeFiles([
{
path: '/vercel/sandbox/exec-spec.ts',
content: Buffer.from(executionScript),
},
]);
const commandResult = await this.sandbox.runCommand({
cmd: 'npx',
args: ['tsx', '/vercel/sandbox/exec-spec.ts'],
});
const stdout = await commandResult.stdout();
const stderr = await commandResult.stderr();
if (commandResult.exitCode !== 0) {
throw new Error(`Execution failed: ${stderr}`);
}
const resultMatch = stdout.match(/__RESULT__:(.*)/);
return resultMatch ? JSON.parse(resultMatch[1]) : stdout;
}
}
`
Blocking External Traffic and Protecting API Keys
The worst-case scenario when executing third-party code on your server is an attacker scanning your internal network or exfiltrating tokens to an external C2 server. Even though Vercel Sandbox runs under a non-root vercel-sandbox account by default, you shouldn't rely on that alone. You must lock the door at the network perimeter.
`
[Step-by-Step Network Firewall Transition Flow]
Package Installation Phase
[Sandbox] ---> (SNI Allow: registry.npmjs.org) ---> [NPM Registry]
Code Execution & LLM Call Phase
[Sandbox] ---> (SNI Allow: ai-gateway.vercel.sh + Header Injection) ---> [AI Gateway]
- Actual API Secret Keys do not exist inside the sandbox
Output Transfer & Session Teardown Phase
[Sandbox] ---> (S3/R2 File Upload) ---> [rm -rf File Deletion] ---> (Apply deny-all before destruction)
`
Dynamic Egress Control: Allow only registry.npmjs.org when installing npm packages, then switch policy using sandbox.updateNetworkPolicy() during execution to permit only prepared Gateway domains.
Hide API Keys: Storing OpenAI or Anthropic keys directly in the sandbox environment variables is an open invitation for exploitation. Inject the x-api-key header at the firewall boundary so the inside of the container remains completely unaware of the keys' existence.
Extract Files and Destroy: Upload resulting videos or files to Cloudflare R2 or S3, purge local data with rm -rf, apply a deny-all policy, and shut down the instance.
`typescript
import { Sandbox } from '@vercel/sandbox';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
export class SecureSandboxRunner {
private s3Client: S3Client;
constructor() {
this.s3Client = new S3Client({
region: 'auto',
endpoint: https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
}
public async runUntrustedPipeline(userCode: string, tenantId: string) {
const sandbox = await Sandbox.create({
runtime: 'node24',
timeout: 5 * 60 * 1000,
networkPolicy: {
allow: ['registry.npmjs.org'],
},
});
try {
await sandbox.updateNetworkPolicy({
allow: {
'ai-gateway.vercel.sh': [
{
transform: [
{
headers: {
'x-api-key': process.env.AI_GATEWAY_API_KEY!,
},
},
],
},
],
},
});
await sandbox.writeFiles([
{ path: '/vercel/sandbox/index.js', content: Buffer.from(userCode) },
]);
const execResult = await sandbox.runCommand('node', ['/vercel/sandbox/index.js']);
if (execResult.exitCode !== 0) {
throw new Error(`Execution error: ${await execResult.stderr()}`);
}
const mediaBuffer = await sandbox.fs.readFile('/vercel/sandbox/output.mp4');
const storageKey = `tenants/${tenantId}/exports/${Date.now()}_output.mp4`;
await this.s3Client.send(
new PutObjectCommand({
Bucket: process.env.R2_BUCKET_NAME!,
Key: storageKey,
Body: mediaBuffer,
ContentType: 'video/mp4',
})
);
await sandbox.runCommand('rm', ['-rf', '/vercel/sandbox/*']);
return {
success: true,
mediaUrl: `https://${process.env.R2_PUBLIC_DOMAIN}/${storageKey}`,
};
} finally {
await sandbox.updateNetworkPolicy('deny-all');
await sandbox.stop();
}
}
}
`
Avoiding OOM Crashes During Remotion Video Rendering
Vercel Sandbox Pro provides 2GB RAM per vCPU. An 8 vCPU configuration offers 16GB, which seems generous. However, launching Headless Chromium via Remotion inside the sandbox and running FFmpeg quickly fills frame buffers, triggering the Linux OOM Killer. This is why processes suddenly terminate with SIGKILL mid-render.
To prevent this, abandon greedy full-video rendering in favor of chunked, parallel processing.
- Limit Cache Size: Set
offthreadVideoCacheSizeInBytes to 512MB or lower, and cap concurrency at or below the number of CPU cores.
- Split into Chunks: Break an 1,800-frame video into 150-frame segments and distribute them across multiple sandboxes. Save temporary files in
.ts (h264-ts) format to simplify stitching later.
- Orchestrate via BullMQ & Merge: Manage concurrency using a Redis-backed job queue. Once all chunks complete, combine them without re-encoding using
@remotion/renderer's combineChunks().
`typescript
import { Queue, Worker, Job } from 'bullmq';
import { Sandbox } from '@vercel/sandbox';
import { combineChunks } from '@remotion/renderer';
import Redis from 'ioredis';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
const connection = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null,
});
export interface RenderChunkJobData {
compositionId: string;
serveUrl: string;
frameRange: [number, number];
chunkIndex: number;
totalChunks: number;
fps: number;
}
export const renderQueue = new Queue('video-render-queue', { connection });
export const renderWorker = new Worker(
'video-render-queue',
async (job: Job) => {
const { compositionId, serveUrl, frameRange, chunkIndex } = job.data;
const sandbox = await Sandbox.create({
runtime: 'node24',
timeout: 10 * 60 * 1000,
resources: { vcpus: 4 },
});
try {
const renderScript = `
import { renderMedia } from '@remotion/renderer';
async function run() {
await renderMedia({
composition: ${JSON.stringify({ id: compositionId })},
serveUrl: ${JSON.stringify(serveUrl)},
outputLocation: '/vercel/sandbox/chunk_${chunkIndex}.ts',
frameRange: ${JSON.stringify(frameRange)},
codec: 'h264-ts',
audioCodec: 'pcm-16',
enforceAudioTrack: true,
concurrency: 2,
offthreadVideoCacheSizeInBytes: 512 * 1024 * 1024,
});
}
run().catch(err => { console.error(err); process.exit(1); });
`;
await sandbox.writeFiles([
{ path: '/vercel/sandbox/render-chunk.ts', content: Buffer.from(renderScript) },
]);
const result = await sandbox.runCommand('npx', ['tsx', '/vercel/sandbox/render-chunk.ts']);
if (result.exitCode !== 0) {
throw new Error(`Chunk ${chunkIndex} failed: ${await result.stderr()}`);
}
const chunkBuffer = await sandbox.fs.readFile(`/vercel/sandbox/chunk_${chunkIndex}.ts`);
return { chunkIndex, chunkData: chunkBuffer.toString('base64') };
} finally {
await sandbox.stop();
}
},
{
connection,
concurrency: 4,
limiter: {
max: 10,
duration: 1000,
},
}
);
export async function orchestrateParallelRender(
compositionId: string,
serveUrl: string,
totalFrames: number,
fps: number
): Promise {
const framesPerChunk = 150;
const chunkPromises = [];
const totalChunks = Math.ceil(totalFrames / framesPerChunk);
for (let i = 0; i < totalChunks; i++) {
const startFrame = i * framesPerChunk;
const endFrame = Math.min((i + 1) * framesPerChunk - 1, totalFrames - 1);
const job = await renderQueue.add(`chunk-${i}`, {
compositionId,
serveUrl,
frameRange: [startFrame, endFrame],
chunkIndex: i,
totalChunks,
fps,
});
chunkPromises.push(job.waitUntilFinished(renderWorker.opts.queueEvents!));
}
const results = await Promise.all(chunkPromises);
results.sort((a, b) => a.chunkIndex - b.chunkIndex);
const workDir = join(process.cwd(), tmp_render_${Date.now()});
mkdirSync(workDir, { recursive: true });
const videoFiles: string[] = [];
results.forEach((res, idx) => {
const filePath = join(workDir, chunk_${idx}.ts);
writeFileSync(filePath, Buffer.from(res.chunkData, 'base64'));
videoFiles.push(filePath);
});
const finalOutputPath = join(workDir, 'final_output.mp4');
await combineChunks({
videoFiles,
audioFiles: [],
outputLocation: finalOutputPath,
codec: 'h264',
fps,
framesPerChunk,
compositionDurationInFrames: totalFrames,
});
return finalOutputPath;
}
`
Vercel Sandbox is convenient, but staying unmindful can leave you vulnerable to high infrastructure costs and security risks. Start by cutting server resource usage with snapshot-based caching and preventing prompt waste with an orchestrator.