opfs-caching
OPFS (Origin Private File System) for offline audio caching. Use when implementing offline storage, audio file caching, or prefetching content. Triggers on mentions of OPFS, offline storage, audio cac
SKILL.md.claude/skills/opfs-caching/SKILL.md
GitHubOPFS for Offline Audio Caching
Why OPFS?
OPFS (Origin Private File System) is 3-4x faster than IndexedDB for binary files like audio.
| Feature | OPFS | IndexedDB |
|---|---|---|
| Speed | 3-4x faster | Baseline |
| File operations | Native | Blob wrappers |
| Streaming | Supported | Not ideal |
| Browser support | All modern (2023+) | Universal |
Browser Support
- Chrome 86+
- Edge 86+
- Firefox 111+
- Safari 15.2+
- Requires HTTPS (secure context)
Basic Usage
Get OPFS Root
const root = await navigator.storage.getDirectory();
Create Directory Structure
async function setupCacheDirectories(): Promise<void> {
const root = await navigator.storage.getDirectory();
// Create subdirectories
await root.getDirectoryHandle('tracks', { create: true });
await root.getDirectoryHandle('messages', { create: true });
await root.getDirectoryHandle('schedules', { create: true });
}
Store Audio File
async function cacheTrack(trackId: string, blob: Blob): Promise<void> {
const root = await navigator.storage.getDirectory();
const tracksDir = await root.getDirectoryHandle('tracks');
const fileHandle = await tracksDir.getFileHandle(
`${trackId}.mp3`,
{ create: true }
);
// Method 1: createWritable (async, main thread)
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
}
Read Audio File
async function getCachedTrack(trackId: string): Promise<ArrayBuffer | null> {
try {
const root = await navigator.storage.getDirectory();
const tracksDir = await root.getDirectoryHandle('tracks');
const fileHandle = await tracksDir.getFileHandle(`${trackId}.mp3`);
const file = await fileHandle.getFile();
return await file.arrayBuffer();
} catch {
return null; // Not cached
}
}
Web Worker for Performance
For large files, use sync operations in a Web Worker (faster):
Worker File (cache-worker.ts)
// This runs in Web Worker context where createSyncAccessHandle is available
self.onmessage = async (event: MessageEvent) => {
const { type, payload } = event.data;
switch (type) {
case 'WRITE_FILE': {
const { directory, filename, data } = payload;
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(directory);
const fileHandle = await dir.getFileHandle(filename, { create: true });
// Sync access is much faster for large files
const accessHandle = await fileHandle.createSyncAccessHandle();
const buffer = new Uint8Array(data);
accessHandle.write(buffer, { at: 0 });
accessHandle.flush();
accessHandle.close();
self.postMessage({ type: 'WRITE_COMPLETE', success: true });
break;
}
case 'READ_FILE': {
const { directory, filename } = payload;
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle(directory);
const fileHandle = await dir.getFileHandle(filename);
const accessHandle = await fileHandle.createSyncAccessHandle();
const size = accessHandle.getSize();
const buffer = new Uint8Array(size);
accessHandle.read(buffer, { at: 0 });
accessHandle.close();
// Transfer ownership for performance
self.postMessage(
{ type: 'READ_COMPLETE', data: buffer.buffer },
[buffer.buffer]
);
break;
}
}
};
Using the Worker
class OPFSCache {
private worker: Worker;
constructor() {
this.worker = new Worker(
new URL('./cache-worker.ts', import.meta.url),
{ type: 'module' }
);
}
async writeFile(directory: string, filename: string, data: ArrayBuffer): Promise<void> {
return new Promise((resolve, reject) => {
this.worker.onmessage = (event) => {
if (event.data.type === 'WRITE_COMPLETE') {
resolve();
}
};
this.worker.onerror = reject;
this.worker.postMessage(
{ type: 'WRITE_FILE', payload: { directory, filename, data } },
[data] // Transfer ownership
);
});
}
}
Prefetch Manager
Cache content based on schedule:
interface PrefetchConfig {
lookAheadHours: number; // How far ahead to cache (4)
maxCacheSize: number; // Max bytes (500MB)
networkThreshold: 'wifi' | 'any';
}
class PrefetchManager {
private cache: OPFSCache;
private config: PrefetchConfig;
async prefetchSchedule(schedule: ScheduleEntry[]): Promise<void> {
const now = Date.now();
const lookAhead = this.config.lookAheadHours * 60 * 60 * 1000;
const cutoff = now + lookAhead;
// Filter to upcoming tracks
const upcoming = schedule
.filter(entry => {
const entryTime = entry.scheduledTime.getTime();
return entryTime > now && entryTime < cutoff;
})
.sort((a, b) => a.scheduledTime.getTime() - b.scheduledTime.getTime());
// Check storage quota
const { usage, quota } = await navigator.storage.estimate();
const available = (quota ?? 0) - (usage ?? 0);
for (const entry of upcoming) {
if (await this.cache.has(entry.trackId)) continue;
// Check we have space
if (entry.fileSize > available * 0.8) {
await this.evictOldest(entry.fileSize);
}
await this.downloadAndCache(entry);
}
}
private async evictOldest(neededBytes: number): Promise<void> {
// Remove oldest cached tracks until we have space
const cached = await this.cache.getAllMetadata();
const sorted = cached.sort((a, b) => a.cachedAt - b.cachedAt);
let freed = 0;
for (const entry of sorted) {
if (freed >= neededBytes) break;
await this.cache.delete(entry.id);
freed += entry.size;
}
}
}
Storage Quota
Check available storage:
async function checkStorageQuota(): Promise<{
used: number;
quota: number;
percent: number;
}> {
const estimate = await navigator.storage.estimate();
const used = estimate.usage ?? 0;
const quota = estimate.quota ?? 0;
return {
used,
quota,
percent: quota > 0 ? (used / quota) * 100 : 0
};
}
Cleanup
Remove old cached files:
async function evictExpired(maxAge: number = 24 * 60 * 60 * 1000): Promise<void> {
const root = await navigator.storage.getDirectory();
const tracksDir = await root.getDirectoryHandle('tracks');
const now = Date.now();
for await (const [name, handle] of tracksDir.entries()) {
if (handle.kind === 'file') {
const file = await (handle as FileSystemFileHandle).getFile();
if (now - file.lastModified > maxAge) {
await tracksDir.removeEntry(name);
}
}
}
}
Best Practices
- Use Web Workers for sync operations (faster for large files)
- Transfer ArrayBuffers to avoid copying
- Check quota before writing
- Evict old content based on schedule
- Handle errors gracefully (file not found, quota exceeded)
- Keep metadata index in IndexedDB for fast lookups