How I Fixed File Uploads in Firebase Functions (Why Multer Failed & Busboy Saved the Day)

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)

  1. Switching to Functions v1 with runWith({ memory, timeout }) → still 0 bytes.

  2. Deleting the Content-Length header → fixed one request but broke others.

  3. 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?

1 Like