
Introduction
JavaScript runtimes have evolved significantly over the years, and developers now expect better security, simpler tooling, and native TypeScript support. Deno was created by Ryan Dahl, the original creator of Node.js, to address many of the design limitations found in earlier runtimes while keeping modern developer workflows in mind. In this comprehensive guide, you will learn what Deno is, why it was built, how it differs from Node.js, and when it makes sense to use it in real-world projects. By the end, you will have a clear understanding of Deno’s strengths, its permission system, built-in tooling, and practical examples for building production-ready applications.
Why Deno Was Created
In a famous 2018 JSConf talk titled “10 Things I Regret About Node.js,” Ryan Dahl outlined design decisions in Node.js that proved problematic over time. These included the lack of security sandboxing, the complexity of the module system, and the dependence on npm and node_modules. Deno was designed from scratch to avoid these pitfalls.
• Secure by default with explicit permissions for file system, network, and environment access
• Native TypeScript support without extra tooling or compilation steps
• Built-in formatting, linting, and testing tools eliminating third-party dependencies
• Standardized module system using URLs instead of package.json and node_modules
• Modern web APIs aligned with browser standards like fetch, WebSocket, and Web Workers
• Single executable with everything bundled for simple distribution
Because of these design choices, Deno offers a cleaner, safer, and more modern runtime for JavaScript and TypeScript workloads. It represents a fundamental rethinking of what a JavaScript runtime should be in the 2020s.
Getting Started with Deno
Installing Deno is straightforward. The runtime is distributed as a single executable that you can install through various methods.
Installation
# macOS/Linux using shell
curl -fsSL https://deno.land/install.sh | sh
# Windows using PowerShell
irm https://deno.land/install.ps1 | iex
# Using Homebrew (macOS)
brew install deno
# Using Cargo (Rust)
cargo install deno --locked
# Verify installation
deno --version
# deno 2.x.x (release, aarch64-apple-darwin)
# v8 12.x.x
# typescript 5.x.x
Deno bundles V8 (the JavaScript engine) and TypeScript compiler, so you get everything you need in a single download.
Your First Deno Script
Create a file called hello.ts and run it directly.
// hello.ts
interface Greeting {
message: string;
timestamp: Date;
}
function createGreeting(name: string): Greeting {
return {
message: `Hello, ${name}! Welcome to Deno.`,
timestamp: new Date()
};
}
const greeting = createGreeting("Developer");
console.log(greeting.message);
console.log(`Created at: ${greeting.timestamp.toISOString()}`);
# Run the TypeScript file directly
deno run hello.ts
# Output:
# Hello, Developer! Welcome to Deno.
# Created at: 2025-01-06T10:30:00.000Z
Notice that you don’t need to compile TypeScript or configure anything. Deno handles it automatically.
Deno Permission System
Security is Deno’s most distinguishing feature. By default, scripts run in a sandbox without access to the file system, network, environment variables, or subprocess spawning. You must explicitly grant permissions.
Permission Flags
# No permissions (sandboxed)
deno run app.ts
# Allow network access
deno run --allow-net app.ts
# Allow specific domains only
deno run --allow-net=api.example.com,deno.land app.ts
# Allow file system read access
deno run --allow-read app.ts
# Allow read access to specific paths
deno run --allow-read=/tmp,./config app.ts
# Allow file system write access
deno run --allow-write=./output app.ts
# Allow environment variable access
deno run --allow-env app.ts
# Allow specific environment variables
deno run --allow-env=DATABASE_URL,API_KEY app.ts
# Allow subprocess spawning
deno run --allow-run app.ts
# Allow high-resolution time measurement
deno run --allow-hrtime app.ts
# Allow FFI (Foreign Function Interface)
deno run --allow-ffi app.ts
# Allow all permissions (use sparingly)
deno run -A app.ts
Runtime Permission Requests
You can also request permissions at runtime, giving users a chance to approve or deny access.
// permission-demo.ts
async function checkPermissions() {
// Check if we have network permission
const netStatus = await Deno.permissions.query({ name: "net" });
console.log(`Network permission: ${netStatus.state}`);
// Request read permission if not granted
const readStatus = await Deno.permissions.query({
name: "read",
path: "./config"
});
if (readStatus.state !== "granted") {
const request = await Deno.permissions.request({
name: "read",
path: "./config"
});
if (request.state === "granted") {
console.log("Read permission granted!");
const config = await Deno.readTextFile("./config/settings.json");
console.log("Config loaded:", config);
} else {
console.log("Read permission denied");
}
}
}
await checkPermissions();
This permission model reduces the risk of supply chain attacks where malicious dependencies could access sensitive system resources.
Module System and Dependencies
Deno uses URL-based imports instead of a package manager. Dependencies are fetched from URLs and cached locally.
Importing from URLs
// Import from Deno's standard library
import { serve } from "https://deno.land/std@0.220.0/http/server.ts";
import { join } from "https://deno.land/std@0.220.0/path/mod.ts";
// Import from third-party registries
import { Application } from "https://deno.land/x/oak@v12.6.1/mod.ts";
// Import from npm using npm: specifier (Deno 2.0+)
import express from "npm:express@4.18.2";
import _ from "npm:lodash@4.17.21";
// Import from JSR (JavaScript Registry)
import { z } from "jsr:@std/zod@3.22.4";
Dependency Management with deno.json
For larger projects, use deno.json to configure imports and project settings.
// deno.json
{
"name": "my-deno-app",
"version": "1.0.0",
"tasks": {
"dev": "deno run --watch --allow-net --allow-read main.ts",
"start": "deno run --allow-net --allow-read main.ts",
"test": "deno test --allow-read",
"lint": "deno lint",
"fmt": "deno fmt"
},
"imports": {
"@std/http": "jsr:@std/http@0.220.0",
"@std/path": "jsr:@std/path@0.220.0",
"@std/assert": "jsr:@std/assert@0.220.0",
"oak": "https://deno.land/x/oak@v12.6.1/mod.ts",
"zod": "npm:zod@3.22.4"
},
"compilerOptions": {
"strict": true,
"noImplicitAny": true
},
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": true
},
"lint": {
"rules": {
"tags": ["recommended"]
}
}
}
Now you can import using the short names.
// main.ts - using import map
import { serve } from "@std/http";
import { join } from "@std/path";
import { z } from "zod";
Lock Files
Generate a lock file to ensure reproducible builds.
# Generate lock file
deno cache --lock=deno.lock --lock-write main.ts
# Use lock file to verify integrity
deno run --lock=deno.lock main.ts
Building HTTP Servers
Deno provides multiple ways to build HTTP servers, from low-level APIs to full frameworks.
Using Deno.serve (Native API)
// server.ts - Using native Deno.serve
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" }
];
function handleRequest(request: Request): Response {
const url = new URL(request.url);
const method = request.method;
// CORS headers
const headers = new Headers({
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE"
});
// Route handling
if (url.pathname === "/api/users" && method === "GET") {
return new Response(JSON.stringify(users), { headers });
}
if (url.pathname.startsWith("/api/users/") && method === "GET") {
const id = parseInt(url.pathname.split("/")[3]);
const user = users.find(u => u.id === id);
if (user) {
return new Response(JSON.stringify(user), { headers });
}
return new Response(
JSON.stringify({ error: "User not found" }),
{ status: 404, headers }
);
}
if (url.pathname === "/api/health") {
return new Response(
JSON.stringify({ status: "healthy", timestamp: new Date().toISOString() }),
{ headers }
);
}
return new Response(
JSON.stringify({ error: "Not found" }),
{ status: 404, headers }
);
}
const port = parseInt(Deno.env.get("PORT") || "8000");
Deno.serve({ port }, handleRequest);
console.log(`Server running on http://localhost:${port}`);
Using Oak Framework
For more complex applications, Oak provides Express-like middleware and routing.
// oak-server.ts
import { Application, Router, Context } from "https://deno.land/x/oak@v12.6.1/mod.ts";
import { z } from "npm:zod@3.22.4";
// Validation schemas
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(["user", "admin"]).default("user")
});
const UpdateUserSchema = CreateUserSchema.partial();
// In-memory store
interface User {
id: string;
name: string;
email: string;
role: string;
createdAt: Date;
}
const users = new Map();
// Router
const router = new Router();
// List all users
router.get("/api/users", (ctx: Context) => {
ctx.response.body = Array.from(users.values());
});
// Get user by ID
router.get("/api/users/:id", (ctx: Context) => {
const id = ctx.params.id;
const user = users.get(id!);
if (!user) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
ctx.response.body = user;
});
// Create user
router.post("/api/users", async (ctx: Context) => {
try {
const body = await ctx.request.body.json();
const validated = CreateUserSchema.parse(body);
const id = crypto.randomUUID();
const user: User = {
id,
...validated,
createdAt: new Date()
};
users.set(id, user);
ctx.response.status = 201;
ctx.response.body = user;
} catch (error) {
if (error instanceof z.ZodError) {
ctx.response.status = 400;
ctx.response.body = {
error: "Validation failed",
details: error.errors
};
} else {
throw error;
}
}
});
// Update user
router.put("/api/users/:id", async (ctx: Context) => {
const id = ctx.params.id;
const existing = users.get(id!);
if (!existing) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
try {
const body = await ctx.request.body.json();
const validated = UpdateUserSchema.parse(body);
const updated = { ...existing, ...validated };
users.set(id!, updated);
ctx.response.body = updated;
} catch (error) {
if (error instanceof z.ZodError) {
ctx.response.status = 400;
ctx.response.body = { error: "Validation failed", details: error.errors };
} else {
throw error;
}
}
});
// Delete user
router.delete("/api/users/:id", (ctx: Context) => {
const id = ctx.params.id;
if (!users.has(id!)) {
ctx.response.status = 404;
ctx.response.body = { error: "User not found" };
return;
}
users.delete(id!);
ctx.response.status = 204;
});
// Application setup
const app = new Application();
// Logger middleware
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.request.method} ${ctx.request.url.pathname} - ${ctx.response.status} (${ms}ms)`);
});
// Error handler middleware
app.use(async (ctx, next) => {
try {
await next();
} catch (error) {
console.error("Unhandled error:", error);
ctx.response.status = 500;
ctx.response.body = { error: "Internal server error" };
}
});
app.use(router.routes());
app.use(router.allowedMethods());
const port = 8000;
console.log(`Oak server running on http://localhost:${port}`);
await app.listen({ port });
Built-In Developer Tooling
One of Deno’s biggest advantages is its integrated tooling. Instead of relying on third-party packages, Deno provides essential tools out of the box.
Code Formatting
# Format all files
deno fmt
# Format specific files
deno fmt src/main.ts src/utils.ts
# Check formatting without modifying
deno fmt --check
# Ignore files or directories
deno fmt --ignore=vendor,dist
Linting
# Lint all files
deno lint
# Lint specific files
deno lint src/
# Show available rules
deno lint --rules
# Use specific rules
deno lint --rules-exclude=no-explicit-any
Testing
Deno has a built-in test runner with support for async tests, filtering, and coverage.
// user.test.ts
import { assertEquals, assertThrows, assertRejects } from "@std/assert";
import { describe, it, beforeEach } from "@std/testing/bdd";
interface User {
id: string;
name: string;
email: string;
}
class UserService {
private users = new Map();
create(name: string, email: string): User {
if (!name || name.length < 2) {
throw new Error("Name must be at least 2 characters");
}
if (!email.includes("@")) {
throw new Error("Invalid email format");
}
const user: User = {
id: crypto.randomUUID(),
name,
email
};
this.users.set(user.id, user);
return user;
}
findById(id: string): User | undefined {
return this.users.get(id);
}
delete(id: string): boolean {
return this.users.delete(id);
}
async findByIdAsync(id: string): Promise {
await new Promise(resolve => setTimeout(resolve, 10));
const user = this.users.get(id);
if (!user) {
throw new Error("User not found");
}
return user;
}
}
describe("UserService", () => {
let service: UserService;
beforeEach(() => {
service = new UserService();
});
describe("create", () => {
it("should create a user with valid data", () => {
const user = service.create("Alice", "alice@example.com");
assertEquals(user.name, "Alice");
assertEquals(user.email, "alice@example.com");
assertEquals(typeof user.id, "string");
});
it("should throw error for short name", () => {
assertThrows(
() => service.create("A", "a@example.com"),
Error,
"Name must be at least 2 characters"
);
});
it("should throw error for invalid email", () => {
assertThrows(
() => service.create("Alice", "invalid-email"),
Error,
"Invalid email format"
);
});
});
describe("findById", () => {
it("should find existing user", () => {
const created = service.create("Bob", "bob@example.com");
const found = service.findById(created.id);
assertEquals(found, created);
});
it("should return undefined for non-existent user", () => {
const found = service.findById("non-existent-id");
assertEquals(found, undefined);
});
});
describe("findByIdAsync", () => {
it("should resolve with user for existing id", async () => {
const created = service.create("Charlie", "charlie@example.com");
const found = await service.findByIdAsync(created.id);
assertEquals(found, created);
});
it("should reject for non-existent user", async () => {
await assertRejects(
() => service.findByIdAsync("non-existent-id"),
Error,
"User not found"
);
});
});
});
# Run all tests
deno test
# Run with permissions
deno test --allow-read --allow-net
# Run specific test file
deno test user.test.ts
# Run tests matching pattern
deno test --filter "create"
# Generate coverage report
deno test --coverage=coverage/
deno coverage coverage/ --lcov > coverage.lcov
# Watch mode
deno test --watch
Documentation Generation
// Generate docs from JSDoc comments
/**
* Represents a user in the system.
* @example
* ```ts
* const user = createUser("Alice", "alice@example.com");
* console.log(user.id);
* ```
*/
export interface User {
/** Unique identifier */
id: string;
/** User's display name */
name: string;
/** User's email address */
email: string;
}
/**
* Creates a new user with the given details.
* @param name - The user's name (minimum 2 characters)
* @param email - The user's email address
* @returns A new User object
* @throws {Error} If name or email is invalid
*/
export function createUser(name: string, email: string): User {
return {
id: crypto.randomUUID(),
name,
email
};
}
# Generate HTML documentation
deno doc --html --output=docs mod.ts
# View documentation in terminal
deno doc mod.ts
# View documentation for specific symbol
deno doc mod.ts createUser
Working with Files and Environment
Deno provides modern async APIs for file system operations.
// file-operations.ts
import { join, dirname, basename } from "@std/path";
import { ensureDir, exists } from "https://deno.land/std@0.220.0/fs/mod.ts";
// Reading files
const content = await Deno.readTextFile("./config.json");
const config = JSON.parse(content);
console.log("Config loaded:", config);
// Reading binary files
const imageData = await Deno.readFile("./image.png");
console.log(`Image size: ${imageData.length} bytes`);
// Writing files
const data = { users: [], lastUpdated: new Date().toISOString() };
await Deno.writeTextFile(
"./output/data.json",
JSON.stringify(data, null, 2)
);
// Creating directories
await ensureDir("./output/logs");
// Check if file exists
if (await exists("./config.local.json")) {
console.log("Local config found");
}
// List directory contents
for await (const entry of Deno.readDir("./src")) {
console.log(`${entry.isDirectory ? "[DIR]" : "[FILE]"} ${entry.name}`);
}
// Get file info
const fileInfo = await Deno.stat("./main.ts");
console.log(`Size: ${fileInfo.size} bytes`);
console.log(`Modified: ${fileInfo.mtime}`);
// Watch for file changes
const watcher = Deno.watchFs("./src");
console.log("Watching for changes...");
for await (const event of watcher) {
console.log(`${event.kind}: ${event.paths.join(", ")}`);
}
Environment Variables
// env.ts
// Get environment variable
const databaseUrl = Deno.env.get("DATABASE_URL");
const port = parseInt(Deno.env.get("PORT") || "3000");
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required");
Deno.exit(1);
}
// Get all environment variables
const allEnv = Deno.env.toObject();
console.log(`Running with ${Object.keys(allEnv).length} environment variables`);
// Set environment variable (runtime only)
Deno.env.set("APP_MODE", "development");
Comparing Deno and Node.js
Although both runtimes serve JavaScript developers, their philosophies differ significantly.
Deno Advantages
• Strong security model by default with granular permissions
• Native TypeScript execution without build steps
• No node_modules directory or complex package resolution
• Modern standard library with consistent APIs
• Built-in tooling for formatting, linting, testing, and bundling
• Web standard APIs like fetch, Request, Response, URL
• Single executable for easy deployment
• Top-level await supported by default
Node.js Advantages
• Massive ecosystem with over 2 million npm packages
• Mature tooling and extensive community support
• Broad compatibility with existing libraries and frameworks
• Proven stability in large production systems
• More hosting options and platform support
• Better IDE support and debugging tools
Feature Comparison Table
| Feature | Deno | Node.js |
|------------------------|-------------------------|-------------------------|
| TypeScript | Native | Requires transpiler |
| Security | Sandboxed by default | Full access by default |
| Package Manager | URL imports / JSR | npm / yarn / pnpm |
| Standard Library | Comprehensive | Minimal |
| Testing | Built-in | Jest/Mocha/Vitest |
| Formatting | Built-in | Prettier |
| Linting | Built-in | ESLint |
| Bundle Size | Can compile to single | Requires bundler |
| npm Compatibility | npm: specifier | Native |
When Should You Use Deno?
Deno is a strong choice when you need:
• TypeScript-first development without build configuration
• Secure execution environments where untrusted code may run
• Lightweight backend services and microservices
• CLI tools and automation scripts that need to be distributed
• Modern APIs aligned with web standards
• Edge computing and serverless functions
• New projects without legacy Node.js dependencies
However, Deno may not be ideal if:
• Your project depends heavily on Node.js-specific libraries
• You’re working with a large existing codebase in Node.js
• You need specific npm packages without Deno alternatives
• Your team is deeply invested in Node.js tooling
Deno Deploy and Production
Deno Deploy is a globally distributed platform for running Deno applications at the edge.
// deploy-example.ts - Ready for Deno Deploy
Deno.serve((request: Request) => {
const url = new URL(request.url);
if (url.pathname === "/") {
return new Response("Hello from Deno Deploy!", {
headers: { "Content-Type": "text/plain" }
});
}
if (url.pathname === "/api/time") {
return Response.json({
timestamp: new Date().toISOString(),
region: Deno.env.get("DENO_REGION") || "local"
});
}
return new Response("Not Found", { status: 404 });
});
Deploy with a single command or connect your GitHub repository for automatic deployments.
# Install deployctl
deno install -A jsr:@deno/deployctl
# Deploy to Deno Deploy
deployctl deploy --project=my-app main.ts
Common Mistakes to Avoid
When working with Deno, developers often encounter these pitfalls.
1. Using –allow-all in Production
// ❌ Bad: Grants all permissions, defeating the security model
deno run -A server.ts
// ✅ Good: Grant only required permissions
deno run --allow-net=0.0.0.0:8000 --allow-read=./public --allow-env=DATABASE_URL server.ts
2. Not Pinning Dependency Versions
// ❌ Bad: No version pinning, can break unexpectedly
import { serve } from "https://deno.land/std/http/server.ts";
// ✅ Good: Pin to specific version
import { serve } from "https://deno.land/std@0.220.0/http/server.ts";
3. Ignoring Lock Files
# ❌ Bad: No integrity checking
deno run main.ts
# ✅ Good: Use lock file for reproducible builds
deno cache --lock=deno.lock --lock-write main.ts
deno run --lock=deno.lock main.ts
4. Mixing Node and Deno APIs
// ❌ Bad: Using Node.js patterns in Deno
const fs = require("fs"); // CommonJS doesn't work
// ✅ Good: Use Deno native APIs
const content = await Deno.readTextFile("./file.txt");
// ✅ Also good: Use npm specifier if you need Node compatibility
import fs from "node:fs/promises";
5. Not Handling Permission Denials
// ❌ Bad: Crashes if permission denied
const data = await Deno.readTextFile("./config.json");
// ✅ Good: Handle permission errors gracefully
try {
const data = await Deno.readTextFile("./config.json");
return JSON.parse(data);
} catch (error) {
if (error instanceof Deno.errors.PermissionDenied) {
console.error("Permission denied. Run with --allow-read=./config.json");
return getDefaultConfig();
}
throw error;
}
6. Blocking the Event Loop
// ❌ Bad: Synchronous file read blocks event loop
const data = Deno.readTextFileSync("./large-file.txt");
// ✅ Good: Use async APIs
const data = await Deno.readTextFile("./large-file.txt");
// ✅ Even better: Stream large files
const file = await Deno.open("./large-file.txt");
for await (const chunk of file.readable) {
processChunk(chunk);
}
Conclusion
Deno introduces a modern, secure, and TypeScript-friendly approach to running JavaScript outside the browser. With built-in tooling for formatting, linting, and testing, a strong permission model that protects against supply chain attacks, and alignment with web standards, it offers a compelling alternative to traditional runtimes. The single executable distribution, native TypeScript support, and URL-based module system eliminate much of the configuration overhead that plagues modern JavaScript development.
While Node.js remains dominant in ecosystem size and enterprise adoption, Deno excels in scenarios requiring security, simplicity, and modern APIs. As the JavaScript ecosystem continues to evolve, Deno represents a thoughtful reimagining of what server-side JavaScript can be.
If you want to explore modern JavaScript infrastructure, read CI/CD for Node.js Projects Using GitHub Actions. For backend framework comparisons, see Framework Showdown: Flask vs FastAPI vs Django in 2025. To learn about functional programming patterns applicable to both Node.js and Deno, check out Functional Programming Techniques in JavaScript. You can also learn more from the Deno documentation, the Deno Standard Library, and the JavaScript Registry (JSR). With the right use case, Deno can significantly simplify and modernize your JavaScript and TypeScript workflows.