Scanning multiple file uploads in a single request
Many upload interfaces let users select several files at once — a photo
gallery, a batch of documents, or an email with multiple attachments. Multer
supports this with upload.array() and
upload.fields(). Scanning each file individually with pompelmi
and running the scans concurrently keeps total latency close to the time it
takes to scan the single largest file.
Multer array upload
upload.array('files', maxCount) writes each uploaded file to a
temp path and populates req.files as an array of
Express.Multer.File objects:
const multer = require('multer');
const os = require('os');
const upload = multer({
dest: os.tmpdir(),
limits: {
fileSize: 20 * 1024 * 1024, // 20 MB per file
files: 10, // max 10 files per request
},
});
Each entry in req.files has a .path property — the
temp path that pompelmi will scan.
Concurrent scanning with Promise.all
Scan all files in parallel using Promise.all. Each call to
scan() spawns an independent clamscan process (or
opens an independent TCP connection in clamd mode), so they run concurrently:
const { scan, Verdict } = require('pompelmi');
async function scanAll(files) {
return Promise.all(
files.map(async (file) => {
const verdict = await scan(file.path);
return { file, verdict };
})
);
}
For 5 files each taking 300 ms to scan, concurrent scanning takes ~300 ms total instead of ~1500 ms sequential.
Promise.all spawns all scans simultaneously. On a machine with
limited cores, ClamAV processes will compete for CPU. If you have dozens of
files, consider a concurrency limiter (e.g.
p-limit) to scan at most N files at a time.
Fail-fast vs report-all
Two strategies for handling a malicious file in a batch:
- Fail-fast: reject the entire request as soon as any file is malicious. Simpler to implement and the safer default.
- Report-all: scan all files, return a per-file verdict, and let the client decide what to do. Useful for developer tools or admin dashboards where the operator wants to see exactly which files were malicious.
The complete example below uses the fail-fast strategy. To switch to
report-all, remove the early return and return the full results
array including verdicts.
Complete Express example
const express = require('express');
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const os = require('os');
const app = express();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 20 * 1024 * 1024, files: 10 },
});
app.post('/upload/batch', upload.array('files', 10), async (req, res) => {
const files = req.files;
if (!files || files.length === 0) {
return res.status(400).json({ error: 'No files provided.' });
}
const tmpPaths = files.map((f) => f.path);
try {
// Scan all files concurrently
const results = await Promise.all(
files.map(async (file) => {
const verdict = await scan(file.path);
return {
originalName: file.originalname,
verdict,
rejected: verdict !== Verdict.Clean,
};
})
);
// Fail-fast: reject everything if any file is not clean
const rejected = results.filter((r) => r.rejected);
if (rejected.length > 0) {
return res.status(400).json({
error: 'One or more files failed scanning. All uploads rejected.',
rejected: rejected.map((r) => ({
name: r.originalName,
reason: r.verdict.description,
})),
});
}
// All clean — persist files to storage
return res.json({
status: 'ok',
uploaded: results.map((r) => r.originalName),
});
} catch (err) {
return res.status(500).json({ error: err.message });
} finally {
// Always clean up temp files
tmpPaths.forEach((p) => {
if (fs.existsSync(p)) fs.unlinkSync(p);
});
}
});
Mixed field uploads
If your form has multiple file fields with different names (e.g. a
thumbnail and multiple attachments), use
upload.fields():
const uploadFields = multer({ dest: os.tmpdir() }).fields([
{ name: 'thumbnail', maxCount: 1 },
{ name: 'attachments', maxCount: 5 },
]);
app.post('/upload/post', uploadFields, async (req, res) => {
const thumbnail = req.files['thumbnail'] ?? [];
const attachments = req.files['attachments'] ?? [];
const allFiles = [...thumbnail, ...attachments];
const tmpPaths = allFiles.map((f) => f.path);
try {
const results = await Promise.all(
allFiles.map(async (file) => ({
name: file.fieldname + '/' + file.originalname,
verdict: await scan(file.path),
}))
);
const malicious = results.filter((r) => r.verdict !== Verdict.Clean);
if (malicious.length > 0) {
return res.status(400).json({
error: 'Malware detected.',
rejected: malicious.map((r) => r.name),
});
}
return res.json({ status: 'ok' });
} finally {
tmpPaths.forEach((p) => { if (fs.existsSync(p)) fs.unlinkSync(p); });
}
});
Next steps
- Scanning files inside a ZIP archive? See Scanning files inside a ZIP archive in Node.js.
- Need async scanning for large batches? See Background virus scanning with BullMQ.
- Optimising scan speed for many concurrent requests? See Optimising ClamAV scan performance in Node.js.