Dexie.js, Local-first, Svelte 5 Subscribers

DevLog - Creating a simplified local-first solution with Dexie and Svelte 5 createSubscriber

Erik
Erik
October 15, 2026 • 2 min read

Introduction

This week I used Dexie.js with Svelte 5’s createSubscriber. This turned out to be a great way for me to implement my own simplified local-first solution. It did not require buying into larger frameworks, or setting up a significant amount of backend architecture.

Here’s what I learned.

Dexie.js

Dexie is a minimalistic wrapper for IndexedDB. Dexie isn’t Svelte-specific, but Svelte makes it easy to integrate any TypeScript / JavaScript library.

After defining a schema, you can read, update, and liveQuery from IndexedDB like this. For more examples, see this Dexie cheat sheet.

import { db } from '$lib/db';

// Read Once
export async function get_tasks_by_list_id(list_id: string): Promise<Task[]> {
	return await db.tasks.where('tasks_list_id').equals(list_id).toArray()
}

// Upsert and Delete
await db.transaction('rw', db.tasks, async () => {
	await db.tasks.bulkDelete(rowsToDelete);
	await db.tasks.bulkPut(rowsToUpsert);
});

// Live Query
const subscription = liveQuery(() => get_tasks_by_list_id(list_id)).subscribe((rows) => {
	tasks = rows;
});

subscription.unsubscribe();

Create Subscriber

Svelte 5’s createSubscriber integrates external event-based systems with Svelte’s reactivity. In this snippet, we manage the subscription to a Dexie.js liveQuery using createSubscriber.

This example passes listId as arg through a $derived. This allows us to recreate the liveQuery whenever the listId changes, returning reactive live queries that update with our Svelte state.

import { liveQuery } from 'dexie';
import { createSubscriber } from 'svelte/reactivity';

export class LiveQuery<T, A> {
	#value: T | undefined;
	#subscribe: () => void;

	constructor(query: (arg: A) => T | Promise<T>, arg: A) {
		const observable = liveQuery(() => query(arg));

		this.#subscribe = createSubscriber((update) => {
			const sub = observable.subscribe({
				next: (value) => {
					this.#value = value;
					this.#error = undefined;
					update();
				},
			});
			return () => sub.unsubscribe();
		});
	}

	get current() {
		this.#subscribe();
		return this.#value;
	}
}

// Example Usage
const query = $derived(new LiveQuery(get_tasks_by_list_id, listId));

Note, calling subscribe in the getter felt weird to me at first. But, this is how the docs show it, and it enables automatic handling of our subscription.

This means the subscription will automatically start and stop whenever there is a reference to our getter. The interactive example below could also work by using query.current in an $effect.

Ref count 1, listening for changes...
{#if on}
  {query.current}
{:else}
  <!-- No getter -->
{/if}

Tomatillo Updates

For Tomatillo Timer users, an update on the tasks feature:

  • Tasks are potentially more performant on slow machines
  • Tasks now rehydrate from the server on browser focus
  • Fixed a bug where the explanation page wasn’t accessible

Comments

Join the discussion for this post on Bluesky or Discord
Discuss on Bluesky
Discuss on Discord *
* Members of the discord can use the link directly, otherwise use this invite