Building TooSimple Part 2: Zero-Dependency Widget Embeds & Video Processing Pipeline

March 4, 2026 (4mo ago)

In Part 1, I covered the full product story behind toosimple.io - the tech stack, architecture decisions, and lessons learned building a testimonial platform at 1/3 the cost of competitors. In this post, I'm going deep on the two hardest technical challenges: the embeddable widget system and the video processing pipeline.

The Widget Challenge

The core promise of any testimonial platform is simple: paste a snippet on your website, testimonials appear. But building a JavaScript widget that runs reliably on someone else's website is fundamentally different from building a web app. You control nothing about the host environment.

Constraints I had to work with:

  • Zero framework dependencies - can't assume React, Vue, or anything
  • Must not conflict with the host page's CSS or JavaScript
  • Must handle slow connections, blocked scripts, and legacy browsers
  • Must be secure against XSS from user-generated testimonial content
  • Must perform well even with multiple widgets on the same page

The Embed API

The end-user experience is a 2-line snippet:

<script src="https://toosimple.io/widget.js"></script>
<div data-toosimple-widget="abc123"></div>

Behind this simplicity is a self-initializing widget class that handles discovery, loading, and rendering entirely in vanilla JavaScript.

Batch Loading to Prevent API Overload

If a customer puts 10 testimonial widgets on a single page, I can't fire 10 simultaneous API calls on load. The batch loading system processes widgets in groups of 3 with a 100ms delay between batches:

const BATCH_SIZE = 3;
const BATCH_DELAY = 100;
 
class TooSimpleWidget {
  private static inflight = new WeakMap();
 
  static async init(container: HTMLElement) {
    const publicId = container.dataset.toosimpleWidget;
 
    // Request deduplication - prevent race conditions
    if (this.inflight.has(container)) {
      return this.inflight.get(container);
    }
 
    const promise = this.fetchAndRender(publicId, container);
    this.inflight.set(container, promise);
    return promise;
  }
}

The WeakMap for inflight tracking is intentional - if the DOM element gets removed, the reference gets garbage collected automatically. No memory leak cleanup needed.

XSS Prevention on User Content

Every testimonial displayed in the widget contains user-generated text. The content sanitization layer validates authorName, content, and authorTitle against patterns for script injection:

function containsMaliciousContent(text: string): boolean {
  const patterns = [
    /<script/i,
    /on\w+\s*=/i,        // Event handlers: onclick=, onerror=
    /javascript:/i,
    /<iframe/i,
    /<object/i,
    /<embed/i,
    /data:text\/html/i,
  ];
  return patterns.some(p => p.test(text));
}

This runs on every testimonial before rendering. If malicious content is detected, the testimonial is silently skipped rather than displayed.

Retry with Exponential Backoff

Widget API calls use a 3-attempt retry strategy with exponential backoff and a 10-second abort signal:

private static async fetchAndRender(id: string, el: HTMLElement) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch(`/api/widget/${id}`, {
        signal: AbortSignal.timeout(10000)
      });
      const data = await res.json();
      this.render(data, el);
      return;
    } catch {
      await new Promise(r =>
        setTimeout(r, Math.pow(2, attempt) * 1000)
      );
    }
  }
  // After 3 failures, show graceful fallback UI
  this.renderFallback(el);
}

The timeout protection is critical - without it, a slow or unresponsive API would block the host page's main thread indefinitely.

Four Widget Layouts

TooSimple ships 4 display layouts, each lazy-loaded so only the selected layout's code is downloaded:

const LAYOUTS = {
  carousel: () => import('./layouts/carousel'),
  grid: () => import('./layouts/grid'),
  single: () => import('./layouts/single'),
  'wall-of-love': () => import('./layouts/wall-of-love')
};

The carousel implementation handles touch swipe, keyboard navigation, autoplay, and pagination:

  • Touch support: 50px swipe threshold with event tracking for natural gestures
  • Keyboard navigation: Arrow keys move between testimonials
  • Autoplay: Configurable interval (minimum 1000ms) with auto-reset on interaction
  • Cleanup function: Returns a teardown callback that stops autoplay and removes all event listeners

The cleanup function matters because in SPA contexts, the widget container might get removed and re-added. Without proper teardown, you'd leak event listeners and timers.

Grid

The grid uses CSS auto-fit with minmax(280px, 1fr) for responsive columns that adapt to container width. Each card is clickable to expand into a modal overlay. The grid limits responsiveness naturally: 1 column on mobile, 2 on tablet, 3-4 on desktop.

Wall of Love

The wall of love uses CSS columns instead of CSS grid for a masonry-like layout:

function renderWallOfLove(container: HTMLElement, testimonials: Testimonial[]) {
  container.style.columnCount = String(config.wallColumns?.max || 3);
  container.style.columnWidth = '280px';
  container.style.columnGap = '16px';
 
  // Limit to 50 testimonials for performance
  const limited = testimonials.slice(0, 50);
 
  for (const t of limited) {
    const card = createCard(t);
    card.style.breakInside = 'avoid'; // Prevent card splitting across columns
    container.appendChild(card);
  }
}

CSS columns have a key advantage over JavaScript masonry: the browser handles column balancing natively, and it degrades gracefully on smaller screens by reducing columns automatically.

Single

The simplest layout - one featured testimonial, centered, max-width 448px. Expanded by default with no interaction needed.

The Widget Customization System

Every visual property of the widget is configurable through the dashboard builder UI:

interface WidgetConfig {
  // Display
  showAvatar: boolean;
  showCompany: boolean;
  showRating: boolean;
  showDate: boolean;
  maxItems: number;
  contentMaxLines: 3 | 5 | 7 | 999;
 
  // Theme
  theme: 'light' | 'dark' | 'auto';
  primaryColor: string;
  borderRadius: number;
  cardShadow: 'none' | 'subtle' | 'medium' | 'strong';
  borderWidth: number;
  cardPadding: 'tight' | 'normal' | 'comfortable';
  cardGap: 'compact' | 'normal' | 'spacious';
  avatarShape: 'rounded' | 'square';
 
  // Layout-specific
  autoplay: boolean;
  autoplayInterval: number;
  gridColumns: 'auto' | 2 | 3 | 4;
  wallColumns: { min: number; max: number };
  showArrows: boolean;
  showDots: boolean;
  videoPlaybackMode: 'modal' | 'inline';
}

The styling system maps these semantic values to CSS. Padding values translate to pixel values (12px tight, 16px normal, 24px comfortable), shadows use pre-defined CSS box-shadow values, and the card border radius is calculated as borderRadius + paddingValue for visually proportional corners.

Video Testimonial Pipeline

Video testimonials convert 2-3x better than text, but processing video is hard. I built a dual-pipeline architecture: client-side for instant preview, server-side for production optimization.

Client-Side: FFmpeg WASM

The browser handles initial video processing using FFmpeg compiled to WebAssembly:

// FFmpeg WASM loaded from CDN
const ffmpeg = new FFmpeg();
await ffmpeg.load({
  coreURL: 'https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.js',
  wasmURL: 'https://unpkg.com/@ffmpeg/[email protected]/dist/esm/ffmpeg-core.wasm',
});

Compression decision logic - not every video needs processing:

function shouldCompress(metadata: VideoMetadata): boolean {
  // Resolution exceeds 720p?
  if (metadata.width > 1280 || metadata.height > 720) return true;
  // Bitrate too high?
  if (metadata.bitrate > TARGET_BITRATE * 1.5) return true;
  // Non-MP4 format?
  if (['webm', 'mov', 'mkv', 'avi'].includes(metadata.format)) return true;
  return false;
}

Primary compression arguments:

-vf "scale=-2:'min(1280,ih)':flags=lanczos,format=yuv420p"
-c:v libx264 -preset veryfast -crf 23
-profile:v main -level 4.0
-maxrate 5000k -bufsize 10000k
-vsync cfr -r 30

This normalizes to 720p, H.264, 30fps with the Lanczos scaling algorithm for quality downscaling. The -2 in the scale filter ensures the width is always divisible by 2 (required by H.264).

Platform constraints were the biggest headache:

  • iOS: Maximum 150MB input for mobile compression (Safari memory limits)
  • Low-memory devices: Skip compression if device has less than 4GB RAM
  • SharedArrayBuffer: Required for FFmpeg WASM threading - needs specific security headers
  • Fallback path: If primary compression fails, retry with ultrafast preset and CRF 30

Server-Side: Trigger.dev Processing

After upload, Trigger.dev handles production-quality processing in the background:

export const processVideo = task({
  id: "process-video",
  retry: { maxAttempts: 3 },
  run: async ({ videoId, uploadUrl }) => {
    // Download from R2
    const video = await downloadFromR2(uploadUrl);
 
    // Transcode to 720p
    const processed = await ffmpeg(video, {
      resolution: '720p',
      maxDuration: 90,
      format: 'mp4',
      codec: 'h264'
    });
 
    // Generate thumbnail at 2-second mark
    const thumbnail = await extractFrame(processed, { at: '00:00:02' });
 
    // Upload processed files back to R2
    await uploadToR2(processed, `videos/${videoId}/processed.mp4`);
    await uploadToR2(thumbnail, `videos/${videoId}/thumb.jpg`);
 
    // Update status and notify owner
    await db.update(testimonials)
      .set({
        processingStatus: 'completed',
        mediaUrl: processed.url,
        thumbnailUrl: thumbnail.url
      })
      .where(eq(testimonials.id, videoId));
 
    await sendVideoReadyEmail(videoId);
  }
});

Idempotency is critical - if a webhook fires twice or a user double-clicks upload, the system must not process the same video twice:

// KV-based lock with 10-minute TTL
const lockKey = `video-processing:${videoId}`;
const existing = await kv.get(lockKey);
if (existing) return; // Already processing
await kv.set(lockKey, jobId, { ex: 600 });

The Upload Flow

The upload uses presigned URLs for direct-to-R2 transfers with progress tracking:

// Step 1: Get presigned URL from API
const { uploadUrl, videoId } = await fetch('/api/videos/presign', {
  method: 'POST',
  body: JSON.stringify({ contentType: file.type })
}).then(r => r.json());
 
// Step 2: Direct upload to R2 with progress
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) {
    onProgress(Math.round((e.loaded / e.total) * 100));
  }
});
xhr.open('PUT', uploadUrl);
xhr.setRequestHeader('Content-Type', file.type);
xhr.send(file);
 
// Step 3: Trigger server-side processing
await fetch(`/api/videos/${videoId}/complete`, { method: 'POST' });

Why XMLHttpRequest instead of fetch? Progress tracking. The Fetch API still doesn't have upload progress in all browsers. XHR's upload.progress event is the most reliable cross-browser solution.

Video Validation

Before any upload, the client validates:

// File type whitelist
const ALLOWED_TYPES = ['video/mp4', 'video/quicktime', 'video/webm', 'video/x-msvideo', 'video/x-matroska'];
 
// Magic byte validation (prevent renamed files)
async function validateVideoSignature(file: File): Promise<boolean> {
  const header = await file.slice(0, 12).arrayBuffer();
  const bytes = new Uint8Array(header);
  // Check for MP4, MOV, WebM, AVI signatures
  return matchesKnownSignature(bytes);
}
 
// Metadata bounds
const MAX_SIZE = 500 * 1024 * 1024;  // 500MB
const MAX_DURATION = 600;             // 10 minutes
const MAX_RESOLUTION = 7680;          // 8K width limit

The magic byte validation catches the common attack of renaming a malicious file to .mp4. Without it, someone could upload arbitrary content that passes the MIME type check but isn't actually a video.

What Made This Hard

Widget isolation was the constant battle. The host page's CSS can leak into the widget, and the widget's styles can leak out. I considered Shadow DOM but decided against it - too many edge cases with fonts, animations, and older browsers. Instead, I namespace all widget CSS with .toosimple-widget prefixes and use all: initial on the root container.

Video processing on mobile was painful. iOS Safari has strict memory limits, SharedArrayBuffer requires specific headers, and some Android browsers don't support WebAssembly at all. The fallback path (skip client-side compression, upload raw, let Trigger.dev handle everything) handles these cases gracefully.

Rate limiting for public forms - the testimonial collection form is public (no auth required), so I rate-limit to 3 uploads per hour per IP. This prevents abuse while allowing genuine customers to submit multiple testimonials.

Results

  • Widget loads in under 200ms on modern browsers
  • Zero external dependencies in the embed script
  • Batch loading handles pages with 10+ widgets without API overload
  • Video upload-to-playback time: immediate (client preview) + 30-60 seconds (server processing)
  • 90% video storage savings from automatic compression

In Part 3, I'll cover the social platform import system and the auto-approval rule engine - two features that dramatically reduce time-to-value for new users.


Building a testimonial platform or need video processing in your SaaS? Check out toosimple.io or reach out on X.