Deep Dive! Using Deno and Bun in Production Environments

Past the benchmark charts and feature matrices — here's what Deno's permission system and Bun's native APIs look like when you build something real with them. The gotchas, the sharp edges, and the parts that are actually better than Node.

The documentation for Deno and Bun both make their runtimes sound clean, fast, and easy. And to be fair, they mostly are. But the gap between "this works in a tutorial" and "this works when my team is deploying it on Friday" is where the useful information lives.

This article gets into the technical details of both runtimes: Deno's permission system internals, Deno KV, building with Bun's native APIs, Bun's bundler, the SQLite driver, and what you actually hit when you try to run something real on each.

Deno: Deeper Than the Permissions Flag

How the Permission System Works Internally

Deno's permission system isn't a polite suggestion. It's enforced at the system call level through Deno's Rust runtime. When your TypeScript code calls Deno.readTextFile(), the runtime checks the granted permissions before the file system call ever reaches the OS. Denied operations throw a PermissionDenied error with a message that tells you exactly which flag to add.

You can check permissions programmatically, which matters for writing scripts that need to behave differently based on what they've been granted.

// Check if we have read access before trying to use it
const readPermission = await Deno.permissions.query({ name: "read", path: "./data" });

if (readPermission.state === "granted") {
  const data = await Deno.readTextFile("./data/config.json");
  // ...
} else if (readPermission.state === "prompt") {
  // User will be prompted interactively
  const result = await Deno.permissions.request({ name: "read", path: "./data" });
  if (result.state === "granted") {
    const data = await Deno.readTextFile("./data/config.json");
  }
} else {
  // Denied — handle gracefully
  console.warn("Running without data directory access");
}

The "prompt" state appears when running interactively and the user hasn't been asked yet. In a non-interactive environment (CI, production servers), prompts fail immediately. Add the flags.

Permission Scoping in Practice

The --allow-* flags accept comma-separated lists for fine-grained scoping.

deno run \
  --allow-read=./config,./templates \
  --allow-write=./output \
  --allow-net=api.stripe.com,api.github.com \
  --allow-env=DATABASE_URL,JWT_SECRET,PORT \
  app.ts

This script can read from ./config and ./templates, write to ./output, make HTTP requests to Stripe and GitHub APIs, and read three specific environment variables. Nothing else.

A compromised dependency in this script can exfiltrate your SSH keys only if it first bypasses the OS process boundary — not just the JavaScript layer.

For scripts that handle sensitive operations (deployment scripts, credential rotation, data export), this model has real value. You can audit what a script can do from the command that runs it, not by reading all the code.

Deno's Standard Library

The Deno standard library lives at https://deno.land/std and is maintained by the Deno team. It covers the utilities Node.js ships with C bindings for: path manipulation, file system utilities, HTTP servers, crypto, data structures, date formatting, and more.

import { join, dirname, fromFileUrl } from "https://deno.land/[email protected]/path/mod.ts";
import { ensureDir, copy, exists } from "https://deno.land/[email protected]/fs/mod.ts";
import { parse as parseArgs } from "https://deno.land/[email protected]/flags/mod.ts";
import { crypto } from "https://deno.land/[email protected]/crypto/mod.ts";

// Get the directory of the current script
const __dirname = dirname(fromFileUrl(import.meta.url));

async function buildProject() {
  const args = parseArgs(Deno.args, {
    string: ["output"],
    default: { output: "./dist" },
  });

  const outputDir = join(__dirname, args.output);
  await ensureDir(outputDir);

  // Copy static assets
  if (await exists("./public")) {
    await copy("./public", join(outputDir, "public"), { overwrite: true });
  }
}

await buildProject();

Pin the version number in the URL (@0.220.0). Unpinned imports pull the latest version, which changes.

Deno KV In Depth

Deno KV is a key-value store with ACID transactions, built into the runtime. Locally it uses SQLite. On Deno Deploy it uses a distributed system backed by FoundationDB.

The data model uses composite keys as arrays. This lets you model hierarchical data.

const kv = await Deno.openKv();

// Data model: users have sessions
// Keys:
//   ["users", userId]         → user record
//   ["sessions", sessionId]   → session record
//   ["user_sessions", userId, sessionId] → index entry

interface User {
  id: string;
  email: string;
  createdAt: number;
}

interface Session {
  id: string;
  userId: string;
  createdAt: number;
  expiresAt: number;
}

async function createSession(user: User): Promise<Session> {
  const session: Session = {
    id: crypto.randomUUID(),
    userId: user.id,
    createdAt: Date.now(),
    expiresAt: Date.now() + (7 * 24 * 60 * 60 * 1000), // 7 days
  };

  // Atomic write: both the session and the index entry, or neither
  const result = await kv.atomic()
    .set(["sessions", session.id], session)
    .set(["user_sessions", user.id, session.id], true)
    .commit();

  if (!result.ok) throw new Error("Failed to create session");
  return session;
}

async function getUserSessions(userId: string): Promise<Session[]> {
  const sessions: Session[] = [];
  
  // List all sessions for a user using the index
  for await (const entry of kv.list({ prefix: ["user_sessions", userId] })) {
    const sessionId = entry.key[2] as string;
    const sessionEntry = await kv.get<Session>(["sessions", sessionId]);
    if (sessionEntry.value) sessions.push(sessionEntry.value);
  }

  return sessions;
}

async function deleteSession(sessionId: string, userId: string): Promise<void> {
  await kv.atomic()
    .delete(["sessions", sessionId])
    .delete(["user_sessions", userId, sessionId])
    .commit();
}

The atomic() transaction guarantees that both writes succeed or neither does. Without this, a crash between two separate kv.set() calls leaves the data in an inconsistent state.

Deno KV also supports setting expiration on values:

// Value auto-deletes after 24 hours
await kv.set(
  ["cache", "weather_nyc"],
  weatherData,
  { expireIn: 24 * 60 * 60 * 1000 }
);

Deno as a Scripting Runtime

Deno's permission model makes it well-suited for system scripts that you want to run with explicit, auditable capabilities.

// backup.ts — database backup script
// Run with: deno run --allow-read=./data --allow-write=./backups --allow-run=pg_dump backup.ts

const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const outputFile = `./backups/backup-${timestamp}.sql`;

console.log(`Creating backup: ${outputFile}`);

const command = new Deno.Command("pg_dump", {
  args: [
    "--host", Deno.env.get("DB_HOST") ?? "localhost",
    "--dbname", Deno.env.get("DB_NAME") ?? "myapp",
    "--file", outputFile,
  ],
  stdout: "piped",
  stderr: "piped",
});

const { code, stderr } = await command.output();

if (code !== 0) {
  const errMsg = new TextDecoder().decode(stderr);
  console.error("Backup failed:", errMsg);
  Deno.exit(1);
}

console.log("Backup complete");

The Deno.Command API replaces Node's child_process.spawn(). It requires --allow-run, and you can scope it: --allow-run=pg_dump restricts subprocess execution to only pg_dump.

Compiling to a Single Binary

Deno can compile your application to a self-contained executable with no Deno runtime required.

deno compile --allow-net --allow-read --output server app.ts
./server  # Runs without Deno installed

The compiled binary includes the Deno runtime and your code. Binaries are large (typically 80-100MB), but for distributing CLI tools or server applications to environments without Deno installed, this is useful.

Bun: Native APIs in Depth

Bun.serve and HTTP

Bun.serve is Bun's built-in HTTP server. It uses uWebSockets under the hood for performance, and supports HTTP/1.1 and WebSockets.

interface AppState {
  requestCount: number;
}

const state: AppState = { requestCount: 0 };

const server = Bun.serve<AppState>({
  port: 3000,
  hostname: "0.0.0.0",

  // WebSocket upgrade handler
  websocket: {
    open(ws) {
      console.log("WebSocket connected");
      ws.subscribe("broadcast");
    },
    message(ws, message) {
      ws.publish("broadcast", message); // Broadcast to all subscribers
    },
    close(ws) {
      console.log("WebSocket disconnected");
    },
  },

  fetch(req, server) {
    state.requestCount++;

    // Upgrade WebSocket connections
    if (req.headers.get("upgrade") === "websocket") {
      if (server.upgrade(req)) return; // Returns undefined on success
      return new Response("WebSocket upgrade failed", { status: 400 });
    }

    const url = new URL(req.url);

    if (url.pathname === "/stats") {
      return Response.json({
        requests: state.requestCount,
        uptime: process.uptime(),
      });
    }

    return new Response("Hello from Bun", {
      headers: { "Content-Type": "text/plain" },
    });
  },
});

console.log(`Listening on ${server.url}`);

WebSocket support is built into Bun.serve. No ws package, no separate server setup.

Bun's File API

The BunFile object is Bun's file representation. It's lazy — it doesn't read the file until you call a method.

// Reading files
const jsonFile = Bun.file("./data.json");
const data = await jsonFile.json(); // Parsed JSON

const textFile = Bun.file("./content.md");
const text = await textFile.text(); // String

const binFile = Bun.file("./image.png");
const bytes = await binFile.arrayBuffer(); // Binary

// File metadata without reading content
const file = Bun.file("./large-file.csv");
console.log(file.size);     // File size in bytes
console.log(file.type);     // MIME type
console.log(file.lastModified); // Last modified timestamp

// Streaming large files
const stream = Bun.file("./large.csv").stream();
const reader = stream.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // Process chunk
}

// Writing
await Bun.write("./output.json", JSON.stringify(data, null, 2));
await Bun.write("./copy.png", Bun.file("./original.png")); // File copy
await Bun.write("./response.html", await fetch("https://example.com")); // Write response body

Bun.write handles strings, ArrayBuffer, TypedArray, BunFile, and Response objects. For copying files or saving API responses to disk, no intermediate variable or stream setup needed.

SQLite Deep Dive

bun:sqlite is Bun's built-in SQLite driver. It's a thin, fast wrapper around SQLite3 with a query builder API.

import { Database, Statement } from "bun:sqlite";

// Open or create a database
const db = new Database("app.db", { create: true, strict: true });

// WAL mode for better concurrent read performance
db.run("PRAGMA journal_mode = WAL");
db.run("PRAGMA foreign_keys = ON");
db.run("PRAGMA synchronous = NORMAL");

// Schema
db.run(`
  CREATE TABLE IF NOT EXISTS posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    slug TEXT UNIQUE NOT NULL,
    content TEXT NOT NULL,
    author_id INTEGER NOT NULL REFERENCES users(id),
    published_at INTEGER,
    created_at INTEGER NOT NULL DEFAULT (unixepoch())
  )
`);

// Prepared statements (reusable, safe against SQL injection)
const statements = {
  insert: db.prepare<void, [string, string, string, number]>(
    "INSERT INTO posts (title, slug, content, author_id) VALUES (?, ?, ?, ?)"
  ),
  
  getBySlug: db.prepare<Post, [string]>(
    "SELECT * FROM posts WHERE slug = ? AND published_at IS NOT NULL"
  ),
  
  listPublished: db.prepare<Post, []>(
    "SELECT id, title, slug, published_at FROM posts WHERE published_at IS NOT NULL ORDER BY published_at DESC"
  ),
  
  publish: db.prepare<void, [number, number]>(
    "UPDATE posts SET published_at = ? WHERE id = ?"
  ),
};

interface Post {
  id: number;
  title: string;
  slug: string;
  content: string;
  author_id: number;
  published_at: number | null;
  created_at: number;
}

// Batch insert inside a transaction
const bulkInsert = db.transaction((posts: Array<{ title: string; slug: string; content: string; authorId: number }>) => {
  for (const post of posts) {
    statements.insert.run(post.title, post.slug, post.content, post.authorId);
  }
  return posts.length;
});

const count = bulkInsert([
  { title: "Hello World", slug: "hello-world", content: "...", authorId: 1 },
  { title: "Second Post", slug: "second-post", content: "...", authorId: 1 },
]);

console.log(`Inserted ${count} posts`);

// Query a single row
const post = statements.getBySlug.get("hello-world");
if (post) {
  console.log(post.title);
}

// Query multiple rows
const posts = statements.listPublished.all();
posts.forEach(p => console.log(p.title));

The type parameters on db.prepare<ReturnType, ParamsType>() let TypeScript infer return types correctly. get() returns a single row or null. all() returns an array.

WAL mode (Write-Ahead Logging) is worth enabling on any SQLite database that will have concurrent reads. It allows reads to proceed while writes are in progress, which matters for web applications.

For understanding when to use SQLite vs a full relational database, the PostgreSQL article covers what you gain from PostgreSQL's concurrent write model, full-text search, JSON support, and connection pooling.

Bun's Environment and Process

// Environment variables
const port = parseInt(Bun.env.PORT ?? "3000", 10);
const nodeEnv = Bun.env.NODE_ENV ?? "development";

// Bun version info
console.log(Bun.version);  // "1.x.x"
console.log(Bun.revision); // git commit hash

// Spawning subprocesses
const proc = Bun.spawnSync(["git", "log", "--oneline", "-5"]);
const output = proc.stdout.toString();
console.log(output);

// Async subprocess
const asyncProc = Bun.spawn(["npm", "pack"], {
  stdout: "pipe",
  stderr: "pipe",
  cwd: "./packages/mylib",
});

const exitCode = await asyncProc.exited;
const stdout = await new Response(asyncProc.stdout).text();

Bun's Bundler

The bundler handles TypeScript, JSX, module resolution, and tree shaking.

// build.ts
const result = await Bun.build({
  entrypoints: ["./src/server.ts"],
  outdir: "./dist",
  target: "bun",
  format: "esm",
  minify: {
    whitespace: true,
    syntax: true,
    identifiers: false, // Keep identifiers readable for debugging
  },
  sourcemap: "external",
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
  external: ["pg", "redis"], // Don't bundle these — keep as runtime requires
  plugins: [
    // Custom plugins follow esbuild's API
  ],
});

if (!result.success) {
  for (const log of result.logs) {
    console.error(log);
  }
  process.exit(1);
}

for (const output of result.outputs) {
  console.log(`Built: ${output.path} (${(output.size / 1024).toFixed(1)}kb)`);
}

The bundler follows esbuild's plugin API, so esbuild plugins work in Bun with minimal modification.

Bun Macros

Bun macros run code at bundle time, not runtime. They evaluate to their return value in the compiled output.

// version.ts — runs at bundle time
export function getVersion(): string {
  return Bun.file("./package.json").json().version;
}
// app.ts — the macro runs during bun build
import { getVersion } from "./version" with { type: "macro" };

const VERSION = getVersion(); // Replaced with the literal string at build time
console.log(`Version: ${VERSION}`);

Macros are useful for baking in build-time constants (version numbers, commit hashes, config values) without a separate code generation step.

Real-World Gotchas

Deno

The npm: prefix adds overhead. Importing from npm:some-package works, but Deno has to resolve the npm package on first run and sometimes generates compatibility shims. Pure Deno or JSR packages are faster.

node_modules doesn't exist. Code that tries to read node_modules directly (some build tools do this) will break. This is rare but has caught teams off guard.

Worker threads use a different API. Deno uses new Worker(url, { type: "module" }) rather than Node's worker_threads. Code that uses Node's worker API needs to be rewritten.

Deno's file watcher (--watch) sometimes misses changes in deeply nested directories or across symlinks. Restart the process if changes seem to not apply.

Bun

Not all node-gyp native addons work. Packages like canvas, sharp (the native build), and some database drivers with native bindings may fail. sharp has a pure WASM build that works. Check before committing.

bun:test has Jest API gaps. jest.mock() module mocking is not fully supported. Code that depends on Jest's module mocking strategy needs reworking or an alternative.

The binary lockfile (bun.lockb) isn't human-readable. You can view it with bun lockb to get JSON output. In pull request diffs, the binary file shows as changed but you can't review the contents inline.

Bun runs TypeScript without type checking. bun app.ts strips types and runs. It doesn't type-check. Use tsc --noEmit or bun tsc in CI to catch type errors. If you skip this, you get TypeScript's syntax benefits but not its safety benefits.

Choosing Between Them for a Production Project

For a new web API, my current preference is Bun with Elysia or Hono for routing. The development experience is fast, TypeScript is native, the built-in test runner handles most cases, and Node.js compatibility means the npm ecosystem is available.

For scripts that process sensitive data or run with elevated access, Deno's permission model is worth the setup cost. A deployment script, a data export tool, or any script you run with production credentials benefits from the explicit capability declaration.

For serverless and edge functions on Deno Deploy, Deno is the obvious choice. The combination of Deno KV, edge distribution, and the familiar runtime makes it competitive with Cloudflare Workers.

For teams currently on Node.js, Bun as a drop-in for development tooling (package installation, test running, TypeScript execution) with Node in production is the lowest-friction path to faster development cycles.

Further Reading