This is Part 4 of Building Piclume: Browser-First Image Tools. The earlier articles cover the browser-first product boundary, the Canvas/Sharp routing contract, and tests for the actual processing path.
How Piclume Processes Small Image Batches Without a Background Queue
“Batch processing” can sound like a distributed-systems problem.
Sometimes it is. If a product accepts thousands of files, runs jobs for minutes, or needs work to continue after the browser closes, a durable queue and background workers are sensible tools.
Piclume has a different scope. It is a small image workflow: select a few files, process them, review the results, and download them. The current implementation accepts up to 10 images per batch, keeps the workflow on one page, and does not introduce accounts, a database, or background jobs.
That boundary leads to a useful architectural question:
How much batch behavior can a client-orchestrated pipeline provide before it needs a server-side job queue?
For Piclume, the answer is enough for a small interactive batch. The browser owns the batch lifecycle. Each file still goes through the same browser-first or server-fallback processing contract, but the client runs those jobs sequentially, records each result independently, and packages completed outputs into a ZIP only when the user asks for one.
Start with a deliberately small batch contract
The batch design begins with limits rather than infrastructure.
The shared validation module defines two important boundaries:
- one file must stay under 20 MB;
- one batch can contain at most 10 files.
Those limits are enforced before processing starts. The file-selection helper slices an oversized selection to the batch limit, validates each file against the active tool, and returns an informational or warning notice for the workspace to display.
This matters because “batch” should not mean “accept an unbounded list and hope the browser survives.” A bounded batch gives the UI a predictable amount of work, gives the user a clear recovery path for skipped files, and keeps the client-side memory model understandable.
The flow has two different kinds of rejection. A file can be rejected during selection because it does not match the current tool or exceeds a limit. It can also fail later during processing because decoding, export, or the server compatibility path failed. Keeping those stages separate makes the user-facing message more accurate.
The queue is an array, not a service
After accepted files reach the workspace, the client keeps them in React state. The processing function derives the work still to do from the current result count:
const existingResults = reprocess ? [] : results;
const startIndex = existingResults.length;
const filesToProcess = selectedFiles.slice(startIndex);
This small detail supports two workflows with the same function:
- Process a new batch from the beginning.
- Append more files and process only the files that do not already have results.
When the user explicitly reprocesses, the existing object URLs are revoked and the result list is cleared first. Otherwise, completed results remain available while the next files are processed.
The implementation then uses a for...of loop with await for each file. That is intentionally sequential:
for (const [index, currentFile] of filesToProcess.entries()) {
setProgressState({
currentFileName: currentFile.name,
currentIndex: startIndex + index + 1,
total: selectedFiles.length,
});
try {
const processed = await processImageFromClient({
file: currentFile,
quality,
outputFormat,
toolSlug: tool.slug,
resizeOptions,
});
nextResults.push({
sourceName: currentFile.name,
downloadUrl: URL.createObjectURL(processed.blob),
filename: processed.filename,
processor: processed.processor,
originalSize: processed.originalSize,
processedSize: processed.processedSize,
outputFormat: processed.outputFormat,
originalFormat: processed.originalFormat,
width: processed.width,
height: processed.height,
});
} catch (error) {
failedFiles.push(`${currentFile.name}: ${String(error)}`);
}
}
Sequential work gives the progress UI a simple meaning: the current file, its position in the batch, and the percentage completed. It also avoids turning a small browser workflow into a burst of simultaneous Canvas work and server requests.
The trade-off is equally clear: a ten-file batch takes the sum of its file-processing times rather than the duration of the slowest file. That is acceptable here because the product optimizes for a bounded, interactive workflow and needs to keep the current processing path visible.
Each file keeps its processing identity
The batch loop does not reduce every output to a generic “success” flag. It stores the processing engine returned by processImageFromClient:
-
browserwhen the local Canvas path succeeds; -
serverwhen the compatibility route handles the file; -
mixedat the batch summary level when both engines were used.
The client processing helper preserves this distinction. It tries the browser path when the routing policy allows it, catches a local failure, and then calls the server path. The server response also carries headers for the filename, original size, processed size, output format, and dimensions, so the workspace can render the same result shape for both engines.
That result shape is useful beyond the status badge. A mixed batch can show which individual files used the server path, while the aggregate can say Browser + server. The user does not need to understand the implementation to use the tool, but the implementation does not hide a meaningful difference in behavior.
One failed file should not erase the batch
The processing loop catches errors inside the per-file iteration, not around the entire batch. A failed file is added to failedFiles, while successful files are appended to nextResults.
At the end, the workspace has three useful states:
- If there are no results, the batch enters an error state and displays the first processing error.
- If some files succeeded and some failed, the successful results remain available and the UI shows a warning that files were skipped.
- If every file succeeded, the workspace moves to the normal success state.
This is a better fit for interactive batch work than all-or-nothing semantics. A server-side job system might retry failed items, persist their state, and expose a job dashboard. Piclume does not claim to do that. It keeps the successful outputs in the current browser session and tells the user which part of the batch needs attention.
The same boundaries are covered by the test helper used in the Playwright suite. A successful processing assertion checks more than a visible button: it expects Ready to download, the expected processing engine, the expected number of individual result links, and a visible download link.
ZIP creation belongs at the download boundary
Processing and packaging are separate concerns.
Each completed result is first represented by a browser object URL. That lets the workspace show previews, file sizes, dimensions, engine labels, and individual download links without waiting for a final archive. The ZIP is created only when the user clicks Download all.
The archive handler then:
- creates a
JSZipinstance; - fetches each result object URL back into a
Blob; - adds the Blob using the processed filename;
- generates a ZIP Blob in the browser;
- creates a temporary object URL and triggers the download;
- revokes the temporary archive URL after the click.
The result URLs are fetched with Promise.all, so packaging can read the already-completed outputs together. This parallel step is safe because it does not start image processing again; it only gathers blobs that are already in the browser session.
The archive is therefore a convenience layer over the result list, not a second processing pipeline. If ZIP generation fails, the individual results still exist as separate downloads. That separation keeps the main image workflow useful even when the optional packaging step needs attention.
The Playwright test verifies this boundary by uploading a mixed JPG and PNG batch, waiting for the two processed results, clicking the batch download button, loading the downloaded archive with JSZip, and asserting that it contains two files. It checks the actual ZIP contents rather than trusting the button label.
What this design does not solve
A client-orchestrated batch is not a general-purpose job system. It does not provide:
- processing after the browser tab closes;
- durable job state or retries across sessions;
- worker-level concurrency control;
- resumable uploads;
- a server-side archive for very large results.
Those would be valid reasons to introduce a queue, object storage, or a background worker. They are simply outside Piclume's current product boundary, which intentionally excludes accounts, a database, background jobs, and a public API.
The important design habit is to make the boundary explicit. A small batch can be reliable without pretending to be a distributed processing platform. Limit the input, process each file through a stable router, isolate failures, preserve result metadata, and package the outputs at the last responsible moment.
The takeaway
Piclume's batch pipeline is small by design:
- validation creates a bounded list;
- a sequential client loop makes progress and errors understandable;
- the existing browser/server router is reused for every file;
- successful results remain usable when another file fails;
- JSZip turns completed browser-held results into one optional download.
That is enough architecture for a ten-file interactive workflow. The system does not need a background queue until the product needs work that outlives the browser, exceeds the client resource model, or requires durable retry and coordination.
For the live experience, try Piclume's batch image compressor or batch image converter.


Top comments (0)