Spring Boot and Quarkus Media Pipeline

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Baseline date: 2026-10-07. This page assembles the pieces from the Java libraries page into the reference flow of upload pipelines. The running example is the Bookshelf cover upload: a book cover goes to object storage, and the service produces AVIF, WebP and JPEG variants, a BlurHash, and, for the video trailer, a few renditions and a poster.

Pipeline from client to media API, object storage, queue, workers, database and CDN
sequenceDiagram participant C as Client participant A as Media API participant S as Object storage participant Q as Queue participant W as Worker participant D as Database C->>A: POST /media (name, type, size) A->>D: insert media, status UPLOADING A-->>C: media id and pre-signed PUT URL C->>S: PUT file directly C->>A: POST /media/{id}/complete A->>Q: publish ProcessMedia(id) A->>D: status QUEUED Q->>W: ProcessMedia(id) W->>S: GET original W->>W: variants, BlurHash, renditions W->>S: PUT variants W->>D: status READY, variants, blurhash C->>A: GET /media/{id} A-->>C: status and variants

Spring Boot

Entity and API

@Entity
public class Media {
    @Id private UUID id;
    private String originalKey;          // uploads/<id>/original
    private String contentType;
    @Enumerated(EnumType.STRING) private MediaStatus status;   // UPLOADING, QUEUED, PROCESSING, READY, FAILED
    private String blurhash;
    private Integer width;
    private Integer height;
    private String failureReason;
    @JdbcTypeCode(SqlTypes.JSON) private List<Variant> variants = new ArrayList<>();
    // getters and setters omitted
}

public record Variant(String format, int width, String key) {}
@RestController
@RequestMapping("/media")
class MediaController {

    private final MediaService service;

    MediaController(MediaService service) { this.service = service; }

    @PostMapping
    UploadTicket create(@RequestBody @Valid CreateMedia request) {
        return service.createUpload(request);       // returns id and a pre-signed PUT URL
    }

    @PostMapping("/{id}/complete")
    ResponseEntity<Void> complete(@PathVariable UUID id) {
        service.markUploaded(id);                   // verifies the object, publishes the job
        return ResponseEntity.accepted().build();
    }

    @GetMapping("/{id}")
    MediaView get(@PathVariable UUID id) {
        return service.view(id);                    // status, blurhash, variants with CDN URLs
    }
}

The pre-signed URL is created with the AWS SDK S3Presigner (or Spring Cloud AWS, or the equivalent for Google Cloud Storage and Azure Blob Storage), see file storage and object stores. Pin the content type and a maximum length into the signature so that a client cannot upload something other than it declared, and validate the real content again in the worker, see security and operations.

URL putUrl = presigner.presignPutObject(r -> r
    .signatureDuration(Duration.ofMinutes(10))
    .putObjectRequest(p -> p.bucket(bucket).key("uploads/" + id + "/original")
        .contentType(request.contentType()).contentLength(request.size())))
    .url();

Queue and worker

Publish the job with Spring AMQP, as in Spring Boot with RabbitMQ. Kafka works as well, see Spring Boot with Kafka. The worker is a listener on a separate deployment, so you can scale and limit it independently:

@Component
class MediaWorker {

    @RabbitListener(queues = "media.process")
    void handle(ProcessMedia job) {
        Media media = repository.findById(job.id()).orElseThrow();
        if (media.getStatus() == MediaStatus.READY) {
            return;                                   // idempotent: redelivery does nothing
        }
        media.setStatus(MediaStatus.PROCESSING);
        repository.save(media);

        byte[] original = storage.get(media.getOriginalKey());
        ImageInfo info = images.verify(original);     // magic bytes, size, dimensions
        BufferedImage small = images.downscale(original, 64);
        media.setBlurhash(BlurHash.encode(small, 4, 3));

        List<Variant> variants = new ArrayList<>();
        for (int width : new int[] {320, 640, 1280}) {
            for (String format : new String[] {"avif", "webp", "jpg"}) {
                String key = "media/" + media.getId() + "/w" + width + "." + format;
                storage.put(key, images.resize(original, width, format));
                variants.add(new Variant(format, width, key));
            }
        }
        media.setVariants(variants);
        media.setWidth(info.width());
        media.setHeight(info.height());
        media.setStatus(MediaStatus.READY);
        repository.save(media);
    }
}

images is a thin wrapper over Thumbnailator for JPEG and PNG and over libvips for AVIF and WebP. Retries with backoff and a dead-letter queue are configured on the listener container and on the queue, see RabbitMQ reliability, dead-lettering and TTL. When the message ends in the dead-letter queue, a small consumer sets the status to FAILED and records the reason.

For video, the same worker shape calls Jaffree (see Java libraries) to probe, transcode each rendition and extract the poster. Run video workers as separate listeners with their own queue and concurrency of one or two per pod, because a single FFmpeg run uses several cores.

Backfills with Spring Batch

When a breakpoint is added or an encoder is improved, existing media must be reprocessed. A Spring Batch job reads the rows to reprocess and publishes jobs, or processes them directly in chunks, see Spring Batch and chunk-oriented processing:

@Bean
Step backfillStep(JobRepository repository, PlatformTransactionManager tx, EntityManagerFactory emf) {
    return new StepBuilder("backfillVariants", repository)
        .<Media, ProcessMedia>chunk(50, tx)
        .reader(new JpaPagingItemReaderBuilder<Media>()
            .name("mediaReader").entityManagerFactory(emf)
            .queryString("select m from Media m where m.variantsVersion < :v")
            .parameterValues(Map.of("v", CURRENT_VARIANTS_VERSION)).pageSize(50).build())
        .processor(media -> new ProcessMedia(media.getId()))
        .writer(chunk -> chunk.getItems().forEach(publisher::publish))
        .build();
}

Publishing instead of processing keeps the heavy work in the same worker code path and respects its concurrency limits. The variantsVersion column is an integer bumped whenever the recipe changes.

Quarkus

The same flow in Quarkus uses SmallRye Reactive Messaging for the queue, see Messaging with SmallRye Reactive Messaging, and scheduled jobs for backfills, see scheduling and mail.

@ApplicationScoped
public class MediaWorker {

    @Inject MediaRepository repository;
    @Inject ObjectStorage storage;
    @Inject ImageService images;

    @Incoming("media-process")
    @Blocking                                   // runs on a worker thread, not the event loop
    public void handle(ProcessMedia job) {
        Media media = repository.findById(job.id());
        if (media.status == MediaStatus.READY) {
            return;
        }
        // same steps as the Spring Boot worker: verify, BlurHash, variants, save
    }
}
mp.messaging.incoming.media-process.connector=smallrye-rabbitmq
mp.messaging.incoming.media-process.queue.name=media.process
mp.messaging.incoming.media-process.failure-strategy=reject
mp.messaging.incoming.media-process.max-outstanding-messages=2

@Blocking is essential: image and FFmpeg work must never run on the Vert.x event loop. Limit concurrency with the connector’s prefetch settings (max-outstanding-messages for RabbitMQ).

Native images and external binaries

  • A GraalVM native image starts quickly, which suits scale-to-zero workers, but ImageIO plugins that rely on service loading and reflection need extra native image configuration, and Java AWT code may not be supported in your target configuration. Check the Quarkus native documentation, or call libvips and FFmpeg as external processes and keep the Java code free of imaging libraries.

  • FFmpeg and vips are native executables, so they must be installed in the container image, next to the application. The same is true for JVM images, see Java and Spring Boot images.

  • A separate worker image, with FFmpeg and without the web stack, keeps the web image small and the attack surface low.