File uploads are fundamental to web applications, but many teams implement them as: frontend uploads to backend → backend forwards to object storage. This is straightforward but routes all file traffic through your server, increasing bandwidth costs and server load. We adopted a browser direct upload + CDN architecture that dramatically reduced server burden.
The Problem with Server-Relay Uploads
Browser → Backend Server → Object Storage (OSS/COS)
A 10MB image going through the backend means:
- The server processes a 10MB request body, consuming memory and bandwidth
- Upload speed is limited by the slower of the two hops
- Concurrent uploads can bottleneck the server
Browser Direct Upload
The idea is simple: let the browser upload directly to object storage. The backend only issues temporary credentials:
Browser → Object Storage (direct)
↑
Backend issues STS credentials
Backend: Issuing Temporary Credentials
Using Tencent Cloud COS as an example, the backend generates scoped temporary keys via STS (Security Token Service):
// API: GET /api/upload/credentials
export async function GET() {
const credentials = await getStsCredential({
secretId: process.env.COS_SECRET_ID,
secretKey: process.env.COS_SECRET_KEY,
durationSeconds: 1800, // Valid for 30 minutes
policy: {
statement: [{
effect: 'allow',
action: ['cos:PutObject', 'cos:PostObject'],
resource: [`qcs::cos:${region}:uid/${appId}:${bucket}/uploads/*`],
}],
},
});
return Response.json(credentials);
}
Key security decisions:
- Credentials expire after 30 minutes
- Permissions restricted to
PutObjectandPostObject—no delete or list - Resource path scoped to
/uploads/*—can't write elsewhere
Frontend: Direct Upload
The frontend fetches credentials and uploads directly:
async function uploadFile(file: File) {
// 1. Get temporary credentials
const creds = await fetch('/api/upload/credentials').then(r => r.json());
// 2. Generate storage path (organized by date)
const date = new Date().toISOString().slice(0, 10);
const key = `uploads/${date}/${generateId()}_${file.name}`;
// 3. Upload directly to COS
await cos.putObject({
Bucket: bucket,
Region: region,
Key: key,
Body: file,
Headers: {
'x-cos-security-token': creds.sessionToken,
},
});
// 4. Return the relative path (no domain)
return key;
}
Store Relative Paths, Resolve CDN URLs at Display Time
This is an easily overlooked but critical design choice: store only relative paths in the database, never full URLs.
✅ Database: uploads/2026-01-24/abc123_photo.jpg
❌ Database: https://cdn.example.com/uploads/2026-01-24/abc123_photo.jpg
Why? Because CDN domains change. Switching cloud providers, updating CDN acceleration domains, or binding multiple domains to one bucket (separate domains for different regions)—if you stored full URLs, migration means bulk-updating every record.
Resolve at display time instead:
function getImageUrl(relativePath: string): string {
const cdnBase = process.env.CDN_BASE_URL; // https://cdn.example.com
return `${cdnBase}/${relativePath}`;
}
CDN Image Processing
Major cloud providers support real-time image processing via URL parameters—no need to pre-generate thumbnails:
function getThumbnail(path: string, width: number): string {
const base = getImageUrl(path);
// Tencent CI: resize to width, auto WebP
return `${base}?imageMogr2/thumbnail/${width}x/format/webp`;
}
// Alibaba Cloud OSS style:
// ${base}?x-oss-process=image/resize,w_${width}/format,webp
List pages use small images (200px wide), detail pages use medium (800px), and click-to-zoom shows the original. Same image, different sizes per context—the bandwidth savings are significant.
Handling URLs in Rich Text
If your project includes a rich text editor (e.g., for articles), uploaded images are embedded as full URLs in HTML. You need to convert to relative paths on save and back to full URLs on read:
// Before saving: full URL → relative path
function normalizeContent(html: string): string {
return html.replace(
new RegExp(`${CDN_BASE_URL}/`, 'g'),
''
);
}
// After reading: relative path → full URL
function renderContent(html: string): string {
return html.replace(
/(src=["'])(uploads\/)/g,
`$1${CDN_BASE_URL}/$2`
);
}
Takeaways
The browser direct upload + CDN architecture is built on three separations: upload traffic bypasses the server, stored paths exclude domains, and image processing is handled by CDN. In our project, this reduced file-upload-related server bandwidth by over 90%, while CDN caching and edge delivery improved image load times by 3-5x. Any web application with file upload needs should consider this architecture.