How Motrix Uses SQLite and better-sqlite3 for Download Session Storage: Architecture Explained
Motrix stores complete download session states—including tasks, instances, files, notifications, and out-box occurrences—in a local SQLite database accessed synchronously via the better-sqlite3 Node.js driver to ensure fast, deterministic writes without async event loop overhead.
Motrix is a full-featured download manager that persists every aspect of your download queue to disk using a robust SQLite-backed storage layer. According to the agalwood/Motrix source code, the application eschews traditional asynchronous database patterns in favor of better-sqlite3's synchronous API, enabling atomic transactions that keep task metadata perfectly synchronized with download engine state.
The Core Architecture: MotrixDatabase and SessionManager
The persistence layer centers on two primary components that coordinate all database operations.
MotrixDatabase (src/core/session/motrix-database.ts) serves as the low-level wrapper around the SQLite file (motrix.db). It opens the database using new Database(dbPath) from better-sqlite3 and immediately configures WAL mode (db.pragma('journal_mode = WAL')) to enable safe concurrent reads and writes without locking the UI thread. This class prepares and caches reusable SQL statements—such as stmtUpsertTask and stmtInsertInstance—so each operation incurs only the parameter binding cost.
SessionManager (src/core/session/session-manager.ts) acts as the high-level orchestrator that serializes persistence operations through a single-threaded queue (persistenceTail). It builds task payloads (structured as TaskWithInstances objects) and delegates storage to MotrixDatabase methods like saveTaskWithInstances, persistTaskWithOccurrence, and saveTasksBatch. Every write executes inside the same SQLite transaction to maintain consistency between task rows and their occurrence out-box.
Why Synchronous Access with better-sqlite3?
Motrix deliberately chose better-sqlite3 over async SQLite drivers to meet specific durability and performance requirements.
Synchronous API — The persistence pipeline is serialized through SessionManager.persistenceTail, making a blocking driver simpler to reason about for durability guarantees. Each write completes before control returns, eliminating race conditions between disk state and in-memory objects.
Prepared Statement Caching — MotrixDatabase caches all statements during initialization. Bulk operations like saveTasksBatch wrap thousands of upserts in a single transaction, dramatically reducing disk I/O compared to individual async queries.
WAL Mode Concurrency — Write-Ahead Logging allows the UI to read the database while the background auto-save thread writes, avoiding the lock contention typical of rollback journal modes.
Atomic Transactions — All modifications—from task updates to notification ledger entries—execute within explicit transactions. If any step fails, the entire operation rolls back, preventing half-written states in the motrix.db file.
Schema Evolution Through Versioned Migrations
The database schema evolves through versioned TypeScript migration scripts located in src/core/session/migrations/. These scripts execute once during MotrixDatabase.init() using better-sqlite3's db.exec() method.
- v1.ts — Creates the foundation tables:
tasks,task_instances, andtask_files, establishing the core entity relationships for download metadata. - v2.ts — Adds the
task_occurrencestable to support an out-box pattern for tracking task lifecycle events (start, pause, complete) that must be persisted atomically with task state. - v3.ts — Introduces the
notificationsandnotification_occurrencestables, implementing a ledger pattern for idempotent UI alert delivery with duplicate detection via uniquesource_keyconstraints.
Each migration runs transactionally, ensuring that schema updates either complete fully or leave the database in its previous valid state.
Transactional Persistence Patterns
When Motrix saves a download task, the SessionManager coordinates a multi-step atomic write operation.
First, persistTask(task) builds a payload via buildTaskPayload(task), converting the in-memory DownloadTask object into serializable row data. This payload includes the base task metadata plus nested task_instances and task_files arrays. The method then calls MotrixDatabase.saveTaskWithInstances(payload), which executes UPSERT operations using INSERT … ON CONFLICT syntax to handle both new tasks and updates to existing ones.
For terminal states requiring event logging, persistTaskWithOccurrence(task, occ) extends this pattern. It writes both the task state and a corresponding task_occurrences row in a single transaction, ensuring that the out-box ledger remains synchronized with the task's current status.
Notification persistence follows a similar atomic pattern. The insertNotificationWithLedger method inserts both the display notification row and the ledger occurrence row simultaneously. Errors violating the unique source_key constraint are caught and treated as "stale" duplicates, preventing redundant alerts without separate existence checks.
Restoring Session State on Startup
When Motrix launches, SessionManager.restore() rebuilds the entire in-memory task graph from the SQLite database.
The method calls MotrixDatabase.getAllTasks(), which executes prepared SELECT statements to fetch all task rows, instances, files, and unprocessed occurrences. The helper function taskRowToDownloadTask() (located in src/core/task/task-row-to-download-task.ts) transforms these raw database rows back into domain DownloadTask objects.
During restoration, Motrix matches persisted tasks against live aria2 GIDs through the EngineAdapter, reconciling any differences between the database state and the actual download engine state. Once validated, each reconstructed task registers with the TaskManager, effectively resurrecting the exact download queue from the previous session.
Implementation Example
The following TypeScript demonstrates initializing the database layer and persisting tasks within the Motrix architecture:
import { MotrixDatabase } from '@core/session/motrix-database';
import { SessionManager } from '@core/session/session-manager';
import { TaskManager } from '@core/task/task-manager';
import { Aria2RpcClient } from '@core/engine/aria2/aria2-rpc-client';
import { EngineAdapter } from '@core/engine/engine-adapter';
// 1️⃣ Initialise the database (usually done in main process)
const dbPath = '/home/user/.motrix/motrix.db';
const db = new MotrixDatabase(dbPath);
db.init(); // Runs v1.ts → v3.ts migrations
// 2️⃣ Create a SessionManager that will use the DB
const session = new SessionManager(
new TaskManager(),
new Aria2RpcClient(),
db,
new EngineAdapter(),
);
await session.restore(); // Rebuild in‑memory tasks from SQLite
// 3️⃣ Persist a new task (called from UI/IPC)
async function addNewTask(task: DownloadTask) {
await session.persistTask(task); // Writes task + instances in one TX
}
// 4️⃣ Persist a task together with its terminal occurrence (e.g. on completion)
async function completeTask(task: DownloadTask, occ: TaskOccurrence) {
await session.persistTaskWithOccurrence(task, occ);
}
// 5️⃣ Insert a user notification (ledger + display) atomically
session.db.insertNotificationWithLedger({
sourceKey: `task-${task.id}`,
taskId: task.id,
kind: 'download',
severity: 'info',
titleKey: 'notification.downloadComplete',
titleParams: null,
bodyKey: null,
bodyParams: null,
createdAt: Date.now(),
});
This example illustrates how Motrix maintains durability guarantees by ensuring every database interaction occurs through the synchronous better-sqlite3 driver, with all related writes bundled in explicit transactions.
Summary
- Motrix uses a local SQLite database (
motrix.db) to persist complete download session states including tasks, instances, files, and notification ledgers. - The better-sqlite3 driver provides synchronous access, enabling deterministic transaction boundaries without async/await complexity.
- MotrixDatabase (
src/core/session/motrix-database.ts) wraps the connection, configures WAL mode, and caches prepared statements for optimal performance. - SessionManager (
src/core/session/session-manager.ts) serializes all persistence through a single-threaded queue, building payloads and executing atomic writes viasaveTaskWithInstancesand related methods. - Versioned migration scripts (
v1.ts,v2.ts,v3.ts) insrc/core/session/migrations/manage schema evolution for tables includingtasks,task_occurrences, andnotification_occurrences. - Restore operations reconstruct the in-memory task graph by reading all persisted rows and converting them back to
DownloadTaskobjects viataskRowToDownloadTask().
Frequently Asked Questions
Why does Motrix use a synchronous SQLite driver instead of an async one?
Motrix uses better-sqlite3 specifically for its synchronous API because the persistence pipeline is deliberately serialized through SessionManager.persistenceTail. Synchronous access simplifies reasoning about durability guarantees, ensures that each write completes before control returns to the application logic, and eliminates race conditions between the SQLite database state and the in-memory TaskManager objects.
How does Motrix handle database schema updates when the app upgrades?
Schema evolution is managed through versioned migration files located in src/core/session/migrations/ (specifically v1.ts, v2.ts, and v3.ts). During MotrixDatabase.init(), these scripts execute sequentially via db.exec() to create or alter tables. Each migration runs within a transaction, ensuring that schema updates either apply completely or roll back, preventing corruption during application updates.
What happens if Motrix crashes during a download—will the session be recoverable?
Yes, because Motrix persists task state atomically using transactions that include both metadata and occurrence records. On startup, SessionManager.restore() reads all rows, reconstructs DownloadTask objects via taskRowToDownloadTask(), and reconciles them with the aria2 engine. Since writes use INSERT … ON CONFLICT upserts within explicit transactions, the database never contains half-written states, ensuring full recovery of the download queue.
How does Motrix prevent duplicate notifications when saving to SQLite?
The notification system implements a ledger pattern using the notification_occurrences table alongside the main notifications table. When inserting a notification, Motrix calls insertNotificationWithLedger, which attempts to insert both rows within a single transaction. The source_key column has a unique constraint; if a duplicate key insertion is attempted, better-sqlite3 throws an error that the application catches and treats as a "stale" notification, effectively deduplicating alerts without requiring separate existence checks.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →