DEV Community

Aissam Irhir
Aissam Irhir

Posted on

Build a 10x Faster Image Upload API in 15 Minutes with Bun

Your image upload endpoint is slow. Your users are frustrated. Your server is crying.

I know because I've been there. Processing a single product image took 800ms. Multiply that by 50 concurrent uploads and... you get timeout errors and angry customers.

Today, I'll show you how to build a blazing-fast image upload API that:

  • ✅ Processes images in under 80ms
  • ✅ Auto-generates WebP + thumbnails
  • ✅ Creates Blurhash placeholders for lazy loading
  • ✅ Handles 50+ concurrent uploads without breaking a sweat

Total time: 15 minutes. Total dependencies: 2.

Let's go.


The Stack

  • Bun — Fast JavaScript runtime
  • Hono — Lightweight web framework (Express-like, but faster)
  • bun-image-turbo — Rust-powered image processing (36x faster than Sharp)

Step 1: Project Setup (2 minutes)

# Create project
mkdir image-api && cd image-api
bun init -y

# Install dependencies
bun add hono bun-image-turbo
Enter fullscreen mode Exit fullscreen mode

Create your folder structure:

mkdir -p uploads/{original,thumbnails,webp}
Enter fullscreen mode Exit fullscreen mode

Step 2: Basic Server Setup (3 minutes)

Create index.ts:

import { Hono } from 'hono';
import { cors } from 'hono/cors';

const app = new Hono();

// Enable CORS for frontend apps
app.use('/*', cors());

// Health check
app.get('/', (c) => c.json({ status: 'ok', message: 'Image API is running 🚀' }));

export default {
  port: 3000,
  fetch: app.fetch,
};
Enter fullscreen mode Exit fullscreen mode

Test it:

bun run index.ts
# Visit http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Step 3: The Magic — Image Processing Endpoint (5 minutes)

Now the fun part. Add the upload endpoint:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { 
  transform, 
  metadata, 
  blurhash,
  resize,
  toWebp 
} from 'bun-image-turbo';
import { randomUUID } from 'crypto';

const app = new Hono();
app.use('/*', cors());

// Configuration
const CONFIG = {
  maxFileSize: 10 * 1024 * 1024, // 10MB
  thumbnail: { width: 300, height: 300 },
  webp: { quality: 85 },
  allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
};

app.post('/upload', async (c) => {
  const startTime = performance.now();

  try {
    // 1. Get the file from form data
    const formData = await c.req.formData();
    const file = formData.get('image') as File;

    if (!file) {
      return c.json({ error: 'No image provided' }, 400);
    }

    // 2. Validate file type
    if (!CONFIG.allowedTypes.includes(file.type)) {
      return c.json({ error: 'Invalid file type. Use JPEG, PNG, or WebP' }, 400);
    }

    // 3. Validate file size
    if (file.size > CONFIG.maxFileSize) {
      return c.json({ error: 'File too large. Max 10MB' }, 400);
    }

    // 4. Convert to buffer
    const buffer = Buffer.from(await file.arrayBuffer());
    const id = randomUUID();
    const filename = `${id}.webp`;

    // 5. Extract metadata (THIS IS 36x FASTER THAN SHARP!)
    const meta = await metadata(buffer);
    console.log(`📊 Original: ${meta.width}x${meta.height} ${meta.format}`);

    // 6. Process images in parallel
    const [optimized, thumbnail, blur] = await Promise.all([
      // Optimized WebP version
      transform(buffer, {
        resize: { width: 1200, fit: 'inside' },
        output: { format: 'webp', webp: { quality: CONFIG.webp.quality } }
      }),

      // Thumbnail
      transform(buffer, {
        resize: { 
          width: CONFIG.thumbnail.width, 
          height: CONFIG.thumbnail.height, 
          fit: 'cover' 
        },
        output: { format: 'webp', webp: { quality: 80 } }
      }),

      // Blurhash for lazy loading placeholder
      blurhash(buffer, 4, 3)
    ]);

    // 7. Save files
    const basePath = './uploads';
    await Promise.all([
      Bun.write(`${basePath}/original/${id}-original.${meta.format}`, buffer),
      Bun.write(`${basePath}/webp/${filename}`, optimized),
      Bun.write(`${basePath}/thumbnails/thumb-${filename}`, thumbnail),
    ]);

    // 8. Calculate processing time
    const processingTime = (performance.now() - startTime).toFixed(2);

    return c.json({
      success: true,
      id,
      processingTime: `${processingTime}ms`,
      original: {
        width: meta.width,
        height: meta.height,
        format: meta.format,
        size: `${(file.size / 1024).toFixed(2)} KB`
      },
      files: {
        optimized: `/uploads/webp/${filename}`,
        thumbnail: `/uploads/thumbnails/thumb-${filename}`,
      },
      blurhash: blur.hash,
    });

  } catch (error) {
    console.error('Upload error:', error);
    return c.json({ error: 'Failed to process image' }, 500);
  }
});

// Serve static files
app.get('/uploads/*', async (c) => {
  const path = `.${c.req.path}`;
  const file = Bun.file(path);
  if (await file.exists()) {
    return new Response(file);
  }
  return c.json({ error: 'File not found' }, 404);
});

app.get('/', (c) => c.json({ 
  status: 'ok', 
  message: 'Image API is running 🚀',
  endpoints: {
    upload: 'POST /upload (multipart/form-data with "image" field)',
  }
}));

export default {
  port: 3000,
  fetch: app.fetch,
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Test It! (2 minutes)

Start the server:

bun run index.ts
Enter fullscreen mode Exit fullscreen mode

Upload an image with curl:

curl -X POST http://localhost:3000/upload \
  -F "image=@/path/to/your/photo.jpg"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "success": true,
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "processingTime": "47.23ms",
  "original": {
    "width": 4032,
    "height": 3024,
    "format": "jpeg",
    "size": "2847.32 KB"
  },
  "files": {
    "optimized": "/uploads/webp/550e8400-e29b-41d4-a716-446655440000.webp",
    "thumbnail": "/uploads/thumbnails/thumb-550e8400-e29b-41d4-a716-446655440000.webp"
  },
  "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
}
Enter fullscreen mode Exit fullscreen mode

47ms to process a 3MB image into 3 different formats + generate a Blurhash. 🔥


Step 5: Bonus — Batch Upload Endpoint (3 minutes)

Want to upload multiple images at once? Add this:

app.post('/upload/batch', async (c) => {
  const startTime = performance.now();
  const formData = await c.req.formData();
  const files = formData.getAll('images') as File[];

  if (!files.length) {
    return c.json({ error: 'No images provided' }, 400);
  }

  if (files.length > 20) {
    return c.json({ error: 'Max 20 images per batch' }, 400);
  }

  const results = await Promise.all(
    files.map(async (file) => {
      try {
        const buffer = Buffer.from(await file.arrayBuffer());
        const id = randomUUID();

        const [optimized, thumb, blur, meta] = await Promise.all([
          toWebp(buffer, { quality: 85 }),
          resize(buffer, { width: 300, height: 300, fit: 'cover' }),
          blurhash(buffer, 4, 3),
          metadata(buffer),
        ]);

        await Promise.all([
          Bun.write(`./uploads/webp/${id}.webp`, optimized),
          Bun.write(`./uploads/thumbnails/${id}.webp`, await toWebp(thumb, { quality: 80 })),
        ]);

        return { 
          id, 
          success: true, 
          originalName: file.name,
          dimensions: `${meta.width}x${meta.height}`,
          blurhash: blur.hash 
        };
      } catch (e) {
        return { success: false, originalName: file.name, error: 'Processing failed' };
      }
    })
  );

  const processingTime = (performance.now() - startTime).toFixed(2);
  const successful = results.filter(r => r.success).length;

  return c.json({
    processingTime: `${processingTime}ms`,
    total: files.length,
    successful,
    failed: files.length - successful,
    results
  });
});
Enter fullscreen mode Exit fullscreen mode

Test batch upload:

curl -X POST http://localhost:3000/upload/batch \
  -F "images=@photo1.jpg" \
  -F "images=@photo2.jpg" \
  -F "images=@photo3.jpg"
Enter fullscreen mode Exit fullscreen mode

10 images processed in 320ms. Try that with Sharp. 😏


Performance Comparison

I benchmarked this exact API with both Sharp and bun-image-turbo:

Metric Sharp bun-image-turbo Improvement
Single upload (3MB JPEG) 412ms 47ms 8.7x faster
Batch (10 images) 3,847ms 320ms 12x faster
Concurrent (50 requests) Timeouts 💀 1,200ms
Memory usage 512MB 180MB 2.8x less

Using the Blurhash in Your Frontend

The Blurhash string can be decoded in your frontend for beautiful loading placeholders:

// React example with blurhash
import { Blurhash } from 'react-blurhash';

function ProductImage({ src, blurhash, alt }) {
  const [loaded, setLoaded] = useState(false);

  return (
    <div className="relative">
      {!loaded && (
        <Blurhash
          hash={blurhash}
          width={400}
          height={300}
          resolutionX={32}
          resolutionY={32}
          punch={1}
        />
      )}
      <img 
        src={src} 
        alt={alt}
        onLoad={() => setLoaded(true)}
        className={loaded ? 'opacity-100' : 'opacity-0'}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No more ugly gray boxes while images load!


Production Tips

1. Add Rate Limiting

bun add hono-rate-limiter
Enter fullscreen mode Exit fullscreen mode
import { rateLimiter } from 'hono-rate-limiter';

app.use('/upload', rateLimiter({
  windowMs: 60 * 1000, // 1 minute
  limit: 30, // 30 uploads per minute
}));
Enter fullscreen mode Exit fullscreen mode

2. Add to S3/Cloudflare R2

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ /* your config */ });

// Replace Bun.write with:
await s3.send(new PutObjectCommand({
  Bucket: 'your-bucket',
  Key: `images/${filename}`,
  Body: optimized,
  ContentType: 'image/webp',
}));
Enter fullscreen mode Exit fullscreen mode

3. Add Validation with Zod

import { z } from 'zod';

const uploadSchema = z.object({
  maxWidth: z.number().optional().default(1200),
  quality: z.number().min(1).max(100).optional().default(85),
});
Enter fullscreen mode Exit fullscreen mode

The Complete Code

Here's the final index.ts — copy, paste, and run:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { transform, metadata, blurhash, resize, toWebp } from 'bun-image-turbo';
import { randomUUID } from 'crypto';

const app = new Hono();
app.use('/*', cors());

const CONFIG = {
  maxFileSize: 10 * 1024 * 1024,
  thumbnail: { width: 300, height: 300 },
  webp: { quality: 85 },
  allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
};

app.post('/upload', async (c) => {
  const startTime = performance.now();

  try {
    const formData = await c.req.formData();
    const file = formData.get('image') as File;

    if (!file) return c.json({ error: 'No image provided' }, 400);
    if (!CONFIG.allowedTypes.includes(file.type)) {
      return c.json({ error: 'Invalid file type' }, 400);
    }
    if (file.size > CONFIG.maxFileSize) {
      return c.json({ error: 'File too large' }, 400);
    }

    const buffer = Buffer.from(await file.arrayBuffer());
    const id = randomUUID();
    const filename = `${id}.webp`;

    const meta = await metadata(buffer);

    const [optimized, thumbnail, blur] = await Promise.all([
      transform(buffer, {
        resize: { width: 1200, fit: 'inside' },
        output: { format: 'webp', webp: { quality: CONFIG.webp.quality } }
      }),
      transform(buffer, {
        resize: { ...CONFIG.thumbnail, fit: 'cover' },
        output: { format: 'webp', webp: { quality: 80 } }
      }),
      blurhash(buffer, 4, 3)
    ]);

    await Promise.all([
      Bun.write(`./uploads/webp/${filename}`, optimized),
      Bun.write(`./uploads/thumbnails/thumb-${filename}`, thumbnail),
    ]);

    return c.json({
      success: true,
      id,
      processingTime: `${(performance.now() - startTime).toFixed(2)}ms`,
      original: { width: meta.width, height: meta.height, format: meta.format },
      files: {
        optimized: `/uploads/webp/${filename}`,
        thumbnail: `/uploads/thumbnails/thumb-${filename}`,
      },
      blurhash: blur.hash,
    });
  } catch (error) {
    return c.json({ error: 'Failed to process image' }, 500);
  }
});

app.get('/uploads/*', async (c) => {
  const file = Bun.file(`.${c.req.path}`);
  return await file.exists() ? new Response(file) : c.json({ error: 'Not found' }, 404);
});

app.get('/', (c) => c.json({ status: 'ok', message: 'Image API 🚀' }));

export default { port: 3000, fetch: app.fetch };
Enter fullscreen mode Exit fullscreen mode

Wrap Up

In 15 minutes, you built an image API that:

  • ⚡ Processes images 10x faster than traditional solutions
  • 📦 Generates optimized WebP + thumbnails automatically
  • 🎨 Creates Blurhash placeholders for smooth UX
  • 🚀 Handles high concurrency without breaking

The secret? bun-image-turbo — a Rust-powered Sharp alternative I built after getting frustrated with slow image processing in my own SaaS.


Links


Got questions? Drop them in the comments.

Found this useful? Star the repo ⭐ — it helps more than you know.


Top comments (2)

Collapse
 
kemora_13conf profile image
Abdelghani El Mouak

The choice of rust was a good choice i guess.

Collapse
 
aissam_irhir_1e776f7ef2ac profile image
Aissam Irhir

Definitely!
Rust's memory safety + raw speed = perfect combo for image processing.
Thanks for checking it out!