A production-grade CLI tool built in Go to batch-convert 500,000+ SVG images to optimized WebP format at multiple sizes. Originally developed for VectorIcons.com, a multi-vendor marketplace for vector illustrations and icons.
This is a working application, but it was built for one specific production task: backfilling WebP renditions for an existing marketplace. It is not a general-purpose conversion tool, and some pieces will not work outside that environment — the PostgreSQL contributor validation and the AWS role/S3 integration are tied to VectorIcons infrastructure. I left them in deliberately so the code reflects a real production workload rather than a sanitized demo.
Treat this repo as a reference for building high-throughput, concurrent image-conversion pipelines in Go — worker pools, buffered channels, and a multi-stage processing pipeline — rather than as a drop-in tool. If you want to reuse it for your own conversions, see Adapting This Code for Your Own Use below for how to strip out the environment-specific parts.
When VectorIcons launched, only PNG conversions were performed at upload time to save on development time. Later, we needed to generate multiple WebP versions retroactively for CDN delivery, previews, and browser compatibility across 500,000+ existing images.
Initial estimates using Node.js suggested 11.5 days of processing time. Go's concurrency model reduced this to 45 minutes - a 16x performance improvement.
- Language: Go 1.22 with goroutines and channels
- Cloud: AWS S3, AWS STS (IAM role assumption)
- Database: PostgreSQL with GORM
- Image Processing: rsvg-convert (SVG→PNG), ffmpeg (PNG→WebP, watermarking)
- Configuration: YAML-based with runtime validation
I first built a single-threaded version to establish a baseline, processing 4,500 test files:
| Metric | Value |
|---|---|
| Files processed | 4,500 files |
| Total time | 7 minutes 17 seconds |
| Throughput | 10.4 files/sec |
| Extrapolated for 500K files | 13.4 hours |
After adding Go's worker pool pattern with 10 concurrent goroutines:
| Metric | Value |
|---|---|
| Files processed | 4,500 files |
| Total time | 27 seconds |
| Throughput | 166.67 files/sec |
| Extrapolated for 500K files | 50 minutes |
| Language | Est. Time per Image | Total Runtime (500K files) | vs Go |
|---|---|---|---|
| Go | ~1ms | ~45 minutes | 1x |
| Python | ~50ms | ~41.6 hours | 55x slower |
| Node.js | ~200ms | ~11.5 days | 368x slower |
Result: Go's lightweight goroutines and native parallelism delivered a 16x improvement over single-threaded execution.
The application follows a producer-consumer pattern with dual worker pools, using Go's concurrency primitives (goroutines, channels, and WaitGroups) to achieve high throughput.
The processor uses two independent worker pools to parallelize I/O-bound (downloading) and CPU-bound (processing) operations:
- Download Workers fetch files from source (S3 or local filesystem) and add them to the Process Queue
- Process Workers pull from the queue and execute the image transformation pipeline
- Buffered Channels connect the pools, enabling continuous processing without blocking
For each image, the processor generates multiple sizes (thumbnail: 128px, preview: 512px, watermark: 512px):
- SVG → PNG Conversion using
rsvg-convertat target dimensions - Optional Watermarking using ffmpeg's overlay filter for the watermark variant
- PNG → WebP Conversion using ffmpeg with quality optimization (
-q:v 75) - Delivery: every WebP is written to the run's output directory (
<work_dir>/<uuid>/output;run.shcollects results into./test/output). S3 upload is optional, controlled by the local flag: whenis_local: false, each WebP is also uploaded to the target bucket
// Buffered channels for work distribution
DownloadQueue := make(chan ImageFile, len(files))
ProcessQueue := make(chan ImageFile, len(files))
// Configurable worker pools
for i := 0; i < downloadWorkers; i++ {
go downloadWorker(DownloadQueue, ProcessQueue)
}
for i := 0; i < processWorkers; i++ {
go processWorker(ProcessQueue)
}
// Synchronization with WaitGroups
downloadWG.Wait() // Wait for all downloads
close(ProcessQueue)
processWG.Wait() // Wait for all processing- Highly Concurrent: Configurable worker pools optimize for I/O and CPU workloads
- Storage Flexibility: Supports both local filesystem and AWS S3 (via strategy pattern)
- Production-Ready: Database validation, comprehensive logging, automatic cleanup
- Config-Driven: YAML configuration with sensible defaults
- Hardware Acceleration: Optional VideoToolbox support for ffmpeg on macOS
- Modular Design: Interface-based architecture with dependency injection
- Producer-Consumer: Decouples file discovery from processing via buffered channels
- Strategy Pattern: Abstract file service enables runtime switching between local/S3 backends
- Worker Pool: Limits concurrency to prevent resource exhaustion
- Pipeline: Sequential transformation stages (SVG → PNG → WebP) with conditional watermarking
- Go 1.22+
- rsvg-convert and ffmpeg on your PATH:
# macOS
brew install librsvg ffmpeg
# Ubuntu
sudo apt-get install librsvg2-bin ffmpeg- AWS credentials (via
~/.awsor environment) that can assume the IAM role named inconfig.yml. Thesetup.shscript creates a suitable role with read/write access to your S3 buckets. - PostgreSQL reachable with a
userstable containing the contributor you process. The contributor name passed on the command line is validated against this table at startup.
The database connection is configured through a .env file in the project root (never commit this file — it is gitignored):
POSTGRES_HOST=your-database-host
POSTGRES_PORT=5432
POSTGRES_USER=your-database-user
POSTGRES_PASS=your-database-password
POSTGRES_DB=your-database-nameIf no .env file exists, the same variables are read from the process environment.
Copy config-example.yml to config.yml and adjust it. The values that matter most for a local run:
aws_region: us-east-1
role_arn: arn:aws:iam::111111111111:role/svg-webp-app-role # role your AWS user can assume
# Local mode: read SVGs from local_source instead of S3
is_local: true
upload_to_s3: false
local_source: /path/to/svg/input # tree of {contributor}/{icons|illustrations}/{familyID}/{setID}/*.svg
# Worker pool configuration
download_worker_pool_size: 5
process_worker_pool_size: 10
# Output sizes
webp_sizes:
thumbnail: 128
preview: 512
watermark: 512
ffmpegPath: /opt/homebrew/bin/ffmpeg # output of `which ffmpeg`
watermark_path: ./assets/watermark.svg
work_dir: ./tmp/work
use_hardware_acceleration: true # VideoToolbox on macOSIn local mode, output WebP files are written under <work_dir>/<run-uuid>/output/; when you run through run.sh, the results are copied to ./test/output before the RAM disk is destroyed. The local_target config value is managed internally (it is overwritten at startup) and does not control the output location.
For S3 mode, set is_local: false and configure source_bucket / target_bucket instead of the local paths.
./build.sh # runs: go build -o image-processor main.goThe recommended entry point is the wrapper script, which builds the binary, mounts a 4GB RAM disk for intermediate files, points work_dir at it, runs the processor, and restores your config afterward:
./run.shTo run the binary directly:
./image-processor -f config.yml -c contributor-namego test ./...The suite is self-contained: database tests verify generated SQL against a dry-run ORM session (no database needed), S3 tests run against an injected mock client (no AWS needed), and the two conversion tests skip automatically when rsvg-convert/ffmpeg are not installed.
go-batch-svg-to-webp/
├── main.go # CLI entry point
├── src/
│ ├── image-processor/ # Main orchestrator, config, pipeline
│ ├── file-service/ # Storage abstraction (Local/S3)
│ ├── image-file/ # Image metadata parser
│ ├── database/ # PostgreSQL integration
│ ├── models/ # GORM data models
│ └── common/ # Shared utilities
├── docs/ # Code overview, spec, roadmap
├── build.sh / run.sh # Build and RAM-disk run wrappers
└── config-example.yml # Configuration template
The concurrency machinery — the worker pools, the buffered channels, and the SVG → PNG → WebP pipeline — is fully generic. What ties this app to its original environment are two integrations: PostgreSQL (used only to validate the contributor name at startup) and AWS (an STS role assumption plus the S3 storage backend). Both can be removed cleanly.
The database is consulted exactly once, in IsValidContributor(), which NewImageProcessor() calls during startup (src/image-processor/main.go). To remove it:
- Delete the
IsValidContributor()method and the call to it inNewImageProcessor(). The contributor name then acts purely as a path prefix with no validation. - Delete the
src/database/andsrc/models/packages, and remove their imports fromsrc/image-processor/main.go. - Delete your
.envfile — nothing else reads thePOSTGRES_*variables.
AWS appears in two places: session setup and the S3 storage backend.
- In
NewImageProcessor(), delete the call toSessionWithRole()(and the function itself). This removes the STS role assumption, sorole_arninconfig.ymlbecomes unnecessary. - In
src/file-service/IFileService.go, simplify theNewFileService()factory to always return aLocalFileService. Then deleteS3FileService.go,S3FileService_test.go,IS3FileService.go, andS3FileServiceConfig.go. - Remove the now-unused AWS imports and run
go mod tidy.
After both removals, the app reads SVGs from local_source, fans them out across the download and process worker pools, and writes sized WebP files locally — the interesting part of the codebase, with no external services required:
Run()insrc/image-processor/main.go— worker pool orchestration with buffered channels and WaitGroupsProcessFile(),ConvertSVGToPNG(), andRunFFmpeg()insrc/image-processor/funcs.go— the conversion pipelinesrc/file-service/LocalFileService.go— filesystem walking and transfersrc/image-file/— path parsing into structured image metadata
You still need rsvg-convert and ffmpeg installed, since the pipeline shells out to them for the actual rasterization and encoding.
MIT License. See LICENSE for details.
This software is provided "as is" without warranty of any kind. You are responsible for testing it in your environment and ensuring it meets your needs.