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
Create your folder structure:
mkdir -p uploads/{original,thumbnails,webp}
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,
};
Test it:
bun run index.ts
# Visit http://localhost:3000
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,
};
Step 4: Test It! (2 minutes)
Start the server:
bun run index.ts
Upload an image with curl:
curl -X POST http://localhost:3000/upload \
-F "image=@/path/to/your/photo.jpg"
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"
}
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
});
});
Test batch upload:
curl -X POST http://localhost:3000/upload/batch \
-F "images=@photo1.jpg" \
-F "images=@photo2.jpg" \
-F "images=@photo3.jpg"
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>
);
}
No more ugly gray boxes while images load!
Production Tips
1. Add Rate Limiting
bun add hono-rate-limiter
import { rateLimiter } from 'hono-rate-limiter';
app.use('/upload', rateLimiter({
windowMs: 60 * 1000, // 1 minute
limit: 30, // 30 uploads per minute
}));
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',
}));
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),
});
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 };
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
- bun-image-turbo: github.com/nexus-aissam/bun-image-turbo
-
npm:
bun add bun-image-turbo
Got questions? Drop them in the comments.
Found this useful? Star the repo ⭐ — it helps more than you know.
Top comments (2)
The choice of rust was a good choice i guess.
Definitely!
Rust's memory safety + raw speed = perfect combo for image processing.
Thanks for checking it out!