Dexie remains one of the most convenient ways to work with IndexedDB in a browser application. In this post, I walk through a complete Ionic/Angular application that stores earthquake data locally, keeps it up to date, and queries it efficiently from TypeScript.
The sample application downloads earthquake data from the USGS Earthquake Hazards Program, stores the records in IndexedDB, and displays them in a mobile-friendly list. Users can filter the data by magnitude, time, and distance.
Dexie with TypeScript ¶
Current Dexie releases ship with built-in TypeScript support, so the setup is straightforward. I start by defining the shape of the data that will be stored in IndexedDB.
export interface Earthquake {
id: string;
time: number;
place: string;
mag: number;
depth: number;
distance?: number;
latLng: [number, number];
}
Next comes the database class. The class extends Dexie, declares one property for each object store, and defines the schema in the constructor.
import Dexie, {type EntityTable} from 'dexie';
export class EarthquakeDb extends Dexie {
earthquakes!: EntityTable<Earthquake, 'id'>;
constructor() {
super('Earthquake');
this.version(1).stores({
earthquakes: 'id,mag,time'
});
}
}
In Dexie 4, the EntityTable helper is a concise way to type an object store. The first generic type is the entity type, and the second generic type is the name of the primary key property. As before, the property name on the class must match the store name from stores().
With that in place, the service can keep a single typed database instance and use it everywhere else in the application.
export class EarthquakeService {
private static readonly FORTY_FIVE_MINUTES = 45 * 60 * 1000;
private static readonly ONE_HOUR = 60 * 60 * 1000;
private static readonly ONE_DAY = 24 * 60 * 60 * 1000;
private static readonly SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
private static readonly THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
private readonly db = new EarthquakeDb();
From that point on, working with the database looks like normal Dexie code, but now with code completion and type checking.
Refreshing the Data ¶
Whenever the app starts or the user triggers pull-to-refresh, the service calls refreshEarthquakes(). The method first checks when the data was last updated.
async refreshEarthquakes(): Promise<number> {
if (!navigator.onLine) {
return Promise.resolve(-1);
}
const lastUpdate = localStorage.getItem('lastUpdate');
The refresh logic then chooses the smallest USGS feed that can bring the local database up to date. If the cached data is only a few hours old, there is no reason to download the monthly file again.
const lastUpdate = localStorage.getItem('lastUpdate');
if (lastUpdate) {
const lastUpdateTs = parseInt(lastUpdate, 10);
const now = Date.now();
if (lastUpdateTs + EarthquakeService.SEVEN_DAYS < now) {
// database older than 7 days. load the 30 days file
await this.loadData('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.csv');
} else if (lastUpdateTs + EarthquakeService.ONE_DAY < now) {
// database older than 1 day. load the 7 days file
await this.loadData('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.csv');
} else if (lastUpdateTs + EarthquakeService.ONE_HOUR < now) {
// database older than 1 hour. load the 1 day file
await this.loadData('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.csv');
} else if (lastUpdateTs + EarthquakeService.FORTY_FIVE_MINUTES < now) {
// database older than 45 minutes. load the 1 hour file
await this.loadData('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.csv');
}
} else {
// no last update. load the 30 days file
await this.loadData('https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_month.csv');
}
return this.deleteOldRecords();
The loadData() method downloads the CSV file with fetch, checks response.ok, parses the payload with PapaParse, converts each row into an Earthquake object, and writes the data with bulkPut().
Because the earthquake ID is used as the primary key, bulkPut() behaves like an upsert: new rows are inserted, and existing rows are updated.
private async loadData(dataUrl: string): Promise<void> {
const response = await fetch(dataUrl);
if (!response.ok) {
throw new Error(`Request failed for ${dataUrl}: ${response.status} ${response.statusText}`);
}
const text = await response.text();
const data = Papa.parse<EarthquakeCsvRow>(text, {
header: true,
skipEmptyLines: true
});
const earthquakes: Earthquake[] = [];
for (const row of data.data) {
if (row.id) {
earthquakes.push({
time: new Date(row.time).getTime(),
place: row.place,
mag: Number(row.mag),
depth: Number(row.depth),
latLng: [Number(row.latitude), Number(row.longitude)],
id: row.id
});
}
}
await this.db.transaction('rw', this.db.earthquakes, async () => {
await this.db.earthquakes.bulkPut(earthquakes);
localStorage.setItem('lastUpdate', Date.now().toString());
});
}
The import runs inside an awaited read-write transaction. That keeps the write and the lastUpdate timestamp in sync and avoids leaving the database half updated if something fails during the import.
Deleting Old Records ¶
After each refresh, the service deletes entries older than 30 days so the local database does not grow without bounds.
private deleteOldRecords(): Promise<number> {
const thirtyDaysAgo = Date.now() - EarthquakeService.THIRTY_DAYS;
return this.db.earthquakes.where('time').below(thirtyDaysAgo).delete();
}
Querying the Data ¶
All filter logic lives in the filter() method, which returns the matching earthquakes as a promise.
async filter(filter: Filter): Promise<Earthquake[]> {
const hasMagFilter = !(filter.mag.lower === -1 && filter.mag.upper === 10);
const hasDistanceFilter = !(filter.distance.lower === 0 && filter.distance.upper === 20000);
const hasTimeFilter = filter.time !== '-1';
const now = new Date();
let result: Earthquake[];
if (hasMagFilter && !hasTimeFilter) {
result = await this.db.earthquakes.where('mag').between(filter.mag.lower, filter.mag.upper, true, true).toArray();
} else if (!hasMagFilter && hasTimeFilter) {
now.setHours(now.getHours() - parseInt(filter.time, 10));
result = await this.db.earthquakes.where('time').aboveOrEqual(now.getTime()).toArray();
} else if (hasMagFilter && hasTimeFilter) {
now.setHours(now.getHours() - parseInt(filter.time, 10));
result = await this.db.earthquakes.where('time').aboveOrEqual(now.getTime()).toArray();
result = result.filter(e => e.mag >= filter.mag.lower && e.mag <= filter.mag.upper);
} else {
result = await this.db.earthquakes.toArray();
}
let filtered: Earthquake[] = [];
if (hasDistanceFilter || filter.sort === 'distance') {
result.forEach(r => {
const distanceInKilometers = getDistance(
{latitude: r.latLng[0], longitude: r.latLng[1]},
{latitude: filter.myLocation.latitude, longitude: filter.myLocation.longitude}) / 1000;
if (hasDistanceFilter) {
if (filter.distance.lower <= distanceInKilometers && distanceInKilometers <= filter.distance.upper) {
r.distance = distanceInKilometers;
filtered.push(r);
}
} else {
r.distance = distanceInKilometers;
filtered.push(r);
}
});
} else {
filtered = result;
}
if (filter.sort === 'mag') {
return filtered.sort((a, b) => b.mag - a.mag);
}
if (filter.sort === 'distance') {
return filtered.sort((a, b) => {
if (a.distance && b.distance) {
return a.distance - b.distance;
} else if (!a.distance && b.distance) {
return -1;
} else if (a.distance && !b.distance) {
return 1;
} else {
return 0;
}
});
}
return filtered.sort((a, b) => b.time - a.time);
}
For a pure magnitude filter, the code uses between() on the indexed mag field.
if (hasMagFilter && !hasTimeFilter) {
result = await this.db.earthquakes.where('mag').between(filter.mag.lower, filter.mag.upper, true, true).toArray();
For a pure time filter, it uses aboveOrEqual() on the indexed time field.
} else if (!hasMagFilter && hasTimeFilter) {
now.setHours(now.getHours() - parseInt(filter.time, 10));
result = await this.db.earthquakes.where('time').aboveOrEqual(now.getTime()).toArray();
When both filters are active, the code first loads the time-based subset and then applies the magnitude check in JavaScript.
} else if (hasMagFilter && hasTimeFilter) {
now.setHours(now.getHours() - parseInt(filter.time, 10));
result = await this.db.earthquakes.where('time').aboveOrEqual(now.getTime()).toArray();
result = result.filter(e => e.mag >= filter.mag.lower && e.mag <= filter.mag.upper);
That approach works well for this application because the time predicate already reduces the dataset significantly, and toArray() can take advantage of IndexedDB's native getAll() implementation in modern browsers. As always, the right trade-off depends on your data volume and memory budget.
Distance filtering is also performed in JavaScript. Because the app can run on a mobile device, the distance has to be recalculated against the user's current position each time the filter changes. I use the geolib library for that calculation.
if (hasDistanceFilter || filter.sort === 'distance') {
result.forEach(r => {
const distanceInKilometers = getDistance(
{latitude: r.latLng[0], longitude: r.latLng[1]},
{latitude: filter.myLocation.latitude, longitude: filter.myLocation.longitude}) / 1000;
if (hasDistanceFilter) {
if (filter.distance.lower <= distanceInKilometers && distanceInKilometers <= filter.distance.upper) {
r.distance = distanceInKilometers;
filtered.push(r);
}
} else {
r.distance = distanceInKilometers;
filtered.push(r);
}
});
} else {
filtered = result;
The last step sorts the filtered result according to the selected sort mode.
if (filter.sort === 'mag') {
return filtered.sort((a, b) => b.mag - a.mag);
}
if (filter.sort === 'distance') {
return filtered.sort((a, b) => {
if (a.distance && b.distance) {
return a.distance - b.distance;
} else if (!a.distance && b.distance) {
return -1;
} else if (a.distance && !b.distance) {
return 1;
} else {
return 0;
}
});
}
return filtered.sort((a, b) => b.time - a.time);
You can find the complete source code for this version of the app with Dexie and the native IndexedDB version on GitHub.
- Dexie version: https://github.com/ralscha/blog/tree/master/dexiejs
- IndexedDB version: https://github.com/ralscha/blog/tree/master/indexeddb
Compared with the native IndexedDB version, the Dexie version is shorter, easier to read, and much easier to type correctly in a TypeScript project.