# How Motrix Uses SQLite and better-sqlite3 for Download Session Storage: Architecture Explained

> Learn how Motrix uses SQLite and better-sqlite3 for fast, synchronous download session storage, ensuring reliable writes without blocking the event loop. Explore the architecture.

- Repository: [Dr_rOot/Motrix](https://github.com/agalwood/Motrix)
- Tags: architecture
- Published: 2026-08-19

---

**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`](https://github.com/agalwood/Motrix/blob/main/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`](https://github.com/agalwood/Motrix/blob/main/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`, and `task_files`, establishing the core entity relationships for download metadata.
- **v2.ts** — Adds the `task_occurrences` table 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 `notifications` and `notification_occurrences` tables, implementing a ledger pattern for idempotent UI alert delivery with duplicate detection via unique `source_key` constraints.

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`](https://github.com/agalwood/Motrix/blob/main/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:

```typescript
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`](https://github.com/agalwood/Motrix/blob/main/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`](https://github.com/agalwood/Motrix/blob/main/src/core/session/session-manager.ts)) serializes all persistence through a single-threaded queue, building payloads and executing atomic writes via `saveTaskWithInstances` and related methods.
- Versioned migration scripts ([`v1.ts`](https://github.com/agalwood/Motrix/blob/main/v1.ts), [`v2.ts`](https://github.com/agalwood/Motrix/blob/main/v2.ts), [`v3.ts`](https://github.com/agalwood/Motrix/blob/main/v3.ts)) in `src/core/session/migrations/` manage schema evolution for tables including `tasks`, `task_occurrences`, and `notification_occurrences`.
- **Restore operations** reconstruct the in-memory task graph by reading all persisted rows and converting them back to `DownloadTask` objects via `taskRowToDownloadTask()`.

## 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`](https://github.com/agalwood/Motrix/blob/main/v1.ts), [`v2.ts`](https://github.com/agalwood/Motrix/blob/main/v2.ts), and [`v3.ts`](https://github.com/agalwood/Motrix/blob/main/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.