Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
Search posts
Backend

Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients

Every time you start a new project, you type npm install pg ioredis @aws-sdk/client-s3, wrestle with @types/* packages, chase down version conflicts, and before you know it the afternoon is gone without a single line of actual code written — sound familiar? With Bun 1.2 bundling SQL and S3 clients into the runtime, and Bun 1.3 adding a Redis client, one question naturally arises: is there still a reason to install data and storage drivers yourself?

This post is written for developers considering migrating a Node.js backend to Bun. It covers how Bun.sql, Bun.redis, and Bun.s3 actually work in practice — where they shine and where they trip you up — honestly.

To set expectations upfront, the tangible gains come more from eliminating driver dependencies, cutting cold start times, and simplifying CI pipelines than from raw runtime performance. If those three are your team's pain points, read on.


Core Concepts

Background: Why These Three Clients Exist

pg, ioredis, and @aws-sdk/client-s3 each run in pure JavaScript, but they pull in dozens of transitive dependencies at install time. Version conflicts, @types/* package compatibility, per-package config files — complexity stacks up before you even spin up a single service. The really painful native binary build issues come from N-API addons like sharp and bcrypt; that's covered separately in the disadvantages section below.

Bun implemented all three drivers in Zig at the runtime level. Web frameworks (Hono, etc.) and ORMs (Drizzle, etc.) still install via npm, but data and storage drivers specifically can now be used without npm installation.

Bun.sql — PostgreSQL, MySQL, and SQLite with One API

Bun.sql is a client that executes SQL using tagged template literal syntax. It started with PostgreSQL support in Bun 1.2, and evolved into the unified Bun.SQL API when MySQL/MariaDB was added in v1.2.21. Since bun:sqlite already existed before that, you can now work with all three SQL database types using the same syntax.

The API is used in two ways:

  • import { sql } from "bun" — a module-level singleton that reads connection settings from the DATABASE_URL environment variable
  • import { SQL } from "bun" — a class you instantiate by passing connection options directly

For most cases you use sql (lowercase), and reach for SQL (uppercase class) when you need explicit connection management — for example, separating an in-memory :memory: SQLite instance from your production DB in tests.

typescript
import { sql } from "bun";
 
// Interpolations are automatically parameter-bound — no SQL injection risk
const users = await sql`SELECT * FROM users WHERE id = ${userId}`;
 
// Transactions — atomic execution inside a callback via sql.begin()
await sql.begin(async (tx) => {
  await tx`INSERT INTO orders ${sql(order)}`;
  await tx`UPDATE inventory SET stock = stock - 1 WHERE id = ${itemId}`;
});

Internally it handles automatic prepared statements, query pipelining, connection pooling, and the binary wire protocol. The binary protocol has less serialization/deserialization overhead than text-based protocols, which makes a performance difference for high-frequency queries.

Tagged templates feel unfamiliar at first, but they expose the actual executed query more transparently than an ORM, and built-in utilities for composing dynamic conditions help you adapt quickly. Drizzle ORM also runs on top of Bun.sql, so if type safety matters, you can use them together.

Diagram 1

The same syntax works across different DB types with no code branching. However, syntax like FOR UPDATE (row locking) is only valid in PostgreSQL and MySQL — it does not work in SQLite — so double-check the syntax you're using before switching environments.

Bun.redis — Automatic Pipelining by Default

Bun.redis is a Redis 7.2+ and Valkey-compatible client introduced officially in Bun 1.3. What stands out is automatic pipelining: wrap commands in Promise.all and Bun batches them for transmission automatically, with no extra configuration required.

typescript
import { RedisClient } from "bun";
 
const redis = new RedisClient("redis://localhost:6379");
// If REDIS_URL or VALKEY_URL environment variables are set, no argument is needed
 
// Set TTL (in seconds)
await redis.set("session:abc", JSON.stringify(data), { ex: 3600 });
const cached = await redis.get("session:abc");
 
// Automatic pipelining — commands are sent in a single batch with Promise.all
const [a, b, c] = await Promise.all([
  redis.get("key1"),
  redis.get("key2"),
  redis.get("key3"),
]);
 
// Pub/Sub — experimental support since v1.2.23 (see disadvantages section below)
await redis.subscribe("events", (message, channel) => {
  console.log(`[${channel}]`, message);
});

Set the VALKEY_URL environment variable to connect to Valkey directly. Many teams have moved to Valkey since Redis switched to the BSL license, and you can make that switch with just a URL change — no client code changes needed.

Bun.s3 — Presigned URLs as Synchronous Computation

Bun.s3 is an S3-compatible storage client that arrived in Bun 1.2. It is a shorthand for new Bun.S3Client() and automatically reads credentials from the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, and S3_BUCKET environment variables.

typescript
const file = Bun.s3.file("uploads/avatar.png");
await file.write(imageBuffer, { type: "image/png" });
 
// Presigned URL — returned synchronously via local HMAC computation only, no network request
const url = file.presign({ expiresIn: 3600 });
 
// S3-compatible storage like Cloudflare R2 uses the same API
const r2 = new Bun.S3Client({
  endpoint: "https://<account>.r2.cloudflarestorage.com",
  bucket: "my-bucket",
  accessKeyId: process.env.R2_ACCESS_KEY_ID,
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
});
 
const data = await r2.file("report.pdf").arrayBuffer();
 
// Web standard Blob API compatible
const blob = await Bun.s3.file("data.json").blob();

Presigned URL generation is synchronous because the signature is computed entirely via local HMAC. @aws-sdk/client-s3 can require a network request for time synchronization, which makes a difference in services that issue presigned URLs at high frequency.


Real-World Application

Scenario 1: Session Cache + DB Lookup Unified API

The combination of Hono + Bun.redis (session cache) + Bun.sql (user data) is currently the most common pattern seen in the Bun ecosystem.

typescript
import { Hono } from "hono";
import { sql } from "bun";
import { RedisClient } from "bun";
 
const app = new Hono();
const redis = new RedisClient(); // Automatically references REDIS_URL environment variable
 
app.get("/user/:id", async (c) => {
  const userId = c.req.param("id");
  const cacheKey = `user:${userId}`;
 
  const cached = await redis.get(cacheKey);
  if (cached) {
    return c.json(JSON.parse(cached));
  }
 
  const [user] = await sql`
    SELECT id, name, email FROM users WHERE id = ${userId} LIMIT 1
  `;
 
  if (!user) return c.json({ error: "Not found" }, 404);
 
  // Cache for 5 minutes
  await redis.set(cacheKey, JSON.stringify(user), { ex: 300 });
  return c.json(user);
});
 
export default app;

Diagram 2

Scenario 2: File Upload API (S3 + Presigned URL)

Bun.s3's default credentials are read from the S3_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION environment variables. If these are absent, an error is thrown at runtime.

typescript
import { Hono } from "hono";
 
const app = new Hono();
 
// Receive upload on the server and save to S3
app.post("/upload", async (c) => {
  const formData = await c.req.formData();
  const file = formData.get("file");
 
  // Guard against the case where it arrives as a string
  if (!(file instanceof File)) {
    return c.json({ error: "No file provided or invalid format" }, 400);
  }
 
  const key = `uploads/${Date.now()}-${file.name}`;
  const s3File = Bun.s3.file(key);
 
  await s3File.write(await file.arrayBuffer(), { type: file.type });
 
  // Synchronous computation — no network request
  const downloadUrl = s3File.presign({ expiresIn: 86400 });
 
  return c.json({ key, downloadUrl });
});
 
// Issue a presigned PUT URL so the client can upload directly to S3
app.get("/upload-url", async (c) => {
  const filename = c.req.query("filename") ?? "unknown";
  const key = `uploads/${Date.now()}-${filename}`;
 
  const uploadUrl = Bun.s3.file(key).presign({
    method: "PUT",
    expiresIn: 300,
    type: "application/octet-stream",
  });
 
  return c.json({ key, uploadUrl });
});
 
export default app;

Scenario 3: Order Processing with Transactions

Use sql.begin() to atomically handle inventory decrement and order creation. FOR UPDATE is a row-locking syntax supported in PostgreSQL and MySQL; it does not work in SQLite. This example targets PostgreSQL.

typescript
import { sql } from "bun";
import { RedisClient } from "bun";
 
const redis = new RedisClient();
 
async function placeOrder(userId: number, itemId: number, quantity: number) {
  const order = await sql.begin(async (tx) => {
    const [item] = await tx`
      SELECT id, stock, price FROM items WHERE id = ${itemId} FOR UPDATE
    `;
 
    if (!item || item.stock < quantity) {
      throw new Error("Insufficient stock");
    }
 
    const [newOrder] = await tx`
      INSERT INTO orders (user_id, item_id, quantity, total_price)
      VALUES (${userId}, ${itemId}, ${quantity}, ${item.price * quantity})
      RETURNING *
    `;
 
    await tx`
      UPDATE items SET stock = stock - ${quantity} WHERE id = ${itemId}
    `;
 
    return newOrder;
  });
 
  // Redis operations after the DB transaction — failures here don't affect order data (best-effort)
  await redis.incr(`order_count:${userId}`);
  await redis.expire(`order_count:${userId}`, 3600);
  await redis.del(`user_orders:${userId}`);
 
  return order;
}

Scenario 4: Test Setup Without Environment-Specific Code Branching

A single DATABASE_URL determines the connection target, so as long as you stick to standard CRUD queries, no environment-specific code branching is needed.

typescript
// db.ts
import { SQL } from "bun";
 
// "postgres://..." → PostgreSQL, "mysql://..." → MySQL, ":memory:" → SQLite
export const db = new SQL(process.env.DATABASE_URL ?? ":memory:");
bash
# Local development
DATABASE_URL=:memory: bun run dev
 
# Tests (in-memory SQLite)
DATABASE_URL=:memory: bun test
 
# Production
DATABASE_URL=postgres://user:pass@host/db bun run start

Syntax that behaves differently depending on the DB type — like FOR UPDATE — needs separate verification when switching environments. "Zero code changes to switch" is only true within the scope of standard SELECT, INSERT, UPDATE, and DELETE.


Pros and Cons

What You Actually Gain

Item Details
Eliminated driver dependencies Roughly 12 packages are removed from the data and storage driver layer
SQL performance ~50% faster query processing compared to pg/postgres.js¹
Redis performance ~7.9× higher throughput compared to ioredis¹
S3 performance ~5× higher throughput compared to @aws-sdk/client-s3¹
Cold start Under 10ms (vs. ~200ms for Node.js)
Unified SQL API PostgreSQL, MySQL, and SQLite with the same syntax
Simplified CI Fewer driver package install steps means lighter Docker images and CI pipelines
Valkey support Free from Redis BSL license concerns

¹ Based on Bun official benchmarks, measured at the I/O driver layer in isolation. In DB-bound CRUD apps, end-to-end request throughput differences can be as small as ~3%. In practice, the bigger gains often come from dependency elimination and cold start reduction rather than raw performance.

Disadvantages and Limitations

Item Details
N-API native addons Support scope varies by Bun version. Verify compatibility for native builds like sharp and bcrypt against the current Bun version before proceeding
Redis Pub/Sub is experimental Has remained in experimental status since v1.2.23 — recommended to wait for stabilization before production use
Redis Cluster not supported Requested via GitHub issue but not yet implemented
S3 multipart uploads Large file uploads require a custom implementation
APM and compliance gaps Some enterprise APM tools do not yet officially support Bun

Common Pitfalls in Practice

1. Migrating services that rely on N-API addons without checking compatibility first

If you're using native addons like sharp or bcrypt, verify compatibility with the current Bun version before migrating. Bun's N-API support scope has changed across versions and is still improving, so check the official compatibility docs and release notes first. If something isn't compatible, consider finding an alternative package or splitting that processing into a separate service.

2. Trying to migrate while running Redis Cluster

Bun.redis does not currently support Redis Cluster. Sentinel has been reported to partially work, but if you're running in cluster mode, monitor the GitHub issue and make a judgment call from there.

3. Pushing Pub/Sub to production immediately

Redis Pub/Sub landed as experimental in v1.2.23. The team is still collecting feedback, so for real-time systems where recovery from failure matters, it's safer to wait for stabilization or run it alongside ioredis in parallel.

4. Expecting driver benchmark numbers to translate to whole-service performance improvements

The 7.9× Redis and 50% SQL figures are isolated driver-layer measurements. Bottlenecks in CRUD-heavy applications are usually in the DB or external APIs, so end-to-end request throughput differences are far narrower. A more realistic primary motivation for migration is dependency elimination, cold start reduction, and CI simplification.

Migration Decision Flow

Diagram 3

Node.js Package Replacement Mapping

Node.js Package Bun Native Replacement
pg, postgres.js Bun.sql (PostgreSQL)
mysql2 Bun.sql (MySQL)
better-sqlite3 bun:sqlite
ioredis, node-redis Bun.redis (RedisClient)
@aws-sdk/client-s3 Bun.s3, Bun.S3Client
dotenv Built-in (Bun auto-loads .env)
ts-node, nodemon Built-in (Bun runs TS directly, --watch included)

Closing Thoughts

Bun.sql, Bun.redis, and Bun.s3 are runtime-bundled drivers that replace pg/mysql2, ioredis, and @aws-sdk/client-s3 respectively, offering clear benefits in driver dependency elimination, cold start reduction, and CI simplification. That said, N-API native addon compatibility, the lack of Redis Cluster support, Pub/Sub's experimental status, and large-file multipart uploads are areas that need verification at this point in time.

Here are three steps you can take right now:

  1. Audit your N-API dependencies first. Look through package.json for native addons like sharp, bcrypt, and canvas, and check compatibility with the current Bun version. If they're absent or compatible, you have no migration blockers.

  2. Validate with a single new project first. Rather than migrating an existing service wholesale, spin up one new API server with Bun + Hono + Bun.sql/Bun.redis. Connecting to a DB and cache without installing any packages will give you a real feel for what's changed.

  3. Just try bun run --watch and bun test. The moment you realize you no longer need dotenv, ts-node, or nodemon, "batteries-included runtime" stops being a marketing phrase.


References

  • SQL - Bun Official Documentation
  • Redis - Bun Official Documentation
  • S3 - Bun Official Documentation
  • Bun 1.3 Official Blog
  • Bun 1.2 Improves Node Compatibility and Adds Postgres Client — InfoQ
  • Bun adds Bun.SQL — a zero-dependency unified SQL client — Progosling
  • Bun.sql vs postgres.js vs Drizzle: Postgres in 2026 — PkgPulse Guides
  • Bun 1.2 Deep Dive: Built-in SQLite, S3, and Why It Might Actually Replace Node.js — DEV Community
  • Bun's Built-in Redis Client: Fast, Simple, Production-Ready — bunjs.run
  • Bun v1.2.23 Release Notes
  • Bun vs Node.js in 2026: Benchmarks & Migration Guide — Strapi
  • The Case for Bun in 2026: Where It Works and Where It Doesn't — Medium
  • Redis/Valkey Cluster Support Issue — GitHub oven-sh/bun
#Bun#PostgreSQL#Redis#S3#TypeScript#Backend
Share

Table of Contents

Core ConceptsBackground: Why These Three Clients ExistBun.sql — PostgreSQL, MySQL, and SQLite with One APIBun.redis — Automatic Pipelining by DefaultBun.s3 — Presigned URLs as Synchronous ComputationReal-World ApplicationScenario 1: Session Cache + DB Lookup Unified APIScenario 2: File Upload API (S3 + Presigned URL)Scenario 3: Order Processing with TransactionsScenario 4: Test Setup Without Environment-Specific Code BranchingPros and ConsWhat You Actually GainDisadvantages and LimitationsCommon Pitfalls in PracticeMigration Decision FlowNode.js Package Replacement MappingClosing ThoughtsReferences

Recommended Posts

Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern
Backend

Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern

The NOT VALID + VALIDATE CONSTRAINT combination is especially powerful in PostgreSQL 12+. Because VALIDATE CONSTRAINT only acquires a SHARE UPDATE EXC…

July 19, 202611 min read
Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system
Backend

Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system

Translating the document now, following the advisor's guidance on participant IDs in sequence diagrams and all other rules. Feeling honestly nervous w…

July 19, 202627 min read
Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`
Backend

Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`

Supply chain attacks in the npm ecosystem are on the rise. The moment a single package is compromised, it becomes hard to fully trust any of the hundr…

July 20, 202614 min read
Building a TypeScript REST API with Fastify v5 + TypeBox
Backend

Building a TypeScript REST API with Fastify v5 + TypeBox

Let's start with the code showing the structural problem that arises when writing an API with Express. The TypeScript type, runtime validation rules,…

July 19, 202615 min read
BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration
Backend

BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration

When building a user registration API, you eventually find yourself wondering: "Should the HTTP response really be blocked while a welcome email is be…

July 19, 202623 min read
Node.js Permission Model Practical Guide — How to Block Unauthorized Access by Third-Party Packages Using the File System Least Privilege Principle
Backend

Node.js Permission Model Practical Guide — How to Block Unauthorized Access by Third-Party Packages Using the File System Least Privilege Principle

The 2018 event stream incident was a turning point in NPM supply chain attacks. After a maintainer account was compromised, a malicious package was qu…

July 19, 202617 min read