The Problem I Faced
I had a simple requirement: upload files to Firebase Storage using a Node.js backend.
On my local Express server, it was easy — Multer handled file uploads like a charm.
But the moment I deployed the same logic to Firebase Functions (v2):
-
Every uploaded file came through as 0 bytes.
-
Headers looked fine, body parsers were disabled, I even logged raw streams… still nothing worked.
Why Multer Didn’t Work in Firebase Functions
Firebase Functions (especially v2) behaves differently from a regular Express server:
-
Incoming requests are proxied through Google’s infrastructure before hitting your app.
-
Some request transformations happen behind the scenes, and Multer (which expects a raw, untouched stream) never got the full file buffer.
-
Result? Every uploaded file was empty.
What I Tried (But Failed)
-
Switching to Functions v1 with
runWith({ memory, timeout })→ still 0 bytes. -
Deleting the
Content-Lengthheader → fixed one request but broke others. -
Wrapping Multer with raw-body middleware → too complex and still inconsistent.
Clearly, I needed a different approach.
The Real Fix: Busboy
The solution was to stop fighting the Firebase proxy and use a library designed for raw streams: Busboy.
Busboy processes multipart/form-data directly from req.rawBody (provided by Firebase Functions).
-
No dependency on how the HTTP request stream is proxied.
-
Works consistently on both local development and Firebase Functions.
What I Changed
1. index.ts (Avoid JSON Body Parsing for Multipart Requests)
import dotenv from “dotenv”;
import express from “express”;
import cors from “cors”;
import admin from “firebase-admin”;
import { onRequest } from “firebase-functions/v2/https”;
// === Importing Routes ===
import adminRoutes from “./routes/admin.route.js”;
import stripeRoutes from “./routes/stripe.route.js”;
import webhookRoutes from “./routes/webhook.route.js”;
import geoLocationRoutes from “./routes/geoLocation.routes.js”;
import jobRoutes from “./routes/job.route.js”;
// === Firebase Config ===
import { serviceAccount, FIREBASE_DATABASE_URL } from “./utils/firebaseConfig.js”;
// === Load Environment Variables ===
dotenv.config();
// === Initialize Firebase Admin SDK ===
if (!admin.apps || !admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
databaseURL: FIREBASE_DATABASE_URL,
});
}
// === Initialize Express App ===
const app = express();
// ================================================================
// 1. Stripe Webhook Route (Raw Body Required)
// ================================================================
app.use(“/api/v1/webhook”, webhookRoutes);
// ================================================================
// 2. File Upload Route (Handled by Busboy, skip default JSON parser)
// ================================================================
app.use(“/api/v1/job”, jobRoutes);
// ================================================================
// 3. Conditional Body Parser for Non-Multipart Routes
// ================================================================
app.use((req, res, next) => {
if (req.headers[“content-type”]?.startsWith(“multipart/form-data”)) {
// Skip JSON body parsing for multipart requests
return next();
}
// Apply JSON parsing for all other routes
express.json({ limit: “20mb” })(req, res, next);
});
// ================================================================
// 4. URL Encoded Body Parser
// ================================================================
app.use(express.urlencoded({ extended: true, limit: “1mb” }));
// ================================================================
// 5. Enable CORS
// ================================================================
app.use(
cors({
origin: [“https://flutterflow.app”],
methods: [“GET”, “POST”, “PUT”, “DELETE”],
})
);
// ================================================================
// 6. Other Routes
// ================================================================
app.use(“/api/v1/admin”, adminRoutes);
app.use(“/api/v1/stripe”, stripeRoutes);
app.use(“/api/v1/geolocation”, geoLocationRoutes);
// ================================================================
// 7. Root Route for Health Check
// ================================================================
app.get(“/”, (_, res) => {
res.send(“Working”);
});
// ================================================================
// 8. Error Handling Middleware
// ================================================================
app.use((err, req, res, next) => {
if (res.headersSent) {
// If headers are already sent, delegate to default Express error handler
return next(err);
}
const statusCode = err.statusCode || 500;
const message = err.message || "Internal Server Error";
return res.status(statusCode).json({
success: false,
message,
statusCode,
});
});
// ================================================================
// 9. Deploy to Firebase Functions v2 (onRequest)
// ================================================================
export const onDemandHireApiS = onRequest(
{
region: “australia-southeast2”,
memory: “4GiB”, // Allocate 4GB memory
timeoutSeconds: 640, // Max timeout (10+ minutes)
minInstances: 1, // Keep 1 warm instance
maxInstances: 20, // Scale up to 20 instances
concurrency: 80, // Requests handled per instance
},
(req, res) => {
// Disable auto body parsing for multipart requests
if (req.headers[“content-type”]?.startsWith(“multipart/form-data”)) {
req.body = undefined;
}
app(req, res);
}
);
2. Custom Upload Middleware (Busboy)
Instead of Multer, I used a Busboy-based middleware:
import Busboy from “busboy”;
import fs from “fs”;
import os from “os”;
import path from “path”;
// === Allowed file types ===
const allowedTypes = [
“image/jpeg”,
“image/png”,
“image/webp”,
“image/heic”,
“application/pdf”,
“application/vnd.openxmlformats-officedocument.wordprocessingml.document”,
“video/mp4”,
“image/gif”,
];
// === Max file size: 20MB ===
const fileSizeLimit = 20 * 1024 * 1024;
export const busboyUpload = (req, res, next) => {
console.log(“[busboyUpload] Starting upload middleware…”);
// === Initialize Busboy ===
const bb = Busboy({ headers: req.headers, limits: { fileSize: fileSizeLimit } });
const uploadedFiles = [];
const fields = {};
const fileWrites = [];
// === Handle regular form fields ===
bb.on("field", (name, value) => {
console.log(`[busboyUpload] Field received: ${name} = ${value}`);
const match = name.match(/(.+)\[(\d+)\]/); // Handle array-like fields
if (match) {
const [, key, index] = match;
fields[key] = fields[key] || [];
fields[key][parseInt(index)] = value;
} else {
fields[name] = value;
}
});
// === Handle file uploads ===
bb.on("file", (fieldname, file, info) => {
const { filename, encoding, mimeType } = info;
console.log(`[busboyUpload] File received: field=${fieldname}, filename=${filename}, type=${mimeType}`);
if (!filename) {
console.warn("[busboyUpload] No filename, skipping...");
return file.resume();
}
if (!allowedTypes.includes(mimeType)) {
console.warn(`[busboyUpload] File type not allowed: ${mimeType}`);
return file.resume();
}
// === Temporary file path ===
const tmpPath = path.join(os.tmpdir(), `${Date.now()}-${path.basename(filename)}`);
console.log(`[busboyUpload] Writing temp file to ${tmpPath}`);
const writeStream = fs.createWriteStream(tmpPath);
file.pipe(writeStream);
// === Handle file stream completion ===
const filePromise = new Promise<void>((resolve, reject) => {
writeStream.on("finish", () => {
console.log(`[busboyUpload] Finished writing file: ${filename}`);
fs.readFile(tmpPath, (err, buffer) => {
if (err) {
console.error(`[busboyUpload] Error reading temp file: ${err}`);
return reject(err);
}
console.log(`[busboyUpload] Temp file read complete, size=${buffer.length}`);
uploadedFiles.push({
fieldname,
originalname: filename,
encoding,
mimetype: mimeType,
buffer,
size: buffer.length,
});
fs.unlink(tmpPath, () => {
console.log(`[busboyUpload] Temp file deleted: ${tmpPath}`);
resolve();
});
});
});
writeStream.on("error", (err) => {
console.error(`[busboyUpload] Write stream error: ${err}`);
reject(err);
});
});
fileWrites.push(filePromise);
});
// === Finish event ===
bb.on("finish", async () => {
console.log("[busboyUpload] Busboy finished parsing.");
try {
await Promise.all(fileWrites);
console.log(`[busboyUpload] All file writes completed. Total files: ${uploadedFiles.length}`);
req.body = fields;
req.uploadedFiles = uploadedFiles;
console.log("[busboyUpload] Passing control to next middleware...");
next();
} catch (err) {
console.error("[busboyUpload] Error in finishing:", err);
next(err);
}
});
// === Handle errors ===
bb.on("error", (err) => {
console.error("[busboyUpload] Busboy error:", err);
next(err);
});
// === Stream handling based on Firebase rawBody ===
if (req.rawBody) {
console.log("[busboyUpload] Using rawBody from Firebase Functions");
bb.end(req.rawBody);
} else {
console.log("[busboyUpload] Piping request into Busboy");
req.pipe(bb);
}
};
The Result
-
No more 0-byte uploads.
-
Works both locally and in Firebase Functions.
-
No engine tweaks, no fragile header hacks.
Key Takeaways
-
Multer is great for traditional servers but struggles in serverless environments like Firebase Functions v2.
-
Busboy handles raw streams and is better suited for Firebase’s request flow.
-
Sometimes the fix isn’t adding more hacks, but switching to a lower-level library that fits the environment.
Have You Faced This Too?
Have you ever had to change your approach because something that “just works” locally completely fails in a serverless setup? How did you solve it?