Date
Category
Blog
Author

Dexie.js: Storing File Attachments the Local-First Way

Dexie.js can be a surprisingly elegant solution when you need to store files locally in a web application.

While working on a task of saving attached files in a document draft, I came up with the idea of documenting the implementation process.

I liked the concept of interacting with a local database and focusing on a Local-First approach (and no, that doesn’t mean LocalStorage).

Local-First applications are conceptually built in a way that allows users to store data within the application environment itself. The app works offline-first, supports synchronization across clients, and — most importantly — gives users ownership of their data by removing reliance on the cloud. You can read more about the concept here: https://localfirstweb.dev/

The example problem I’m trying to solve isn’t rocket science or something so obscure that it skirts around an NDA.
It’s more of a small-scale implementation example — a simple solution to the simple problem of saving files locally within a document draft.

A Small Introduction

IndexedDB is a low-level API built into the browser that allows storing large amounts of data — including files!

However, there’s a downside: the IndexedDB interface is far from being convenient (more on that later).

Fortunately, some passionate developers have managed to tame its complexity by wrapping it into user-friendly libraries like  Dexie.js and idb-keyval.

A Small Task

I aimed to create a simple demo showing how you can work with a document draft without losing its attached files.

Just a basic CRUD involving two related entities:

interface IDocument { id: number; title: string }

interface IAttachment { id: number; file: File; documentId: number; }

IndexedDB offers a key-value interaction model, and the experience feels more like working with Redis than with a full-fledged relational database.

You could build an intermediate table to handle the relationships, but that would mostly be an attempt to please SQL-enthusiasts.

A Small Solution

The architecture assumes isolating the storage layer from the application and interacting with it through a mediator.

A Small Pain Point

“Native” interaction with IndexedDB is a bit clunky — it feels a lot like working with XMLHttpRequest.

Here’s the IndexedDB configuration:

const openDB = async (): Promise<IDBDatabase> => {
 return new Promise((resolve, reject) => {
   const request = indexedDB.open('documents-storage', 1);
   request.onupgradeneeded = () => {
     const db = request.result;
     if (!db.objectStoreNames.contains('documents')) {
       db.createObjectStore('documents', { keyPath: 'id', autoIncrement: true });
     }
     if (!db.objectStoreNames.contains('attachments')) {
       const store = db.createObjectStore('attachments', { keyPath: 'id', autoIncrement: true });
       store.createIndex('documentId', 'documentId', { unique: false });
     }
   };
   request.onsuccess = () => resolve(request.result);
   request.onerror = () => reject(request.error);
 });
}

Document addition:

const addDocument = async (doc: IDocument) => {
 const db = await openDB();
 const tx = db.transaction('documents', 'readwrite');
 const store = tx.objectStore('documents');
 const request = store.add(doc);
 return new Promise<number>((resolve, reject) => {
   request.onsuccess = () => resolve(request.result as number);
   request.onerror = () => reject(request.error);
 });
}

A Small Painkiller

The one-time experience was educational — something I’ll cherish deep in my heart — but it’s much easier to work with a library that automates repetitive tasks and adds a bit of syntactic sugar.

Here’s the IndexedDB configuration using Dexie.js:

const db = new Dexie('documents-storage') as Dexie & {
 documents: EntityTable<IDocument, 'id'>;
 attachments: EntityTable<IAttachment, 'id'>;
};


db.version(1).stores({
 documents: '++id',
 attachments: '++id, documentId',
});
export { db };

Component solution: documents/[id]/+page.svelte

const fileChanged = async e => {
 const { files } = (e.target as HTMLInputElement);
 const list = Array.from(files || []);
 await Promise.all(list.map((file: File) => addAttachment({ file, documentId } as IAttachment)));
}

Repo solution: repository/index.ts

const addAttachment = async (attachment: IAttachment) => {
 try {
   return await db.attachments.add(attachment);
 }  catch (e) {
   console.log(e);
 }
}

Svelte 5.0 required a bit more effort to make the data reactive, and I found a solution in a comment by a Dexie.jsdeveloper in this thread. I tucked it into utils.index.ts.

So where’s the solution to the problem? It’s in every add<Entity> function. In IndexedDB, files are literally written without any additional manipulation with formats, base64 encoding, or other frontend tricks.

An object is created, partially matching the interface (without the id field, which is auto-generated by IndexedDB), then a method is called that creates the entity in the database. And the file is saved locally!

It’s important to note that browsers have limits on file sizes. For instance, Chrome allows up to 80% of disk size, while Firefox caps it at around 2GB. More details here.

You can find the full source code here: https://github.com/Unimate/dexie-storage/

Looking for a developer to join your project?

Get in touch with us — or explore our available developers here.

Author / Kirill T. / Frontend Lead, Developer

The latest news

See all
Up