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:
- Client stores upload state locally (uploadId, file key, received chunks)
- Server tracks which chunks have been received
- On resume, client checks with server for status
- Missing chunks are uploaded, already-received chunks are skipped
Enable Resume (Client)
Using localStorage (Browser)
The simplest way to enable resume in browsers:
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.
// 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
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:
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:
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)
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:
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:
// 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 offClearing Resume Data
Client Side
// Clear specific upload
const store = createLocalStorageResumeStore();
await store.remove(uploadId);
// Clear file mapping
await store.removeFileKey(fileKey);Server Side
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
- Always enable resume in production for better UX
- Use IndexedDB for browsers (more storage than localStorage)
- Use Redis/DB for server persistence in production
- Set appropriate TTL to clean up abandoned uploads (24-72 hours)
- Show resume UI to inform users when an upload is resumed
- Test resume scenarios (refresh, network failure, browser crash)
Example: Resume Indicator
Show users when an upload is being resumed:
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
- Configuration - Configure chunk size, retries, etc.
- Error Handling - Handle failures gracefully
- TTL & Cleanup - Manage abandoned uploads