Skip to main content
Opal Editor automatically optimizes images to reduce storage usage and improve loading performance. The image optimization system runs entirely in the browser using service workers and the Cache API.

Image Cache Architecture

Images are cached using the browser’s Cache API, which provides fast, persistent storage for binary data:
// From ImageCache.ts
export class ImageCache {
  name: string;
  guid: string;
  _cache: Promise<Cache> | null = null;
  
  constructor({ guid, name }: { guid: string; name: string }) {
    this.guid = guid;
    this.name = name;
  }
  
  private getCacheId = () => `${this.guid}/${this.name}`;
  
  getCache() {
    return (this._cache ??= caches.open(this.getCacheId()));
  }
  
  async destroy() {
    await caches.delete(this.getCacheId());
  }
}

Cache Organization

Each workspace has its own isolated image cache:
workspace-guid-123/img → Main image cache
workspace-guid-123/thumbs → Thumbnail cache  
workspace-guid-123/optimized → Optimized versions

Automatic WebP Conversion

When images are served through the service worker, they’re automatically converted to WebP format for better compression:
// Service worker image handling
app.get('/api/workspace/:name/image/*', async (c) => {
  const imagePath = c.req.param('*');
  const workspace = await resolveWorkspace(c);
  
  // Check cache first
  const cache = await ImageCache.getCache(workspace.id);
  const cached = await cache.match(imagePath);
  if (cached) {
    return cached;
  }
  
  // Load original image
  const imageBuffer = await workspace.readFile(imagePath);
  
  // Convert to WebP
  const webpBuffer = await convertToWebP(imageBuffer, {
    quality: 85,
    lossless: false
  });
  
  // Cache the result
  const response = new Response(webpBuffer, {
    headers: {
      'Content-Type': 'image/webp',
      'Cache-Control': 'max-age=31536000, immutable'
    }
  });
  
  await cache.put(imagePath, response.clone());
  
  return response;
});

WebP Benefits

Smaller Files

WebP provides 25-35% better compression than JPEG/PNG

Faster Loading

Smaller files mean faster page loads and less bandwidth

Better Quality

Maintains visual quality at lower file sizes

Transparency

Supports alpha channel like PNG

Image Processing Pipeline

1. Upload

When images are added to a workspace:
// From handleImageUpload.ts
app.post('/api/workspace/:name/image/upload', async (c) => {
  const formData = await c.req.formData();
  const file = formData.get('image') as File;
  
  // Validate image
  if (!file.type.startsWith('image/')) {
    return c.json({ error: 'Invalid file type' }, 400);
  }
  
  // Read file data
  const arrayBuffer = await file.arrayBuffer();
  const uint8Array = new Uint8Array(arrayBuffer);
  
  // Generate unique filename
  const filename = `${Date.now()}-${file.name}`;
  const imagePath = `/images/${filename}`;
  
  // Save to workspace
  const workspace = await resolveWorkspace(c);
  await workspace.writeFile(imagePath, uint8Array);
  
  return c.json({ 
    path: imagePath,
    url: `/api/workspace/${workspace.name}/image${imagePath}`
  });
});

2. Storage

Images are stored in their original format in the workspace storage (IndexedDB/OPFS).

3. Serving

On request, images are:
  • Retrieved from storage
  • Converted to WebP (if not already cached)
  • Cached for future requests
  • Served to the browser

4. Cleanup

When images are deleted:
app.delete('/api/workspace/:name/image/*', async (c) => {
  const imagePath = c.req.param('*');
  const workspace = await resolveWorkspace(c);
  
  // Delete from workspace
  await workspace.removeFile(imagePath);
  
  // Clear from cache
  const cache = await ImageCache.getCache(workspace.id);
  await cache.delete(imagePath);
  
  return c.json({ success: true });
});

Thumbnail Generation

Opal automatically generates thumbnails for image previews:
// From Thumb.ts
class Thumb {
  static async generate(
    imageData: Uint8Array,
    options: {
      width?: number;
      height?: number;
      quality?: number;
    } = {}
  ): Promise<Uint8Array> {
    const {
      width = 200,
      height = 200,
      quality = 80
    } = options;
    
    // Create canvas
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    // Load image
    const img = new Image();
    const blob = new Blob([imageData]);
    const url = URL.createObjectURL(blob);
    
    await new Promise((resolve) => {
      img.onload = resolve;
      img.src = url;
    });
    
    // Calculate dimensions maintaining aspect ratio
    const scale = Math.min(width / img.width, height / img.height);
    canvas.width = img.width * scale;
    canvas.height = img.height * scale;
    
    // Draw and compress
    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
    
    // Convert to WebP
    const thumbnailBlob = await new Promise<Blob>((resolve) => {
      canvas.toBlob(resolve, 'image/webp', quality / 100);
    });
    
    URL.revokeObjectURL(url);
    
    return new Uint8Array(await thumbnailBlob.arrayBuffer());
  }
}
Thumbnail uses:
  • File browser previews
  • Grid view in image galleries
  • Quick loading in markdown preview
  • Reduced memory usage

Optimization Strategies

Lazy Loading

Images are only loaded when they enter the viewport:
<img 
  src="/api/workspace/my-blog/image/photos/large.jpg"
  loading="lazy"
  alt="Large image"
/>

Responsive Images

Serve different sizes based on viewport:
<img 
  srcset="
    /api/workspace/my-blog/image/photo-400.jpg 400w,
    /api/workspace/my-blog/image/photo-800.jpg 800w,
    /api/workspace/my-blog/image/photo-1200.jpg 1200w
  "
  sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
  src="/api/workspace/my-blog/image/photo-800.jpg"
  alt="Responsive image"
/>

Progressive Enhancement

Provide fallbacks for browsers without WebP support:
<picture>
  <source 
    srcset="/api/workspace/my-blog/image/photo.webp" 
    type="image/webp"
  />
  <source 
    srcset="/api/workspace/my-blog/image/photo.jpg" 
    type="image/jpeg"
  />
  <img 
    src="/api/workspace/my-blog/image/photo.jpg" 
    alt="Photo with WebP fallback"
  />
</picture>

Cache Management

Cache Size Limits

Monitor and manage cache size:
async function getCacheSize(): Promise<number> {
  if ('storage' in navigator && 'estimate' in navigator.storage) {
    const estimate = await navigator.storage.estimate();
    return estimate.usage || 0;
  }
  return 0;
}

async function clearImageCache(workspaceId: string) {
  const cacheId = ImageCache.getCacheId(workspaceId);
  await caches.delete(cacheId);
}

Cache Invalidation

Invalidate cache when images are updated:
app.put('/api/workspace/:name/image/*', async (c) => {
  const imagePath = c.req.param('*');
  const workspace = await resolveWorkspace(c);
  
  // Update image in workspace
  const formData = await c.req.formData();
  const file = formData.get('image') as File;
  const arrayBuffer = await file.arrayBuffer();
  await workspace.writeFile(imagePath, new Uint8Array(arrayBuffer));
  
  // Invalidate cache
  const cache = await ImageCache.getCache(workspace.id);
  await cache.delete(imagePath);
  
  return c.json({ success: true });
});

Selective Caching

Only cache images above a certain size threshold:
const MIN_CACHE_SIZE = 10 * 1024; // 10 KB

if (imageBuffer.length > MIN_CACHE_SIZE) {
  await cache.put(imagePath, response.clone());
}

Performance Metrics

Compression Ratios

Typical file size reductions:
Original FormatOriginal SizeWebP SizeSavings
JPEG500 KB325 KB35%
PNG (photo)800 KB520 KB35%
PNG (graphics)200 KB140 KB30%

Loading Performance

With caching enabled:
  • First load: Original image load time
  • Subsequent loads: < 10ms (cache hit)
  • WebP conversion: 50-200ms depending on image size

Best Practices

Pre-optimize images before adding to workspace:
  • Resize to appropriate dimensions
  • Use image editing tools to compress
  • Remove EXIF data if not needed
  • Photos: JPEG or WebP
  • Graphics/logos: PNG or SVG
  • Icons: SVG when possible
  • Avoid GIF for photos (use video instead)
Set appropriate cache headers:
'Cache-Control': 'max-age=31536000, immutable'
Periodically check and clean up old cached images:
const size = await getCacheSize();
if (size > CACHE_LIMIT) {
  await pruneOldEntries();
}

Advanced Configuration

Custom Quality Settings

Adjust WebP quality based on image type:
function getQualityForImage(imagePath: string): number {
  if (imagePath.includes('/photos/')) {
    return 85; // High quality for photos
  } else if (imagePath.includes('/thumbnails/')) {
    return 70; // Lower quality for thumbnails
  } else {
    return 80; // Default quality
  }
}

Batch Optimization

Optimize multiple images at once:
async function optimizeAllImages(workspace: Workspace) {
  const images = workspace.getImages();
  
  for (const imagePath of images) {
    const imageData = await workspace.readFile(imagePath);
    const optimized = await convertToWebP(imageData);
    
    // Store optimized version
    await workspace.writeFile(
      imagePath.replace(/\.[^.]+$/, '.webp'),
      optimized
    );
  }
}

Smart Caching Strategy

Implement intelligent cache eviction:
class SmartImageCache {
  private accessTimes = new Map<string, number>();
  
  async put(key: string, response: Response) {
    const cache = await this.getCache();
    
    // Record access time
    this.accessTimes.set(key, Date.now());
    
    // Evict least recently used if cache is full
    if (await this.isCacheFull()) {
      await this.evictLRU();
    }
    
    await cache.put(key, response);
  }
  
  private async evictLRU() {
    // Find least recently used entry
    let oldestKey: string | null = null;
    let oldestTime = Date.now();
    
    for (const [key, time] of this.accessTimes.entries()) {
      if (time < oldestTime) {
        oldestTime = time;
        oldestKey = key;
      }
    }
    
    if (oldestKey) {
      const cache = await this.getCache();
      await cache.delete(oldestKey);
      this.accessTimes.delete(oldestKey);
    }
  }
}
The image optimization system is completely automatic. Users don’t need to manually optimize images — Opal handles it all transparently.

Build docs developers (and LLMs) love