TuBrief
Subscribed Channels
Videos
Community

Running a Media Server with Bun Built-in Features Without Sharp in Node.js

TuBrief Editorial
August 22, 2026
0
Computing/Software

Written with AI assistance from the source video. The video is the authority.

English한국어Español中文العربيةFrançaisBahasa Indonesiaहिन्दीPortuguês日本語DeutschРусский

Related Video

Bun.Image Makes Your Entire Image Pipeline Obsolete4:34

Bun.Image Makes Your Entire Image Pipeline Obsolete

Better Stack

More from the community

사내 시스템에 llm api 붙일 때 마주하는 현실적인 한계와 대응법

September 13, 2026

레거시 백엔드에 GPT-6 Astra 붙일 때 예산 승인과 보안 통과를 먼저 끝내는 법이 있습니다

September 13, 2026

에이전트끼리 대화하다 6천만 원 청구서가 나오는 이유

September 13, 2026

사내 RAG 벡터 검색에 Okta 권한 필터를 직접 거는 방법

September 13, 2026

브라우저 에이전트에게 내 구글 계정을 통째로 넘기면 안 되는 이유

September 12, 2026

Apple Won the AI Race

September 12, 2026

Comments (0)

Log in to leave a comment

No posts yet

© 2026 . All rights reserved.

TuBrief
Subscribed Channels
Videos
Community
Log in

Running a Media Server with Bun Built-in Features Without Sharp in Node.js

Sometimes a Docker image that ran smoothly locally throws build errors on the deployment server. If you dig into the cause, nine times out of ten, the culprits are sharp and the libvips library, which use C++ native bindings. When you look at Cloudinary or Imgix billing statements draining dozens of dollars every month on top of that, you inevitably feel skeptical about why you have to go through all this trouble just to add image processing features.

Here is a guide to a practical transition method that reduces container capacity and saves cloud costs by building an image processing pipeline using only the built-in features of the Bun single runtime, without any external C++ compilation steps.

Stripping Out C++ Bindings That Break Alpine Container Builds

When using sharp in a Node.js environment, it dynamically connects to the operating system's C libraries through node-gyp and the N-API layer. When trying to create a Docker image based on lightweight Alpine Linux, node-gyp rebuild is forcibly triggered inside the container due to incompatibilities between the glibc and musl C libraries.

During this process, compilation toolchains like GCC, Python, and make all find their way into the image. It is also common for code that ran fine locally (macOS ARM64) to fail with a segmentation fault (SIGSEGV) as soon as it is deployed to the server (Linux x86_64).

Bun includes JPEG, PNG, and WebP codecs directly inside its runtime binary. There is no need to separately install external compilers or OS packages like libvips.

Comparison Item Node.js (Sharp + libvips) Bun Native (Bun.Image)
C++ Binding Dependency node-gyp, N-API required None (Built into runtime binary)
Build Toolchain GCC, Python, make required Unnecessary
Deployment Package Size Hundreds of MBs including toolchains Reduced to base image level
Runtime Crashes SIGSEGV occurs on glibc/musl mismatch Prevented by built-in static linking

Replacing Sharp Code 1-to-1 with Bun.Image

Since Bun.Image supports a chaining interface, you can migrate existing sharp code almost as-is. It handles Uint8Array directly without memory copying.

`typescript
// Existing sharp-based code
import sharp from "sharp";

export async function processImageSharp(inputBuffer: Buffer): Promise {
const image = sharp(inputBuffer);
const metadata = await image.metadata();

if (!metadata.width || metadata.width > 2000) {
return await image
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.rotate(90)
.webp({ quality: 85 })
.toBuffer();
}
return inputBuffer;
}

`

`typescript
// Code converted to Bun.Image
export async function processImageBun(inputBytes: Uint8Array): Promise {
// Quickly read only the header to check size without unpacking the entire bitmap
const meta = await new Bun.Image(inputBytes).metadata();

if (!meta.width || meta.width > 2000) {
return await new Bun.Image(inputBytes)
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.rotate(90)
.webp({ quality: 85 })
.bytes();
}
return inputBytes;
}

`

The metadata() method parses only the header area without decoding the entire image. This reduces CPU waste when dealing with large original images.

The API mapping for each task is as follows:

  • For object creation, use new Bun.Image(bytes) or Bun.file(path).image() instead of sharp(buf).
  • Format conversion and compression maintain the .webp({ quality: 85 }) chaining as-is.
  • Final binary extraction calls .bytes() instead of .toBuffer() to return a Uint8Array.
  • Blur images for UI loading are generated using the .placeholder() built-in method without any separate libraries.

The migration sequence is simple. Delete sharp and @types/sharp from package.json, change the output format of utility functions to bytes(), and run functionality verification with bun test.

Building a Thumbnail Cache with Bun Built-in SQLite

Image SaaS like Cloudinary see their billing amounts rise steeply even with just a slight spike in traffic. At a single-developer service stage, you can build an excellent custom resizing cache server simply with the combination of bun:sqlite and Bun.serve.

`typescript
import { Database } from "bun:sqlite";

const db = new Database("image_cache.sqlite");
// Apply WAL mode for concurrent read/write performance
db.exec("PRAGMA journal_mode = WAL;");
db.exec( CREATE TABLE IF NOT EXISTS image_cache ( key TEXT PRIMARY KEY, data BLOB NOT NULL, placeholder TEXT NOT NULL, mime_type TEXT NOT NULL, created_at INTEGER NOT NULL ));

const selectQuery = db.query("SELECT data, mime_type FROM image_cache WHERE key = ?");
const insertQuery = db.query( INSERT OR REPLACE INTO image_cache (key, data, placeholder, mime_type, created_at) VALUES (?, ?, ?, ?, ?));

export async function getOrGenerateThumbnail(
originalBytes: Uint8Array,
cacheKey: string,
width: number = 300
): Promise<{ bytes: Uint8Array; mimeType: string }> {
const cached = selectQuery.get(cacheKey) as { data: Uint8Array; mime_type: string } | null;
if (cached) {
return { bytes: cached.data, mimeType: cached.mime_type };
}

const imagePipeline = new Bun.Image(originalBytes);
const transformedBytes = await imagePipeline.resize(width).webp({ quality: 80 }).bytes();
const placeholder = await imagePipeline.placeholder();

insertQuery.run(cacheKey, transformedBytes, placeholder, "image/webp", Date.now());

return { bytes: transformedBytes, mimeType: "image/webp" };
}

`

`typescript
// Media serving endpoint
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);

if (url.pathname.startsWith("/images/")) {
  const imageId = url.pathname.replace("/images/", "");
  const width = parseInt(url.searchParams.get("w") || "300", 10);
  const cacheKey = `${imageId}_w${width}`;

  const originalFile = Bun.file(`./uploads/${imageId}`);
  if (!(await originalFile.exists())) {
    return new Response("Image Not Found", { status: 404 });
  }

  const originalBytes = await originalFile.bytes();
  const { bytes, mimeType } = await getOrGenerateThumbnail(originalBytes, cacheKey, width);

  return new Response(bytes, {
    headers: {
      "Content-Type": mimeType,
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });
}

return new Response("Not Found", { status: 404 });

},
});

`

Only the initial incoming request goes through resizing and is stored in SQLite in BLOB format, and subsequent requests are served directly from the DB cache. Keeping Cache-Control long in the response headers ensures caching works at the browser and CDN levels as well.

Preventing Main Loop Blocking with Worker Threads

Image encoding and decoding are CPU-intensive tasks. When upload requests flood in, transforming images on the main event loop freezes overall server responses. This is why P99 latency spikes into the hundreds of milliseconds range, causing the entire API server to lock up.

Image processing tasks must be offloaded to a background thread using Bun's Worker API to keep the main loop alive.

`typescript
// imageWorker.ts
declare var self: Worker;

interface ResizeTask {
id: string;
buffer: ArrayBuffer;
width: number;
}

self.onmessage = async (event: MessageEvent) => {
const { id, buffer, width } = event.data;

try {
const inputBytes = new Uint8Array(buffer);
const processedBytes = await new Bun.Image(inputBytes)
.resize(width)
.webp({ quality: 80 })
.bytes();

self.postMessage(
  { id, success: true, buffer: processedBytes.buffer },
  [processedBytes.buffer] as any
);

} catch (error) {
self.postMessage({ id, success: false, error: (error as Error).message });
}
};

`

`typescript
// server.ts
const worker = new Worker("./imageWorker.ts");
const pendingTasks = new Map<string, (buf: ArrayBuffer) => void>();

worker.onmessage = (event) => {
const { id, success, buffer, error } = event.data;
const resolve = pendingTasks.get(id);

if (resolve && success) {
resolve(buffer);
pendingTasks.delete(id);
} else if (!success) {
console.error(Job failed (${id}):, error);
pendingTasks.delete(id);
}
};

export function dispatchImageJob(id: string, buffer: ArrayBuffer, width: number): Promise {
return new Promise((resolve) => {
pendingTasks.set(id, resolve);
// Transfer ownership without memory copying using Transferable objects
worker.postMessage({ id, buffer, width }, [buffer]);
});
}

`

Using Transferable ArrayBuffer incurs zero memory copy overhead even when passing multi-megabyte buffers between threads. Even if high-volume requests pile up, the main thread throws a 202 Accepted response and handles other API requests without delay.

Operational Considerations to Check Before Deployment

Several differences that may arise between the local development environment and the deployment container environment should be reviewed in advance.

  • Check Codec Support Scope: Bun.Image commonly supports JPEG, PNG, WebP, GIF, and BMP across Linux, macOS, and Windows environments. In contrast, HEIC or AVIF support may vary depending on the server OS. In Linux production environments, it is safer to fix the default output format to WebP or JPEG.
  • Clean Up Dockerfile: Completely remove compilation package installation statements such as python3, make, g++, and libvips-dev from the Dockerfile. Build speeds become faster and container images become much lighter.
  • Temporary Directory Permissions: If you lock down the container filesystem to Read-Only for security, write permissions must be explicitly opened on the /tmp directory used by Bun's internal decoding buffers to prevent processes from crashing.
  • Enable SQLite WAL Mode: If you do not run PRAGMA journal_mode = WAL; immediately after creating the SQLite file, you will encounter DB lock errors when concurrent read/write requests pile up.

Shaving off unnecessary external dependencies inherently reduces the probability of build breakages. Actively utilizing a single runtime's built-in tools can spare you quite a bit of service operation complexity and infrastructure maintenance costs.