0

Architecting the TripMediaLibrary Pipeline

20 min read
...

Uploading a batch of high-resolution trip photos can be tricky to get right in the browser. Even if you're only uploading 20 photos (typically 5-8MB each) using a standard HTML <input type="file" multiple />, the browser can sometimes stutter, the progress bar might jump erratically, and if the network blips, the experience degrades quickly.

When building the TripMediaLibrary for BeenThere, I had a few specific constraints. We have a hard cap of 1GB space allocation for free users and a maximum of 20 photos per trip. Because of these limits, I wanted the upload process to feel very intentional and reliable. I opted for a skeuomorphic experience—a vintage camera that visually "develops" polaroids as they upload, sliding them out and stacking them onto a desk.

Getting this UI to feel smooth while processing image data in the background required a solid architecture. It was a great opportunity to apply some core JavaScript concepts: concurrency, parallelism, Web Workers, WASM, OffscreenCanvas, Semaphores, and React performance optimizations.

In this post, I'll break down the key parts of the pipeline. I've also built a few interactive visualizers so you can see how the JavaScript engine handles these concepts in real-time.


1. Concurrency vs Parallelism: The Orchestrator

At the highest level, the pipeline is controlled by a hook: useTripMediaUpload.ts.

Its job is to coordinate three independent systems:

  1. The Network Pipeline: Extracts EXIF data, compresses images, and uploads them to the cloud.
  2. The Visual Loop: Manages the vintage camera animation queue (sliding polaroids out and dropping them into a stack).
  3. The Progress Tracker: Aggregates the async progress events into a single progress bar.

To orchestrate this, we need to understand the differences between Sequential, Concurrent, and Parallel execution.

  • Sequential execution means tasks are done one after another without any overlap. One finishes completely before the next begins.
  • Concurrency is handling numerous tasks by alternating between subtasks (interleaving) on a single thread. The tasks progress together, but never at the exact same physical millisecond.
  • Parallelism is performing multiple tasks simultaneously at the exact same physical millisecond. Because JavaScript's main execution environment is strictly single-threaded, it only has one call stack. The Event Loop can interleave tasks rapidly (concurrency), but it can never execute two pieces of JavaScript code at the exact same physical instant. The only way to achieve true parallelism in the browser—where two distinct pieces of JavaScript are executing simultaneously on different CPU cores—is by spawning an isolated OS-level thread via a Web Worker.

Try the interactive demo below to see the subtle differences between executing tasks sequentially (blocking), concurrently (interleaving via the Event Loop), and in parallel (true simultaneous execution via Web Workers).

Sequential vs Concurrent vs Parallel

Observe the subtle differences in execution. Sequential blocks. Concurrent interleaves. Parallel runs simultaneously.

Photo 1 Processing0%
Photo 2 Processing0%
Photo 3 Processing0%
Photo 4 Processing0%
Click a button above to start the simulation.

In useTripMediaUpload.ts, the background network pipeline and the visual foreground loop run concurrently. Because they rely on non-blocking async operations, the single-threaded event loop interleaves their execution. I simply use Promise.all() as a synchronization barrier to wait for both of these concurrent tasks to finish.

// useTripMediaUpload.ts
// 1. True Streaming Pipeline (Runs in Background)
const networkUploadPromise = processNetworkUploads(
  photoUploadTasks,
  networkUploadResultsByIndex,
  {
    onProgress: (trackingId, points) =>
      updateProgressForPhoto(trackingId, points, photoUploadTasks.length),
    onPhotoExtracted: addPhotoToAnimationQueue,
  }
).then(() => {
  hasAllNetworkUploadsFinished = true;
});
 
// 2. Visual Loop (Runs in Foreground)
const visualLoopPromise = runPolaroidAnimationLoop(
  photoUploadTasks.length,
  networkUploadResultsByIndex,
  () => hasAllNetworkUploadsFinished
);
 
// Wait for both the raw network pipeline and the visual loop to finish!
await Promise.all([networkUploadPromise, visualLoopPromise]);

By using Promise.all(), I ensure the upload session isn't marked as "complete" until both the files are safely in the cloud AND the user has finished seeing the polaroid animations. The code waits for the slower of the two loops.


2. Unblocking the Main Thread: Web Workers in Action

If you try to extract EXIF data and resize a 5-8MB image on the browser's main JavaScript thread, the UI will often freeze. JavaScript is single-threaded; if it's busy crunching pixels, it cannot paint CSS animations or respond to clicks.

Click the "Process on Main Thread" button below to see what happens when you block the main thread. The CSS spinner will freeze. Then try the "Process in Web Worker" button.

Web Worker Offloading

Watch how heavy tasks affect UI responsiveness.

UI Thread Active

To prevent this freeze, useBackgroundPhotoUploader.ts delegates the heavy lifting to Web Workers. For each photo, the pipeline goes through a few phases off the main thread.

Phase 1: Metadata Extraction (extract.worker.ts)

First, we parse EXIF data (capture dates, GPS coordinates). Since Apple's HEIC format isn't natively supported in all browsers, we use a WASM-based library (heic2any) inside the worker to convert it to JPEG.

We also use OffscreenCanvas and createImageBitmap to decode image dimensions. By doing this in a worker, we keep the DOM completely free.

// extract.worker.ts
const bitmap = await createImageBitmap(browserRenderableFile);
const dims = { width: bitmap.width, height: bitmap.height };
bitmap.close(); // Prevent memory leaks!
 
const [photoDimensions, captureDates, geoCoordinates] = await Promise.all([
  dims,
  exifr.parse(file, { pick: ["DateTimeOriginal", "CreateDate"] }),
  exifr.gps(file)
]);

Phase 2: Client-Side Compression (compress.worker.ts)

To respect our 1GB user quota and reduce backend processing, I compress the photos locally in the browser.

Inside compress.worker.ts, I spin up an OffscreenCanvas to resize the image to a maximum of 1920x1920, and then encode it using @jsquash/jpeg, an optimized MozJPEG WebAssembly (WASM) module.

Lazy Loading Modules: WASM modules can be heavy. I don't download the MozJPEG encoder until the user actually uploads a photo, using dynamic imports to lazy-load it:

// compress.worker.ts
let encodeModule = null;
async function loadWasm() {
  if (!encodeModule) {
    encodeModule = await import('@jsquash/jpeg/encode');
  }
  return encodeModule;
}
 
// Draw and extract pixels
ctx.drawImage(bitmap, 0, 0, newWidth, newHeight);
const imageData = ctx.getImageData(0, 0, newWidth, newHeight);
 
// Try to load WASM and encode
const encoder = await loadWasm();
const buffer = await encoder.default(imageData, { quality: 85 });

We also generate a blurhash (a tiny 32x32 blurred placeholder string) natively on the client side using the same OffscreenCanvas pixel data.


3. Network Semaphores: Preventing Browser Bottlenecks

Once compressed, the files are ready to be uploaded to an R2 storage bucket.

Even with a hard cap of 20 photos, if you try to upload them all simultaneously via fetch(), the browser's network stack can get overwhelmed since most browsers limit concurrent TCP connections to a single domain (usually around 6).

To solve this, I implemented a Semaphore—a synchronization primitive that limits the number of concurrent operations.

Try clicking the "Add 5 Photos" button below. You'll see that exactly 3 photos will be processed at any given time while the rest wait in the queue.

Upload Semaphore

Limits concurrent network requests to 3.

Waiting Queue0

Active Uploads (Max 3)0 / 3

Empty Slot
Empty Slot
Empty Slot

Cloud Storage0

Here is the core logic from uploadSemaphore.ts:

// uploadSemaphore.ts
const MAX_CONCURRENT_UPLOADS = 3;
let activeUploads = 0;
const queue = [];
 
export function acquireUploadSlot(signal?: AbortSignal): Promise<() => void> {
  // Fast path: free slot available right now.
  if (activeUploads < MAX_CONCURRENT_UPLOADS) {
    activeUploads += 1;
    return Promise.resolve(createRelease());
  }
 
  // Slow path: queue until an active upload releases a slot.
  return new Promise((resolve, reject) => {
    queue.push({ resolve, reject });
  });
}
 
function createRelease() {
  let released = false;
  return () => {
    if (released) return;
    released = true;
    activeUploads = Math.max(0, activeUploads - 1);
    
    // As soon as a slot opens, drain the queue
    if (activeUploads < MAX_CONCURRENT_UPLOADS && queue.length > 0) {
      const next = queue.shift();
      activeUploads += 1;
      next.resolve(createRelease());
    }
  };
}

Every photo must await acquireUploadSlot() before touching the network. Once the upload finishes, it calls release(), instantly pulling the next photo from the queue.

The SDK Methods

Once a slot is acquired, the photo talks to our Fastify server via three SDK methods:

  1. requestMediaUpload: Requests a pre-signed Cloudflare R2 URL.
  2. uploadToSignedUrl: Streams the raw bytes to R2, firing onProgress callbacks.
  3. completeMediaUpload: Pings our server to verify success and saves the metadata (Blurhash, GPS, Dates) to our Neon Postgres database.

4. The Visual Loop: Decoupling UI from Network Speed

One of the more interesting UX challenges was making the polaroid animation feel consistent.

If a user is on a fast connection, a photo upload might finish in a fraction of a second. If the UI was strictly bound to the network speed, the polaroid would flash on the screen for a single frame and instantly disappear, looking like a glitch.

To fix this, usePolaroidAnimationSequence.ts deliberately decouples the visual state from the network state.

// usePolaroidAnimationSequence.ts
const POLAROID_DEVELOPING_DURATION_MS = 1300;
 
// Wait until THIS specific file has finished its network upload
while (!networkUploadResultsByIndex.has(queuedIndex)) {
  await delayThreadFor(50);
}
 
// Smart visual gate:
// Ensure the polaroid has been visible for at least ~1.3s before dropping it,
// otherwise it looks like a glitch if the network is too fast.
const elapsedDevelopingTime = Date.now() - developingStartedAt;
 
if (elapsedDevelopingTime < POLAROID_DEVELOPING_DURATION_MS) {
  await delayThreadFor(POLAROID_DEVELOPING_DURATION_MS - elapsedDevelopingTime);
}
 
polaroidAnimationQueueRef.current[queuedIndex].status = "stacked";

Even if the network upload is instant, the loop forces a minimum delay of 1.3 seconds. This guarantees the CSS animations have enough time to slide the polaroid out of the camera before it drops onto the stack.


5. Preventing Render Thrashing

When uploading 20 photos concurrently, each photo emits several network progress events. That’s potentially dozens of events firing in a short window.

If I stored the progress of each photo using React's useState, React would attempt to recalculate and re-render the component very rapidly. This is called Render Thrashing, and it can cause noticeable UI stutter.

Run the simulation below. The "useState" method will flash red on every single React re-render, accumulating many renders. The "useRef" method smoothly throttles the renders.

Render Thrashing Prevention

Aggregating hundreds of events using Mutable Refs.

useState (Anti-Pattern)

Overall Progress0%
React Re-renders Triggered:
0

useRef Throttling

Overall Progress0%
React Re-renders Triggered:
0

To achieve this optimization, useUploadProgressTracker.ts utilizes Mutable Refs. A useRef holds a mutable value that does not trigger re-renders when changed.

// useUploadProgressTracker.ts
// Holds mutable progress points WITHOUT causing a render loop
const progressPointsByPhotoIdRef = useRef(new Map<string, number>());
 
const updateProgressForPhoto = useCallback(
  (trackingId: string, points: number, totalPhotosToProcess: number) => {
    
    // 1. Mutate the Map synchronously (no re-render!)
    progressPointsByPhotoIdRef.current.set(trackingId, points);
 
    // 2. Calculate the overall batch percentage
    const nextProgressPercentage = calculateBatchProgress(
      progressPointsByPhotoIdRef.current,
      totalPhotosToProcess
    );
 
    // 3. ONLY push the final percentage to React state to trigger a batched render
    setOverallUploadProgressPercentage(nextProgressPercentage);
  },
  []
);

By aggregating the raw point data into a useRef Map, and only pushing the computed nextProgressPercentage to useState, we drastically throttle the render cycle. React only re-renders when the overall percentage changes, keeping the UI smooth.


6. The Complete Architecture Flow

We've talked about web workers, semaphores, and decoupled visual loops in isolation. But what happens when we stitch them all together with the Backend API, the Database, and Cloudflare's Edge network?

To truly understand the depth of this pipeline, I've built a final interactive sequence diagram. It walks through all 4 phases of the architecture:

  1. Local Pre-processing: Extracting EXIF data instantly.
  2. Parallel WASM Compression: Web Workers decoding HEIC and generating Blurhashes natively.
  3. Network Upload Pipeline (Semaphore): The entire backend trip. Notice how the Semaphore explicitly limits concurrency to 3. We use the "Magic Number 3" to prevent Safari OOM (Out of Memory) crashes from holding too many massive buffers, and to respect the browser's 6-connection limit per domain (leaving room for other API requests to keep the app feeling snappy).
  4. Edge Delivery: The progressive loading experience on the public UI.

Step through the phases below, and watch the Live Semaphore Tracker in Phase 3 to see exactly how Photo D is blocked from even asking for an upload URL until A finishes!

Payload
Limit: 3
-
-
-
A
B
C
D
Media Library
Dropzone
Background
Workers (Pool)
Main Thread
(Client)
Public UI
(Viewer)
Fastify API
(Server)
Cloudflare R2
(Edge Storage)
Neon DB
(Ledger)
Phase 0 / 4

Start

Click 'Next Phase' to step through the sequence diagram.


    Conclusion

    Building the TripMediaLibrary was a fun exercise in balancing UX with system constraints. By combining Web Workers, OffscreenCanvases, WASM encoders, Semaphores, and asynchronous visual loops, we were able to build a reliable uploader that handles our 20-photo limit gracefully while providing a nice, tactile polaroid experience for the user.