HTML - IndexedDB

Introduction to IndexedDB

IndexedDB is a low-level API for client-side storage of large amounts of structured data in web browsers. It provides a way to store and retrieve data locally, letting web applications work offline and give a good user experience.

IndexedDB is a NoSQL database that uses a key-value store and supports transactions and indexes. It lets you store and retrieve JavaScript objects, including complex data types like arrays and nested objects. IndexedDB provides a higher storage capacity compared to other client-side storage options like cookies and localStorage.

One of the main advantages of using IndexedDB is its ability to handle large amounts of structured data well. It is good for applications that need persistent local storage, such as offline web apps, progressive web apps (PWAs), and data-intensive applications. IndexedDB offers asynchronous operations, which means it doesn't block the main thread of execution, resulting in better performance and responsiveness.

Compared to other storage methods like cookies and localStorage, IndexedDB has several advantages:

Aspect IndexedDB Cookies localStorage
Storage capacity Gigabytes Around 4KB Usually up to 5-10MB
Structured data Supports storing complex JavaScript objects and efficient querying using indexes Limited to storing key-value pairs of strings Limited to storing key-value pairs of strings
Asynchronous API Operations are asynchronous, resulting in better performance and responsiveness Synchronous, can block the main thread Synchronous, can block the main thread
Transactional support Provides a transactional model for data operations, ensuring data integrity No transactional support No transactional support
Indexing and querying Supports the creation of indexes on object store properties for fast searching and querying No indexing or querying capabilities No indexing or querying capabilities

By using IndexedDB, web developers can build applications that work well both online and offline, providing a good user experience and handling large amounts of structured data.

Basic Concepts

To work with IndexedDB, you need to understand some basic concepts. IndexedDB is built around a key-value store, which means that data is stored and retrieved using unique keys associated with each value.

At the core of IndexedDB are object stores. An object store is like a table in a traditional database, and it holds the actual data. Each object store has a name and can store JavaScript objects of any type, including complex data structures. Object stores are created within a database and are used to organize and group related data.

Example: Creating an Object Store

let request = indexedDB.open("MyDatabase", 1);

request.onupgradeneeded = function(event) {
  let db = event.target.result;
  let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });
};

Transactions are a fundamental part of IndexedDB. All data operations, such as reading, writing, or deleting data, must happen within a transaction. Transactions provide a way to group multiple operations together and maintain data integrity. They can be read-only or read-write, depending on the type of operations being performed. If any operation within a transaction fails, the entire transaction is rolled back, and the database remains unchanged.

Example: Using a Transaction

let db = event.target.result;
let transaction = db.transaction(["MyObjectStore"], "readwrite");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.add({ id: 1, name: "John Doe" });

request.onsuccess = function(event) {
  console.log("Data has been added to your database.");
};

request.onerror = function(event) {
  console.log("Unable to add data to your database.");
};

Indexes are another important concept in IndexedDB. An index is a way to search and retrieve data from an object store based on a specific property or set of properties. Indexes allow you to quickly find data without having to go through the entire object store. You can create indexes on any property of the objects stored in an object store, and you can also create compound indexes that combine multiple properties.

Example: Creating an Index

let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });
objectStore.createIndex("name", "name", { unique: false });

Setting up IndexedDB

Before you start using IndexedDB in your web application, you need to set it up. This involves checking for browser support, opening a database, creating object stores, and defining indexes.

It's important to check if the browser supports IndexedDB. You can do this by checking if the indexedDB property exists on the window object.

Example: Checking for IndexedDB Support

if (window.indexedDB) {
  console.log("IndexedDB is supported");
} else {
  console.log("IndexedDB is not supported");
}

Once you have confirmed that IndexedDB is supported, the next step is to open a database. You can open a database by calling the open() method on the indexedDB object. This method takes two parameters: the name of the database and the version number. If the database doesn't exist, it will be created; otherwise, the existing database will be opened.

Example: Opening a Database

let request = indexedDB.open("MyDatabase", 1);

request.onerror = function(event) {
  console.log("Error opening database");
};

request.onsuccess = function(event) {
  let db = event.target.result;
  console.log("Database opened successfully");
};

When opening a database, you can also specify a version number. If the version number is higher than the existing version, the onupgradeneeded event will be triggered, allowing you to change the database structure, such as creating or modifying object stores and indexes.

Inside the onupgradeneeded event handler, you can create object stores using the createObjectStore() method on the database object. You need to provide a name for the object store and specify the key path, which is the property that uniquely identifies each object in the store.

Example: Creating Object Store

request.onupgradeneeded = function(event) {
  let db = event.target.result;
  let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });
};

After creating an object store, you can define indexes on specific properties of the objects stored in the object store. Indexes allow you to search and get data based on those properties. To create an index, you can use the createIndex() method on the object store.

Example: Defining Indexes

request.onupgradeneeded = function(event) {
  let db = event.target.result;
  let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });
  objectStore.createIndex("name", "name", { unique: false });
  objectStore.createIndex("age", "age", { unique: false });
};

We create two indexes: one on the "name" property and another on the "age" property. The unique parameter specifies whether the index values should be unique or not.

Performing CRUD Operations

Creating Data

To add data to an object store in IndexedDB, you need to use transactions. First, open a transaction on the desired object store with the "readwrite" mode. Then, get a reference to the object store using the objectStore() method on the transaction object. Finally, use the add() or put() method on the object store to add the data.

Example: Adding data to IndexedDB

let db;
let request = indexedDB.open("MyDatabase", 1);

request.onsuccess = function(event) {
  db = event.target.result;
  addData({ id: 1, name: "John Doe", age: 25 });
};

function addData(data) {
  let transaction = db.transaction(["MyObjectStore"], "readwrite");
  let objectStore = transaction.objectStore("MyObjectStore");

  let request = objectStore.add(data);

  request.onsuccess = function(event) {
    console.log("Data added successfully");
  };

  request.onerror = function(event) {
    console.log("Error adding data");
  };
}

The add() method is used to add new data, while the put() method can be used to add new data or update existing data if the key already exists.

It's important to handle errors that may occur during the data addition process. You can use the onerror event handler on the request object to catch and handle any errors.

Reading Data

To get data from an object store, you can use the get() method on the object store. You need to provide the key of the data you want to get.

Example: Getting data from IndexedDB

let transaction = db.transaction(["MyObjectStore"], "readonly");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.get(1);

request.onsuccess = function(event) {
  let data = event.target.result;
  console.log("Retrieved data:", data);
};

If you want to search for data based on a specific property, you can use indexes. First, get a reference to the index using the index() method on the object store. Then, use the get() method on the index to get data based on the index key.

Example: Using indexes to get data

let transaction = db.transaction(["MyObjectStore"], "readonly");
let objectStore = transaction.objectStore("MyObjectStore");
let index = objectStore.index("name");

let request = index.get("John Doe");

request.onsuccess = function(event) {
  let data = event.target.result;
  console.log("Retrieved data:", data);
};

For more advanced querying or going through multiple data entries, you can use cursors. Cursors allow you to go through all the data in an object store or index. You can open a cursor using the openCursor() method on the object store or index.

Example: Using cursors in IndexedDB

let transaction = db.transaction(["MyObjectStore"], "readonly");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.openCursor();

request.onsuccess = function(event) {
  let cursor = event.target.result;
  if (cursor) {
    console.log("Key:", cursor.key);
    console.log("Data:", cursor.value);
    cursor.continue();
  } else {
    console.log("No more data");
  }
};

In the example above, we open a cursor on the object store. The onsuccess event handler is called for each data entry. We can access the key and value of the current entry using cursor.key and cursor.value, respectively. To go to the next entry, we call the continue() method on the cursor.

Updating Data

To update existing data in an object store, you can use the put() method. It works similarly to the add() method, but if the key already exists, it will update the corresponding data.

Example: Updating data in IndexedDB

let transaction = db.transaction(["MyObjectStore"], "readwrite");
let objectStore = transaction.objectStore("MyObjectStore");

let updatedData = { id: 1, name: "John Doe", age: 26 };
let request = objectStore.put(updatedData);

request.onsuccess = function(event) {
  console.log("Data updated successfully");
};

You can also update specific fields of an existing data entry using the put() method. First, get the data using the get() method, update the desired fields, and then use put() to save the changes.

Example: Partially updating data in IndexedDB

let transaction = db.transaction(["MyObjectStore"], "readwrite");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.get(1);

request.onsuccess = function(event) {
  let data = event.target.result;
  data.age = 27;

  let updateRequest = objectStore.put(data);

  updateRequest.onsuccess = function(event) {
    console.log("Data updated successfully");
  };
};

When updating data, be mindful of versioning and schema changes. If you need to change the structure of your object stores or indexes, you should handle it in the onupgradeneeded event handler and properly version your database.

Deleting Data

To remove data from an object store, you can use the delete() method on the object store. You need to provide the key of the data you want to remove.

Example: Deleting data from IndexedDB

let transaction = db.transaction(["MyObjectStore"], "readwrite");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.delete(1);

request.onsuccess = function(event) {
  console.log("Data removed successfully");
};

If you want to clear an entire object store, you can use the clear() method on the object store.

Example: Clearing an IndexedDB object store

let transaction = db.transaction(["MyObjectStore"], "readwrite");
let objectStore = transaction.objectStore("MyObjectStore");

let request = objectStore.clear();

request.onsuccess = function(event) {
  console.log("Object store cleared successfully");
};

To delete a database, you can use the deleteDatabase() method on the indexedDB object. This will completely remove the database and all its object stores.

Example: Deleting an IndexedDB database

let request = indexedDB.deleteDatabase("MyDatabase");

request.onsuccess = function(event) {
  console.log("Database deleted successfully");
};

Be cautious when deleting data, as it is a permanent operation and cannot be undone.

Advanced Features

IndexedDB has several advanced features that let you manage and optimize data in more complex ways. Let's look at some of these features in detail.

Versioning and Upgrades

IndexedDB uses versioning to manage changes to the database structure over time. Each database has a version number associated with it. When you open a database with a higher version number than the current one, the onupgradeneeded event is triggered, letting you change the database schema.

Example: Versioning and Upgrades

let request = indexedDB.open("MyDatabase", 2); // Upgrade to version 2

request.onupgradeneeded = function(event) {
  let db = event.target.result;

  // Perform database schema changes
  if (event.oldVersion < 1) {
    // Create object stores and indexes for version 1
    let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });
    objectStore.createIndex("name", "name", { unique: false });
  }

  if (event.oldVersion < 2) {
    // Make changes for version 2
    let objectStore = db.createObjectStore("AnotherObjectStore", { keyPath: "id" });
    objectStore.createIndex("category", "category", { unique: false });
  }
};

In the example above, we open the database with version 2. If the current version is lower than 2, the onupgradeneeded event is triggered. We can then check the oldVersion property to find the current version and make the necessary schema changes for each version upgrade.

Indexes and Compound Keys

Indexes in IndexedDB let you query and search data efficiently based on specific properties. You can create indexes on single properties or create compound indexes that combine multiple properties.

Example: Indexes and Compound Keys

let request = indexedDB.open("MyDatabase", 1);

request.onupgradeneeded = function(event) {
  let db = event.target.result;
  let objectStore = db.createObjectStore("MyObjectStore", { keyPath: "id" });

  // Create a single-property index
  objectStore.createIndex("name", "name", { unique: false });

  // Create a compound index
  objectStore.createIndex("nameAge", ["name", "age"], { unique: false });
};

In the example above, we create a single-property index on the "name" property and a compound index on the "name" and "age" properties. Compound indexes let you query data based on multiple properties at the same time.

Transactions and Concurrency

Transactions in IndexedDB make sure data is correct and consistent. All database operations must be done within a transaction. Transactions can be read-only or read-write, depending on the type of operations being done.

IndexedDB also supports concurrent access to the database. Multiple transactions can be active at the same time, but they operate on different object stores or have different access modes (read-only or read-write) to avoid conflicts.

Example: Transactions and Concurrency

let transaction1 = db.transaction(["ObjectStore1"], "readonly");
let transaction2 = db.transaction(["ObjectStore2"], "readwrite");

// Multiple transactions can be active concurrently
transaction1.objectStore("ObjectStore1").get(1);
transaction2.objectStore("ObjectStore2").add({ id: 1, name: "John" });

In the example above, we have two transactions active at the same time. transaction1 is read-only and operates on "ObjectStore1", while transaction2 is read-write and operates on "ObjectStore2". They can proceed independently without conflicts.

Performance Considerations

To optimize the performance of your IndexedDB usage, consider the following:

Consideration Description
Use appropriate indexes Create indexes on properties that you frequently use for querying or searching. Indexes speed up data retrieval operations.
Minimize the scope of transactions Keep transactions as small as possible and only include the necessary operations. This helps in reducing the locking duration and improves concurrency.
Use batch operations If you need to perform multiple write operations, consider using batch operations like add(), put(), or delete() within a single transaction. This minimizes the overhead of creating separate transactions for each operation.
Avoid unnecessary data retrieval Only get the data you need. Use indexes and key ranges to narrow down the result set and avoid getting unnecessary data.
Handle large data sets efficiently If you are dealing with large amounts of data, consider using techniques like pagination or lazy loading to load data in smaller chunks as needed.

By keeping these performance considerations in mind and using indexes, transactions, and concurrency well, you can build efficient and fast web applications with IndexedDB.

Security

When using IndexedDB to store data in a web application, it's important to think about security and protect sensitive information. Here are some key aspects of handling sensitive data and securing access to IndexedDB:

Handling Sensitive Data

If your application deals with sensitive data, such as personal information or financial details, you need to take extra precautions when storing and handling that data in IndexedDB. Here are some best practices:

  1. Encrypt sensitive data: Before storing sensitive information in IndexedDB, encrypt it using a strong encryption algorithm. This way, even if someone gets access to the IndexedDB data, they won't be able to read the sensitive information without the encryption key.

  2. Use secure encryption libraries: Use well-established and trusted encryption libraries or algorithms, such as AES (Advanced Encryption Standard), to encrypt and decrypt sensitive data. Don't make your own encryption algorithms, as they may have vulnerabilities.

  3. Store encryption keys securely: Keep the encryption keys used to encrypt and decrypt sensitive data secure. Don't store them in the same database as the encrypted data. Instead, think about using techniques like key derivation functions or secure key storage mechanisms provided by the platform or browser.

  4. Minimize data retention: Only store sensitive data in IndexedDB when absolutely necessary. If the data is no longer needed, securely delete it from the database. Regularly review and remove any unnecessary sensitive information to reduce the risk of data breaches.

Securing Access to IndexedDB

In addition to handling sensitive data securely, you should also control access to IndexedDB to prevent unauthorized access or changes. Here are some measures you can take:

  1. Use browser security features: Use browser security features like the Same-Origin Policy and Content Security Policy (CSP) to restrict access to IndexedDB from untrusted sources. These policies help prevent cross-site scripting (XSS) attacks and unauthorized access to IndexedDB from different origins.

Same-Origin Policy Example

<meta http-equiv="Content-Security-Policy" content="default-src 'self'">
  1. Implement user authentication: If your application requires user authentication, make sure that only authenticated users can access the IndexedDB data. Use secure authentication mechanisms, such as tokens or session management, to check the user's identity before granting access to the database.

Secure Session Management Example

// Example of token-based authentication
fetch('https://api.example.com/data', {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer ' + token
  }
})
  .then(response => response.json())
  .then(data => console.log(data));
  1. Apply access controls: Implement access controls based on user roles or permissions. Decide which users or user groups should have read or write access to specific object stores or data within IndexedDB. Use these access controls consistently throughout your application.

  2. Validate and sanitize user input: When accepting user input that will be stored in IndexedDB, validate and sanitize the input to prevent potential security vulnerabilities like SQL injection or cross-site scripting attacks. Use strict validation rules and escape or remove any malicious characters or scripts.

User Input Validation Example

const sanitizedInput = input.replace(/[<>]/g, ''); // simple sanitation
  1. Use secure communication: If your application communicates with a server to synchronize IndexedDB data, make sure that the communication channel is secure. Use HTTPS/SSL to encrypt the data transmitted between the client and the server, preventing eavesdropping or tampering.

HTTPS Communication Example

<form action="https://yourserver.com/submit" method="post">
  <input type="text" name="data">
  <input type="submit" value="Submit">
</form>

Remember, security is an ongoing process, and it's important to stay updated with the latest security best practices and vulnerabilities. Regularly review and update your security measures to protect sensitive data and keep your IndexedDB implementation safe.

Browser Compatibility and Fallbacks

When using IndexedDB in your web application, you should think about browser compatibility and provide fallback strategies for browsers that may not support IndexedDB or have limited support.

Browser support for IndexedDB has improved over time, with most modern browsers now offering good support for the API. However, there may still be some older browsers or mobile devices that have limited or no support for IndexedDB.

To check if a browser supports IndexedDB, you can use a simple feature detection technique:

Example: Feature detection for IndexedDB support

if (window.indexedDB) {
  console.log("IndexedDB is supported");
} else {
  console.log("IndexedDB is not supported");
}

If IndexedDB is not supported in a browser, you'll need to provide fallback strategies to handle data storage and retrieval. One common approach is to use other storage mechanisms as fallbacks, such as Web Storage (localStorage or sessionStorage) or cookies. These fallbacks may have limitations compared to IndexedDB, such as smaller storage capacity or lack of advanced features like indexing and transactions, but they can still provide a basic level of data persistence.

Example: Fallback strategies for data storage

if (window.indexedDB) {
  // Use IndexedDB
  let request = indexedDB.open("MyDatabase", 1);
  // ...
} else if (window.localStorage) {
  // Fallback to localStorage
  localStorage.setItem("key", "value");
  // ...
} else {
  console.log("No storage mechanism available");
}

Another option is to use polyfills or libraries that provide a consistent API for IndexedDB while internally handling browser differences and fallbacks. These tools aim to fill the gaps in browser support and let developers use IndexedDB in a more browser-independent way.

Some popular polyfills and libraries for IndexedDB include:

Polyfill/Library Description
IndexedDBShim A polyfill that adds support for IndexedDB in browsers that don't have native support.
localForage A library that provides a simple and consistent API for client-side data storage, including IndexedDB, Web Storage, and WebSQL.
Dexie.js A wrapper library for IndexedDB that provides a more intuitive and concise API for working with IndexedDB.

Example: Include the IndexedDBShim polyfill

<!-- Include the IndexedDBShim polyfill -->
<script src="indexeddbshim.min.js"></script>

Example: Use IndexedDB as usual

// Use IndexedDB as usual
let request = indexedDB.open("MyDatabase", 1);
// ...

When using polyfills or libraries, make sure to review their documentation and consider their browser support, performance impact, and any limitations they may have.

Remember to test your web application across different browsers and devices to make sure the fallback strategies work as intended and provide a good user experience even in browsers without IndexedDB support.

Practical Examples and Use Cases

IndexedDB is a powerful tool that can be used in various practical scenarios to improve web application functionality and user experience. Let's look at some common use cases and examples where IndexedDB shines.

Offline Data Storage and Sync

One of the key benefits of IndexedDB is its ability to store data offline and enable offline functionality in web applications. With IndexedDB, you can store application data locally on the user's device, allowing them to access and interact with the application even when they are not connected to the internet.

Example: Store data offline

// Store data offline
function storeDataOffline(data) {
  let transaction = db.transaction(["MyObjectStore"], "readwrite");
  let objectStore = transaction.objectStore("MyObjectStore");

  let request = objectStore.add(data);

  request.onsuccess = function(event) {
    console.log("Data stored offline");
  };
}

// Sync data when online
function syncDataWithServer() {
  let transaction = db.transaction(["MyObjectStore"], "readonly");
  let objectStore = transaction.objectStore("MyObjectStore");

  let request = objectStore.openCursor();

  request.onsuccess = function(event) {
    let cursor = event.target.result;
    if (cursor) {
      let data = cursor.value;
      sendDataToServer(data);
      cursor.continue();
    } else {
      console.log("All data synced with server");
    }
  };
}

// Send data to server
function sendDataToServer(data) {
  fetch('https://api.example.com/data', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  })
    .then(response => {
      if (response.ok) {
        console.log("Data synced with server");
      }
    });
}

The storeDataOffline function stores data in IndexedDB when the application is offline. The data is added to an object store within a transaction. Later, when the application comes back online, the syncDataWithServer function uses a cursor to go through all the stored data and sends each data item to the server using the sendDataToServer function. This way, the application can work offline and sync the data with the server when a connection is restored.

Caching Application Data

IndexedDB can be used as a caching mechanism to store frequently accessed or slow-to-load data locally. By caching data in IndexedDB, you can improve the performance and speed of your web application.

Example: Check cache for data

// Check cache for data
function getDataFromCache(key) {
  return new Promise((resolve, reject) => {
    let transaction = db.transaction(["CacheStore"], "readonly");
    let objectStore = transaction.objectStore("CacheStore");

    let request = objectStore.get(key);

    request.onsuccess = function(event) {
      let data = event.target.result;
      if (data) {
        console.log("Data retrieved from cache");
        resolve(data);
      } else {
        console.log("Data not found in cache");
        reject();
      }
    };
  });
}

// Store data in cache
function storeDataInCache(key, data) {
  let transaction = db.transaction(["CacheStore"], "readwrite");
  let objectStore = transaction.objectStore("CacheStore");

  let request = objectStore.put(data, key);

  request.onsuccess = function(event) {
    console.log("Data stored in cache");
  };
}

// Get data from cache or fetch from server
function getData(key) {
  getDataFromCache(key)
    .then(data => {
      console.log("Data retrieved from cache:", data);
    })
    .catch(() => {
      fetch(`https://api.example.com/data/${key}`)
        .then(response => response.json())
        .then(data => {
          console.log("Data retrieved from server:", data);
          storeDataInCache(key, data);
        });
    });
}

The getDataFromCache function checks if the requested data is available in the cache (IndexedDB). If the data is found, it is resolved and returned. If the data is not found in the cache, the function rejects, and the getData function fetches the data from the server using the fetch API. Once the data is retrieved from the server, it is stored in the cache using the storeDataInCache function for future access.

By implementing this caching mechanism, you can reduce the number of network requests and improve the loading speed of your application by serving data from the local cache when available.

Implementing Search Functionality

IndexedDB's indexing capabilities make it well-suited for implementing search functionality within a web application. By creating indexes on searchable fields, you can quickly search and retrieve data based on specific criteria.

Example: Create index on searchable field

// Create index on searchable field
function createSearchIndex() {
  let request = indexedDB.open("MyDatabase", 1);

  request.onupgradeneeded = function(event) {
    let db = event.target.result;
    let objectStore = db.createObjectStore("ProductStore", { keyPath: "id" });
    objectStore.createIndex("name", "name", { unique: false });
  };
}

// Search for data using index
function searchData(searchTerm) {
  let transaction = db.transaction(["ProductStore"], "readonly");
  let objectStore = transaction.objectStore("ProductStore");
  let index = objectStore.index("name");

  let request = index.getAll(IDBKeyRange.bound(searchTerm, searchTerm + '\uffff'));

  request.onsuccess = function(event) {
    let results = event.target.result;
    console.log("Search results:", results);
  };
}

// Example usage
createSearchIndex();

// Add sample data
let transaction = db.transaction(["ProductStore"], "readwrite");
let objectStore = transaction.objectStore("ProductStore");
objectStore.add({ id: 1, name: "Apple", category: "Fruit" });
objectStore.add({ id: 2, name: "Banana", category: "Fruit" });
objectStore.add({ id: 3, name: "Orange", category: "Fruit" });

// Perform search
searchData("App");

We create an index on the "name" field of the "ProductStore" object store using the createSearchIndex function. This allows efficient searching based on the product name.

The searchData function performs a search using the index. It takes a search term as input and uses the getAll method with an IDBKeyRange to find all products that match the search term.

In the example usage, we create the search index, add some sample data to the "ProductStore" object store, and then perform a search for products with "App" in their name. The search results are logged to the console.

These are just a few examples of how IndexedDB can be used in practical scenarios. IndexedDB's flexibility and powerful features make it suitable for a wide range of use cases where client-side data storage and handling are required.