Skip to content

Resume & Persistence

One of Torrin's most powerful features is the ability to automatically resume interrupted uploads. This guide covers how to enable and configure resume functionality.

How Resume Works

When an upload is interrupted (browser refresh, network failure, user closes tab), Torrin can resume from where it left off:

  1. Client stores upload state locally (uploadId, file key, received chunks)
  2. Server tracks which chunks have been received
  3. On resume, client checks with server for status
  4. Missing chunks are uploaded, already-received chunks are skipped

Enable Resume (Client)

Using localStorage (Browser)

The simplest way to enable resume in browsers:

typescript
import { 
  createTorrinClient, 
  createLocalStorageResumeStore 
} from '@torrin-kit/client';

const torrin = createTorrinClient({
  endpoint: 'http://localhost:3000/api/uploads',
  resumeStore: createLocalStorageResumeStore(),
});

Now uploads automatically resume if:

  • User refreshes the page
  • Browser crashes
  • Network disconnects temporarily

How File Matching Works

Torrin identifies files by a "file key" based on:

  • File name
  • File size
  • Last modified date
  • MIME type

When a user selects a file, Torrin checks if it matches a previous upload. If yes, it automatically resumes.

typescript
// First upload attempt
const upload1 = torrin.createUpload({ file });
await upload1.start(); // Uploads 50%, then fails

// User refreshes page...

// Second attempt with same file
const upload2 = torrin.createUpload({ file }); // Same file object
await upload2.start(); // Automatically resumes from 50%

Custom Resume Store

For more control, implement a custom resume store:

Interface

typescript
interface TorrinResumeStore {
  // Save upload state
  save(uploadId: string, state: TorrinUploadState): Promise<void> | void;
  
  // Load upload state by ID
  load(uploadId: string): Promise<TorrinUploadState | null> | TorrinUploadState | null;
  
  // Remove upload state
  remove(uploadId: string): Promise<void> | void;
  
  // Optional: Find upload by file
  findByFile?(fileKey: string): Promise<TorrinUploadState | null> | TorrinUploadState | null;
  
  // Optional: Save file-to-upload mapping
  saveFileKey?(fileKey: string, uploadId: string): Promise<void> | void;
  
  // Optional: Remove file-to-upload mapping
  removeFileKey?(fileKey: string): Promise<void> | void;
}

IndexedDB Example

For better storage capacity and performance:

typescript
import { openDB, type IDBPDatabase } from 'idb';
import type { TorrinResumeStore, TorrinUploadState } from '@torrin-kit/client';

class IndexedDBResumeStore implements TorrinResumeStore {
  private dbPromise: Promise<IDBPDatabase>;

  constructor() {
    this.dbPromise = openDB('torrin-uploads', 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('uploads')) {
          db.createObjectStore('uploads', { keyPath: 'uploadId' });
        }
        if (!db.objectStoreNames.contains('fileKeys')) {
          db.createObjectStore('fileKeys');
        }
      },
    });
  }

  async save(uploadId: string, state: TorrinUploadState): Promise<void> {
    const db = await this.dbPromise;
    await db.put('uploads', { uploadId, ...state });
  }

  async load(uploadId: string): Promise<TorrinUploadState | null> {
    const db = await this.dbPromise;
    return (await db.get('uploads', uploadId)) || null;
  }

  async remove(uploadId: string): Promise<void> {
    const db = await this.dbPromise;
    await db.delete('uploads', uploadId);
  }

  async findByFile(fileKey: string): Promise<TorrinUploadState | null> {
    const db = await this.dbPromise;
    const uploadId = await db.get('fileKeys', fileKey);
    if (!uploadId) return null;
    return this.load(uploadId);
  }

  async saveFileKey(fileKey: string, uploadId: string): Promise<void> {
    const db = await this.dbPromise;
    await db.put('fileKeys', uploadId, fileKey);
  }

  async removeFileKey(fileKey: string): Promise<void> {
    const db = await this.dbPromise;
    await db.delete('fileKeys', fileKey);
  }
}

// Usage
const torrin = createTorrinClient({
  endpoint: '/api/uploads',
  resumeStore: new IndexedDBResumeStore(),
});

Server-Side Store (Node.js)

For Node.js uploads, store state in a database:

typescript
import type { TorrinResumeStore, TorrinUploadState } from '@torrin-kit/client';
import { db } from './database';

class DatabaseResumeStore implements TorrinResumeStore {
  async save(uploadId: string, state: TorrinUploadState): Promise<void> {
    await db.uploadStates.upsert({
      where: { uploadId },
      create: { uploadId, ...state },
      update: state,
    });
  }

  async load(uploadId: string): Promise<TorrinUploadState | null> {
    return await db.uploadStates.findUnique({
      where: { uploadId },
    });
  }

  async remove(uploadId: string): Promise<void> {
    await db.uploadStates.delete({
      where: { uploadId },
    });
  }
}

Server-Side Persistence

The server must also persist upload state to support resume.

In-Memory Store (Development)

typescript
import { createInMemoryStore } from '@torrin-kit/server';

createTorrinExpressRouter({
  storage,
  store: createInMemoryStore(), // ⚠️ State lost on restart
});

Limitations:

  • State lost when server restarts
  • Not suitable for production
  • Doesn't work across multiple server instances

Redis Store (Production)

For production, use Redis or a database:

typescript
import { createClient } from 'redis';
import type { TorrinUploadStore, TorrinUploadSession } from '@torrin-kit/server';
import { generateUploadId, calculateTotalChunks } from '@torrin-kit/core';

function createRedisStore(redisUrl: string): TorrinUploadStore {
  const client = createClient({ url: redisUrl });
  client.connect();

  return {
    async createSession(init, chunkSize, ttlMs) {
      const uploadId = generateUploadId();
      const session: TorrinUploadSession = {
        uploadId,
        ...init,
        chunkSize,
        totalChunks: calculateTotalChunks(init.fileSize, chunkSize),
        status: 'pending',
        createdAt: new Date(),
        updatedAt: new Date(),
        expiresAt: ttlMs ? new Date(Date.now() + ttlMs) : undefined,
      };

      const ttlSeconds = ttlMs ? Math.ceil(ttlMs / 1000) : undefined;
      await client.set(
        `torrin:session:${uploadId}`,
        JSON.stringify(session),
        ttlSeconds ? { EX: ttlSeconds } : undefined
      );

      return session;
    },

    async getSession(uploadId) {
      const data = await client.get(`torrin:session:${uploadId}`);
      if (!data) return null;
      
      const session = JSON.parse(data);
      session.createdAt = new Date(session.createdAt);
      session.updatedAt = new Date(session.updatedAt);
      if (session.expiresAt) {
        session.expiresAt = new Date(session.expiresAt);
      }
      return session;
    },

    async updateSession(uploadId, patch) {
      const session = await this.getSession(uploadId);
      if (!session) throw new Error('Session not found');

      const updated = { ...session, ...patch, updatedAt: new Date() };
      await client.set(`torrin:session:${uploadId}`, JSON.stringify(updated));
      return updated;
    },

    async markChunkReceived(uploadId, chunkIndex) {
      await client.sAdd(`torrin:chunks:${uploadId}`, chunkIndex.toString());
    },

    async listReceivedChunks(uploadId) {
      const chunks = await client.sMembers(`torrin:chunks:${uploadId}`);
      return chunks.map(Number).sort((a, b) => a - b);
    },

    async deleteSession(uploadId) {
      await client.del(`torrin:session:${uploadId}`);
      await client.del(`torrin:chunks:${uploadId}`);
    },
  };
}

// Usage
const torrinRouter = createTorrinExpressRouter({
  storage,
  store: createRedisStore('redis://localhost:6379'),
});

Manual Resume

You can manually control resume behavior:

typescript
// Save uploadId for later
const upload = torrin.createUpload({ file });
const result = await upload.start();
localStorage.setItem('myUploadId', result.uploadId);

// Later, resume by uploadId
const savedId = localStorage.getItem('myUploadId');
const upload2 = torrin.createUpload({ 
  file,
  uploadId: savedId, // Explicitly resume this upload
});
await upload2.start(); // Resumes from where it left off

Clearing Resume Data

Client Side

typescript
// Clear specific upload
const store = createLocalStorageResumeStore();
await store.remove(uploadId);

// Clear file mapping
await store.removeFileKey(fileKey);

Server Side

typescript
import { TorrinService } from '@torrin-kit/server';

const service = new TorrinService({ storage, store });

// Abort upload (client initiated)
await service.abortUpload(uploadId);

// Cleanup expired uploads (automated)
await service.cleanupExpiredUploads();

Best Practices

  1. Always enable resume in production for better UX
  2. Use IndexedDB for browsers (more storage than localStorage)
  3. Use Redis/DB for server persistence in production
  4. Set appropriate TTL to clean up abandoned uploads (24-72 hours)
  5. Show resume UI to inform users when an upload is resumed
  6. Test resume scenarios (refresh, network failure, browser crash)

Example: Resume Indicator

Show users when an upload is being resumed:

tsx
const upload = torrin.createUpload({ file });

upload.on('status', (status) => {
  if (status === 'initializing') {
    showMessage('Checking for previous upload...');
  }
});

upload.on('progress', (p) => {
  if (p.chunksCompleted > 0 && p.chunksCompleted < p.totalChunks) {
    showMessage(`Resuming upload from ${p.percentage}%`);
  }
});

Next Steps