Build Ultra-Fast REST APIs with Bun and ElysiaJS: Type-Safe Validation, Eden Treaty, and Production Deployment

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Approach Comparison: Node.js + Express vs. Bun + ElysiaJS

For over a decade, spinning up a JavaScript backend meant reaching for Node.js paired with Express or Fastify. That stack still powers countless production systems. However, modern projects often turn into a fragile puzzle of separate tools for TypeScript transpilation (tsx/esbuild), environment loading (dotenv), test runners (jest/vitest), and schema validators (zod).

Bun collapses these separate tools into a single native binary powered by JavaScriptCore. When you combine Bun with ElysiaJS—a web framework engineered specifically around Bun’s HTTP primitives—you get sub-15ms cold starts, native TypeScript execution, and static type inference. In synthetic benchmarks and real-world I/O workloads alike, Elysia routinely handles over 150,000 requests per second with less than 60MB of baseline memory, compared to Express hovering around 20,000 req/s.

Pros and Cons of Switching to Bun and ElysiaJS

Pros

  • Raw Throughput & Low Latency: Bun executes HTTP handling via low-level C++ bindings, yielding up to 4x to 8x higher requests per second than Express.
  • Zero-Config TypeScript: Execute .ts and .tsx files directly without wrestling with tsconfig.json path mappers or build steps.
  • Native TypeBox Validation: Schema compilation runs through TypeBox JIT compilation, validating payloads up to 100x faster than standard runtime libraries like Joi.
  • End-to-End Type Safety with Eden Treaty: Export your server type definition and consume fully typed API endpoints on your frontend, complete with autocomplete and compile-time error checks.

Cons

  • Node.js Edge-Case Compatibility: While Bun supports most npm packages, legacy libraries that rely on undocumented V8 internals or older C++ native addons can still hit edge-case bugs.
  • Middleware Ecosystem: The Elysia plugin ecosystem covers auth, OpenAPI, CORS, and rate limiting, but niche third-party plugins remain smaller than the decade-old Express catalog.

Recommended Setup and Prerequisites

You only need Bun installed locally to follow along. Bun provides official binaries for macOS, Linux, and Windows Subsystem for Linux (WSL).

# Install Bun on Linux / macOS
curl -fsSL https://bun.sh/install | bash

# Verify your installation (version 1.1 or later recommended)
bun --version

For code editing, Visual Studio Code with the official Elysia and Tailwind/TypeScript extensions offers seamless auto-completion and inline schema diagnostics.

Step-by-Step Implementation Guide

Step 1: Initialize the Project

Set up a clean project directory and install the core dependencies. Bun installs packages in roughly 50 to 200 milliseconds using global hard links:

mkdir bun-elysia-api
cd bun-elysia-api
bun init -y
bun add elysia @elysiajs/cors @elysiajs/swagger

Step 2: Build the Core Server and REST Endpoints

Create an index.ts file. We will define an in-memory product catalog with typed GET and POST routes, utilizing Elysia’s schema builder (t) for instant payload validation.

import { Elysia, t } from 'elysia';
import { swagger } from '@elysiajs/swagger';
import { cors } from '@elysiajs/cors';

interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

const products: Product[] = [
  { id: '1', name: 'Mechanical Keyboard', price: 120, inStock: true },
  { id: '2', name: 'Ergonomic Mouse', price: 65, inStock: false },
];

export const app = new Elysia()
  .use(cors())
  .use(swagger({
    documentation: {
      info: {
        title: 'Product Catalog API',
        version: '1.0.0',
      },
    },
  }))
  .get('/api/products', () => products)
  .get(
    '/api/products/:id',
    ({ params, error }) => {
      const product = products.find((item) => item.id === params.id);
      if (!product) {
        return error(404, { message: 'Product not found' });
      }
      return product;
    },
    {
      params: t.Object({
        id: t.String(),
      }),
    }
  )
  .post(
    '/api/products',
    ({ body, set }) => {
      const newProduct: Product = {
        id: (products.length + 1).toString(),
        ...body,
      };
      products.push(newProduct);
      set.status = 201;
      return newProduct;
    },
    {
      body: t.Object({
        name: t.String({ minLength: 2 }),
        price: t.Number({ minimum: 0 }),
        inStock: t.Boolean(),
      }),
    }
  )
  .listen(3000);

console.log(`Server running at http://${app.server?.hostname}:${app.server?.port}`);
export type App = typeof app;

Start your server with hot reloading enabled:

bun run --watch index.ts

Open http://localhost:3000/swagger in your browser. Elysia automatically turns your TypeBox schema constraints into an interactive Swagger UI with full request and response documentation.

Step 3: Connect Frontend Using Eden Treaty

Eden Treaty synchronizes backend route definitions with your frontend code without code generators, build scripts, or manual OpenAPI syncing. First, install Eden in your client workspace:

bun add @elysiajs/eden

Import the App type export into your client script and initialize the Treaty client:

import { treaty } from '@elysiajs/eden';
import type { App } from './index';

const client = treaty<App>('http://localhost:3000');

async function fetchAndCreateProducts() {
  // 1. Fully typed GET call with autocomplete on URL paths
  const { data: allProducts, error: listError } = await client.api.products.get();
  if (listError) {
    console.error('Failed to load products:', listError.value);
    return;
  }
  console.log(`Loaded ${allProducts.length} products:`, allProducts);

  // 2. Type-checked POST call: TypeScript flags invalid field types immediately
  const { data: created, error: createError } = await client.api.products.post({
    name: 'USB-C Docking Station',
    price: 89,
    inStock: true,
  });

  if (createError) {
    console.error('Validation failed:', createError.value);
    return;
  }
  console.log(`Created product ID: ${created.id}`);
}

fetchAndCreateProducts();

Step 4: Production Deployment with Docker

Containerizing Bun applications yields lean deployment artifacts. A typical Alpine-based Bun image weighs under 100MB and starts in roughly 20 milliseconds.

Create a Dockerfile in your project root:

# Multi-stage production build
FROM oven/bun:1-alpine AS base
WORKDIR /app

# Cache and install dependencies
COPY package.json bun.lock* bun.lockb* ./
RUN bun install --frozen-lockfile --production

# Copy source files
COPY . .

# Run container as non-root user for security
USER bun
EXPOSE 3000
ENV NODE_ENV=production

CMD ["bun", "run", "index.ts"]

Build the container image and test it locally:

docker build -t bun-elysia-api .
docker run -p 3000:3000 bun-elysia-api

You now have an end-to-end type-safe API that starts instantly, enforces strict schema validation at wire speed, and requires zero client-side boilerplate.

Share: