Angular and IndexedDB: A Perfect Pair for Progressive Web Apps

zeeshan khan
12 min readSep 26, 2023

--

In this article, I will guide you through IndexedDB and its advantages, illustrating how we can utilize the client machine as storage. This approach proves particularly valuable when developing Progressive Web Apps (PWAs). By incorporating IndexedDB, we can significantly reduce the number of server API calls.

When we minimize or eliminate unnecessary calls, we effectively lower server costs. This optimization becomes especially crucial for applications with extensive functionality. In such cases, coupling state management with IndexedDB can be a game-changer. It alleviates the complexities of real-time data synchronization.

Integrating push notifications with IndexedDB offers additional benefits. It streamlines the automation of operations such as adding, updating, and deleting data. This automation eliminates the need for manual intervention when performing these tasks.

Consider a scenario where a user is actively engaged with your app, but suddenly loses internet connectivity. If the user continues to input data, IndexedDB can store this information in a local database. Later, when the user regains internet access, the data can be seamlessly retrieved from IndexedDB and synchronized with the server. This ensures a smooth and uninterrupted user experience, even in challenging network conditions.

By harnessing the power of IndexedDB, you can enhance the efficiency and resilience of your web applications, making them more robust and user-friendly.

You can contemplate strategies for minimizing API calls. One effective approach is to check if the required data is available in IndexedDB. If the data exists in IndexedDB, your application can retrieve it directly from there, thereby avoiding additional API requests. However, in cases where the data is not present in IndexedDB, the application can then proceed to make an API request to the server. This strategy optimizes data retrieval, reducing unnecessary API calls and potentially enhancing the overall performance of your application.

Angular PWA and indexdb

Recommendation:

When implementing IndexedDB, it is essential to adhere to a structured approach. One key advantage of using push notifications is the elimination of the need for responses after performing add, edit, or delete operations. With push notifications, you can intelligently determine which tables require updates and then proceed to update IndexedDB accordingly, based on the received notifications.

This streamlined process ensures that your IndexedDB remains synchronized with the server, providing an efficient and responsive experience for your users. It simplifies data management and reduces the complexity of handling various data-related operations in your web application. By integrating push notifications effectively, you can enhance the reliability and real-time capabilities of your application, ensuring that data updates are seamlessly propagated to IndexedDB.

Perfect Combination of IndexDb

  • Push notification
  • Progressive web app
  • Background Sync
  • Optional State management

What is IndexDb?

IndexedDB, short for Indexed Database, is a low-level, NoSQL database that is natively supported by modern web browsers. It is used for storing and managing structured data on the client side within web applications. Here are some reasons why IndexedDB is commonly used:

  • Client-Side Data Storage: IndexedDB allows web applications to store data directly on the user’s device (browser) rather than relying solely on server-side databases. This can reduce server load and improve performance, especially for offline or low-connectivity scenarios.
  • Large Data Handling: It is capable of handling large amounts of structured data efficiently. IndexedDB is not limited by the size of cookies, local storage, or session storage, making it suitable for storing substantial datasets.
  • Complex Data Structures: IndexedDB supports the storage of complex data structures, including objects, arrays, and nested data, making it versatile for various types of web applications.
  • Asynchronous Operations: IndexedDB operations are asynchronous, which means they won’t block the main thread of the web page. This helps maintain a smooth user experience and responsiveness in the browser.
  • Indexed Searching: As the name suggests, IndexedDB is designed for indexed data retrieval. You can define indexes on specific fields of your data, which enables fast and efficient querying of the data.
  • Offline Data Access: IndexedDB allows web applications to provide functionality even when the device is offline. Data can be cached and retrieved from IndexedDB, making offline access to web apps possible.
  • Data Privacy: Data stored in IndexedDB is sandboxed to the specific origin (the combination of protocol, host, and port) that created it. This provides a level of data isolation and security.
  • Cross-Browser Support: IndexedDB is supported by most modern web browsers, including Chrome, Firefox, Safari, Edge, and Opera. This makes it a reliable choice for cross-browser web development.
  • Web Workers: IndexedDB can be used in conjunction with web workers to perform database operations in the background, preventing UI freezes due to heavy data processing.
  • Progressive Web Apps (PWAs): IndexedDB is often used in the development of PWAs, which are web applications that provide an app-like experience, including offline functionality and quick data retrieval.

While IndexedDB provides many advantages, it’s important to note that it has a relatively complex API compared to simpler data storage solutions like localStorage or secession storageDevelopers often use wrapper libraries or abstractions to simplify working with IndexedDB in their applications.

In summary, IndexedDB is a valuable tool for web developers when it comes to client-side data storage, handling large datasets, and providing offline capabilities in web applications.

Third-Party libraries for indexdb

When working with IndexedDB in your web applications, you can make use of third-party libraries and abstractions to simplify and enhance your development process. Here are some popular third-party libraries and frameworks that can assist you in working with IndexedDB effectively:

Dexie.js:

  • Dexie.js is a popular and user-friendly IndexedDB wrapper library that provides a simple and elegant API for database operations. It offers features like chaining queries and supports async/await for handling asynchronous operations.

LocalForage:

  • LocalForage is a JavaScript library that abstracts various web storage technologies, including IndexedDB. It provides a consistent API for storing and retrieving data, making it easy to switch between storage backends without changing your code.

PouchDB:

  • PouchDB is a JavaScript database that is designed to work offline and seamlessly synchronize data with CouchDB and other compatible databases. It can be used with Angular and other web frameworks to provide offline capabilities.

RxDB:

  • RxDB is a real-time database for JavaScript applications, including Angular. It is built on top of PouchDB and RxJS, making it suitable for building reactive applications with offline support.

idb:

  • idb is a small, promise-based IndexedDB wrapper library that simplifies the use of IndexedDB in your applications. It provides a clean and minimalistic API for common database operations.

Local base:

  • Localbase is a lightweight JavaScript library for working with IndexedDB. It is designed to be easy to use and integrates well with Vue.js, although it can also be used with other frameworks like Angular.

Dexie-ORM:

  • Dexie-ORM is an extension library for Dexie.js that adds object-relational mapping (ORM) capabilities to IndexedDB. It allows you to define data models and relationships between objects, making complex data management easier.

angular-indexedDB:

  • This is an Angular-specific library that simplifies the integration of IndexedDB with Angular applications. It provides services and decorators for managing IndexedDB operations within your Angular components.

ng-idb:

  • ng-idb is another Angular-specific library that provides a set of Angular services and decorators for working with IndexedDB. It offers an Angular-friendly interface for database operations.

idb-keyval:

  • idb-keyval is a lightweight and minimalistic library for working with IndexedDB. It provides a simple key-value store interface, making it easy to get started with IndexedDB without complex abstractions.

These libraries can save you time and effort when working with IndexedDB in your Angular applications. Depending on your project’s requirements and your familiarity with these libraries, you can choose the one that best suits your needs.

In this article, I will guide you to dexie.js.

Creating IndexedDB: Step-by-Step Guide

Step 1:

Install Library

npm i dexie

Step 2:

In order to effectively utilize IndexedDB, it is crucial to establish schemas, defining the structure of your database, including table names and columns. For instance, within our database, we have created two distinct tables: one designated as the “Unit” table and the other as the “User” table. These schemas serve as the foundation for organizing and managing data within IndexedDB, ensuring that information is stored and retrieved in a structured and systematic manner.


// unit interface and class
export interface IUnit {
id: string;
units: string;
inserted_at: string;
inserted_by: string;
updated_at: string;
updated_by: string;
}

export class Unit implements IUnit {
id = '';
units = '';
inserted_at = '';
inserted_by = '';
updated_at = '';
updated_by = '';
}
//User interface and class
export interface IUser {
id: number;
user_type_id: number;
user_type_name: string;
user_role_id: number;
user_role_name: string;
parent_user_id?: string;
user_name_company_name?: string;
first_name: string;
last_name: string;
gender: string;
cnic: string;
cnic_expiry: string;
dob: string;
email: string;
password: string;
phone_code: string;
phone: string;
status: string;
active_status: string;
address: string | null;
}

export class User implements IUser {
id = 0;
user_type_id = 0;
user_type_name = '';
user_role_id = 0;
user_role_name = '';
parent_user_id?: string;
user_name_company_name?: string;
first_name = '';
last_name = '';
gender = '';
cnic = '';
cnic_expiry = '';
dob = '';
email = '';
password = '';
phone_code = '';
phone = '';
status = '';
active_status = '';
address: string | null = null;
}
import Dexie, { Collection } from 'dexie';
import { EntityStateEnum } from '../enum/idb.enum';

export interface ITableSchema {
name: string;
schema: string;
}

export interface IDexieTableSchema {
name: string;
primKey: { src: string };
indexes: { src: string }[];
}

export interface IFilterDelegate {
(dbSet: Dexie.Table): Collection;
}

export interface IEntitySyncDTO {
Entity: object;
State: EntityStateEnum;
Table: string;
}

And we need extra table that we need to maintain log table are loaded or not for that we need loaded table

export class LoadedStores {
Id: number;
User!: boolean;
Unit!: boolean;

constructor() {
this.Id = 1;
this.User = false;
this.Unit = false;
}
}

Step 3:

After creating the interfaces and classes, it’s time to establish the database store.

//idb.store.model.ts
import { LoadedStores } from '../model/loaded.store';
import { Unit } from '../model/unit.model';
import { User } from '../model/user.model';

const userInstance = new User();
const unitInstance = new Unit();
const loadedStoresInstance = new LoadedStores();

// Define a generic function to generate columns with a constraint
function generateColumns<T extends Record<string, any>>(instance: T): string {
return (Object.keys(instance) as (keyof T)[]).join(',');
}

export const DBStores = {
User: {
TableName: 'User',
Columns: generateColumns(userInstance),
},
Unit: {
TableName: 'Unit',
Columns: generateColumns(unitInstance),
},
LoadedStores: {
TableName: 'LoadedStores',
Columns: generateColumns(loadedStoresInstance),
},
};

Step 4:

After creating a store, the next step involves initializing the database by specifying its version. For better comprehension, consider conducting a dry run or debugging the code to gain a deeper understanding. Ensure that method names are meaningful, enabling easy comprehension of their purpose. You can also refer to the Dexie.js documentation in parallel and contemplate its concepts to further enhance your understanding.

import { Injectable } from '@angular/core';
import Dexie, { TableSchema } from 'dexie';
import { Unit } from '../model/unit.model';
import { IUnit } from '../index-db-interfaces/unit.interface';
import { IUser } from '../index-db-interfaces/user.interface';
import { DBStores } from './idb.store.model';
import {
IDexieTableSchema,
ITableSchema,
} from '../index-db-interfaces/idb.interface';
import { LoadedStores } from '../model/loaded.store';

@Injectable({
providedIn: 'root',
})
export class AppDatabase extends Dexie {
User!: Dexie.Table<IUser, string>;
Unit!: Dexie.Table<IUnit, string>;
LoadedStores!: Dexie.Table<LoadedStores, number>;

versionNumber: number = 1;
private dbName: string = 'index-db-app';
constructor() {
super('index-db-app');
this.setIndexDbTable();
this.seedData();
}

seedData() {
this.on('populate', async () => {
await this.LoadedStores.add(new LoadedStores());
});
}

setIndexDbTable() {
this.version(this.versionNumber).stores(this.setTablesSchema());
console.log('database initialized');
this.User = this.table(DBStores.User.TableName);
this.Unit = this.table(DBStores.Unit.TableName);
}

private setTablesSchema() {
return Object.entries(DBStores).reduce((tables, [key, value]) => {
tables[value.TableName] = value.Columns;
return tables;
}, {} as Record<string, string>);
}

async migrateDB() {
if (await Dexie.exists(this.dbName)) {
const declaredSchema = this.getCanonicalComparableSchema(this);
const dynDb = new Dexie(this.dbName);
const installedSchema = await dynDb
.open()
.then(this.getCanonicalComparableSchema);
dynDb.close();
if (declaredSchema !== installedSchema) {
console.log('Db schema is not updated, so deleting the db.');
await this.clearDB();
}
}
}

getCanonicalComparableSchema(db: Dexie): string {
const tableSchemas: ITableSchema[] = db.tables.map((table) =>
this.getTableSchema(table)
);
return JSON.stringify(
tableSchemas.sort((a, b) => (a.name < b.name ? 1 : -1))
);
}

getTableSchema(table: {
name: string;
schema: IDexieTableSchema;
}): ITableSchema {
const { name, schema } = table;
const indexSources = schema.indexes.map((idx) => idx.src).sort();
const schemaString = [schema.primKey.src, ...indexSources].join(',');
return { name, schema: schemaString };
}

async clearDB() {
console.log('deleting DB...');
this.close();
await this.delete();
await this.open();
console.log('DB deleted.');
}
}

Step 5 :

After initializing the database, the next crucial step is to set up the caching service. Technically, this entails obtaining instances of the tables that allow you to carry out CRUD (Create, Read, Update, Delete) operations.

import { Injectable } from '@angular/core';
import { DexieCrudService } from './dexie-crud.service';
import { IUser } from '../index-db-interfaces/user.interface';
import { IUnit } from '../index-db-interfaces/unit.interface';
import { AppDatabase } from './init.idb.service';
import { LoadedStores } from '../model/loaded.store';

@Injectable({
providedIn: 'root',
})
export class CacheService {
User!: DexieCrudService<IUser, string>;
Unit!: DexieCrudService<IUnit, string>;
LoadedStores!: DexieCrudService<LoadedStores, number>;

constructor(appDatabase: AppDatabase) {
this.User = new DexieCrudService<IUser, string>(appDatabase.User);
this.Unit = new DexieCrudService<IUnit, string>(appDatabase.Unit);
this.LoadedStores = new DexieCrudService<LoadedStores, number>(
appDatabase.LoadedStores
);
}
}

Step 6:

We require a CRUD (Create, Read, Update, Delete) service that encapsulates the necessary CRUD methods, simplifying the process of performing these operations efficiently.

import { Inject, Injectable } from '@angular/core';
import { Collection, Dexie } from 'dexie';
import { IFilterDelegate } from '../index-db-interfaces/idb.interface';

export class DexieCrudService<T, Tkey> {
dbSet: Dexie.Table<T, Tkey>;

constructor(dbSet: Dexie.Table<T, Tkey>) {
this.dbSet = dbSet;
}

getAll(filterDelegate: IFilterDelegate | undefined = undefined) {
if (!!filterDelegate) {
return filterDelegate(this.dbSet).toArray();
}
return this.dbSet.toArray();
}

async AddBulkAsync(items: T[]) {
const batchSize = 1000;
let processed = 0;

while (processed < items.length) {
const batch = items.slice(processed, processed + batchSize);
await this.dbSet.bulkPut(batch);
processed += batchSize;
}
}

getById(id: Tkey) {
return this.dbSet.get(id);
}
async AddAsync(item: T): Promise<void> {
await this.dbSet.add(item);
}

async AddOrEditAsync(item: T): Promise<void> {
await this.dbSet.put(item);
}

async UpdateAsync(id: Tkey, changes: Partial<T>): Promise<void> {
await this.dbSet.update(id, changes);
}

async RemoveAsync(id: Tkey): Promise<void> {
await this.dbSet.delete(id);
}

async RemoveRangeAsync(ids: Tkey[]): Promise<void> {
await this.dbSet.bulkDelete(ids);
}
}

Step 7:

We are now introducing an additional layer, situated above the existing structure. Within this top layer, all six steps will be orchestrated to ensure seamless execution.

//data service 
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { Subject } from 'rxjs/internal/Subject';
import { EntityStateEnum } from '../enum/idb.enum';
import { ChunkLoadStrategy } from '../index-db-interfaces/api-base-response.interface';
import { IEntitySyncDTO } from '../index-db-interfaces/idb.interface';
import { CacheService } from './cache.service';
import { ApiHandlerService } from './http-service/api-handler.service';

@Injectable({ providedIn: 'root' })
export class DataService {
private refreshSubject: Subject<IEntitySyncDTO> = new Subject();

constructor(
private apiService: ApiHandlerService,
private cacheService: CacheService
) {}

get refreshObserver(): Observable<IEntitySyncDTO> {
return this.refreshSubject.asObservable();
}

/**
*
* @param repo Name of EntityRepo to be used
* @param endpoint API Endpoint with API URL
* @returns list of data fetched from api or cache
*/
async getListAsync(
repo: string,
endpoint: string,
filterDelegate: any = undefined,
chunkLoadStrategy: ChunkLoadStrategy | undefined = undefined
) {
// get data from cache first if availble

let cacheData = await (this.cacheService as any)[repo].getAll(
filterDelegate
);

// if cache data is available then return the data
let isCachedDataAvailable = cacheData?.length > 0;
if (
isCachedDataAvailable ||
(!isCachedDataAvailable && (await this.isStoreLoaded(repo)))
) {
return cacheData;
}
let apiData =
chunkLoadStrategy === undefined
? await this.apiService.GetAll(endpoint).toPromise()
: await this.apiService.GetAllChunks(endpoint, chunkLoadStrategy);
if (apiData?.status) {
// if API call was successful and there is any data then add the data to cache
if (apiData?.response?.length > 0) {
await (this.cacheService as any)[repo].AddBulkAsync(apiData?.response);
}
await this.loadClientDbStore(repo);
if (!!filterDelegate) {
return await (this.cacheService as any)[repo].getAll(filterDelegate);
}

return apiData.response;
} else {
// TODO
// if some error occurs then show a dialog
console.error('Error in Data Service: ', apiData?.response);
}
}

async updateCache(data: IEntitySyncDTO) {
//if store is not loaded then no need of sync notification
if (!(await this.isStoreLoaded(data.Table))) {
return;
}

// add record to cache
if (data.State == EntityStateEnum.Added) {
await (this.cacheService as any)[data.Table].AddOrEditAsync(data.Entity);
}
// delete record from cache
if (data.State == EntityStateEnum.Deleted) {
let entity: any = data.Entity;
await (this.cacheService as any)[data.Table].RemoveAsync(entity.Id);
}
// update record from cache
if (data.State == EntityStateEnum.Modified) {
let entity: any = data.Entity;
await (this.cacheService as any)[data.Table].UpdateAsync(
entity.Id,
entity
);
}
this.refreshSubject.next(data);
}

async isStoreLoaded(storeName: string) {
let record = await this.cacheService.LoadedStores.getById(1);
if (record && (record as any)[storeName] == true) {
return true;
}
return false;
}

async loadClientDbStore(storeName: string) {
let patch = {};
(patch as any)[storeName] = true;
await this.cacheService.LoadedStores.UpdateAsync(1, { ...patch });
}
}

step 8:

How can you utilize this setup to facilitate API calls?

//App compoent
import { DataService } from './../index-db/sevices/data.service';
import { Component } from '@angular/core';
import { IUser } from 'src/index-db/index-db-interfaces/user.interface';
import { DBStores } from 'src/index-db/sevices/idb.store.model';
import { API_ENDPOINTS } from './constants/endpoints';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
title = 'indexdb-app';
constructor(private dataService: DataService) {}
async ngOnInit() {
//Called after the constructor, initializing input properties, and the first call to ngOnChanges.
//Add 'implements OnInit' to the class.
let user = (await this.dataService.getListAsync(
DBStores.User.TableName,
API_ENDPOINTS.user
)) as IUser[];
console.log(user);

// let unit = (await this.dataService.getListAsync(
// DBStores.Unit.TableName,
// API_ENDPOINTS.unit
// )) as IUser[];

// console.log(unit);
}
}

Demo Picture:

indexe Db

GitHub Link:

https://github.com/zeeshankhan8838/indexDb

Conclusion:

In conclusion, by following the six outlined steps, you can efficiently implement IndexedDB in your application. Starting from defining schemas and creating the necessary database store to initializing the database and setting up caching services, these steps help streamline the integration of IndexedDB into your project. Furthermore, the inclusion of a dedicated CRUD service enhances the ability to perform data operations seamlessly. All of these components can be organized within an additional top layer, providing a structured and cohesive architecture. Ultimately, this setup empowers your application to handle API calls more effectively, improving overall performance and user experience.

--

--