Skip to content

feat: add IndexedDB storage engine as alternative to localStorage#270

Open
leoromanovsky wants to merge 5 commits intomainfrom
feat/indexeddb-storage-alternative
Open

feat: add IndexedDB storage engine as alternative to localStorage#270
leoromanovsky wants to merge 5 commits intomainfrom
feat/indexeddb-storage-alternative

Conversation

@leoromanovsky
Copy link
Member

Motivation

We've encountered storage quota limitations with localStorage in production environments, particularly when storing large flag configurations. LocalStorage has a typical storage limit of ~5-10MB, which can be insufficient for customers with extensive feature flag setups or large configuration payloads.

IndexedDB provides significantly larger storage capacity (~50MB+ in most browsers, often much more) and offers better performance characteristics for large datasets through its async-first API that doesn't block t
he main thread.

This change adds IndexedDB as an opt-in alternative to localStorage, allowing customers experiencing quota issues to switch to a more robust storage backend without requiring changes to their existing integrat
ion.

Changes

Add IndexedDBStorageEngine as an opt-in alternative to localStorage for storing flag configurations. IndexedDB provides significantly larger storage capacity (~50MB+) compared to localStorage (~5-10MB).

Key changes:

  • New IndexedDBStorageEngine implementing IStringStorageEngine interface
  • Uses LZ-string compression like LocalStorageEngine for consistency
  • Add hasIndexedDB() detection helper to configuration-factory
  • Update configurationStorageFactory to support IndexedDB with useIndexedDB flag
  • Add useIndexedDB configuration option to IClientConfig
  • Proper error handling for quota exceeded and IndexedDB-specific errors

IndexedDB is opt-in only. LocalStorage remains the default storage mechanism, ensuring no breaking changes for existing users.

Storage Priority Order

When useIndexedDB: true is configured:

  1. Custom persistentStore (if provided)
  2. IndexedDB (if available and opted in) ⬅️ NEW
  3. Chrome storage (if available)
  4. LocalStorage (if available)
  5. Memory-only (fallback)

When useIndexedDB: false or not set (default):

  1. Custom persistentStore (if provided)
  2. Chrome storage (if available)
  3. LocalStorage (if available) ⬅️ DEFAULT (unchanged)
  4. Memory-only (fallback)

Usage

await EppoSdk.init({
  apiKey: 'your-api-key',
  assignmentLogger: yourLogger,
  useIndexedDB: true, // Opt into IndexedDB storage
});

Rollout Plan

Phase 1: Internal Dogfooding

  • Deploy to eppo.cloud with useIndexedDB: true enabled
  • Monitor for any issues, performance regressions, or storage errors
  • Verify IndexedDB storage in browser DevTools
  • Test across different browsers (Chrome, Firefox, Safari, Edge)
  • Validate that configuration persists correctly and fallback works as expected

Phase 2: Customer Notification

  • Notify the customer experiencing quota issues about the new useIndexedDB option
  • Provide documentation on how to enable the flag

Phase 3: Datadog Implementation Rollout

  • Once validated on eppo.cloud and with initial customer, roll forward to Datadog implementation

labels: mergeable

Eppo Internal
//: # (Link to the issue or doc corresponding to this chunk of work)
🎟️ Fixes: #issue
📜 Design Doc (if applicable)

Motivation and Context

Description

How has this been documented?

How has this been tested?

Add IndexedDBStorageEngine as an opt-in alternative to localStorage
for storing flag configurations. IndexedDB provides significantly
larger storage capacity (~50MB+) compared to localStorage (~5-10MB).

Key changes:
- New IndexedDBStorageEngine implementing IStringStorageEngine interface
- Uses LZ-string compression like LocalStorageEngine for consistency
- Add hasIndexedDB() detection helper to configuration-factory
- Update configurationStorageFactory to support IndexedDB with useIndexedDB flag
- Add useIndexedDB configuration option to IClientConfig
- Proper error handling for quota exceeded and IndexedDB-specific errors

IndexedDB is opt-in only. LocalStorage remains the default storage
mechanism, ensuring no breaking changes for existing users.

Storage priority when useIndexedDB=true:
1. Custom persistentStore (if provided)
2. IndexedDB (if available and opted in)
3. Chrome storage (if available)
4. LocalStorage (if available)
5. Memory-only (fallback)
Extend IndexedDB implementation to support the assignment cache in
addition to the configuration store. The assignment cache is stored
as a blob (serialized Map) for efficient storage and retrieval.

Key changes:
- New IndexedDBAssignmentShim implementing Map<string, string> interface
- Stores assignment cache as blob in IndexedDB 'assignments' object store
- New IndexedDBAssignmentCache wrapper class
- Update assignmentCacheFactory to support IndexedDB with useIndexedDB flag
- Add useIndexedDB to IPrecomputedClientConfig interface
- IndexedDB database now has three object stores:
  - 'contents': compressed flag configuration
  - 'meta': configuration metadata
  - 'assignments': assignment cache blob

Storage priority for assignment cache when useIndexedDB=true:
1. Chrome storage (if available)
2. IndexedDB (if available and opted in)
3. LocalStorage (if available)
4. Memory-only (fallback)

Assignment cache data is stored as a serialized array of Map entries
for efficient blob storage, providing ~50MB+ capacity vs localStorage's
~5-10MB limit.
@leoromanovsky leoromanovsky marked this pull request as ready for review January 13, 2026 03:27
* IndexedDB-backed Map implementation for assignment cache.
*
* Stores the entire assignment cache as a single blob in IndexedDB for efficiency.
* This provides better storage capacity (~50MB+) compared to localStorage (~5-10MB)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea thanks for mentioning that; I remember reading that it's basically using the host's storage and should be closer to GBs.

/**
* IndexedDB-backed Map implementation for assignment cache.
*
* Stores the entire assignment cache as a single blob in IndexedDB for efficiency.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered storing each key individually? IndexedDB is essentially a key-value store and we can use arrays as keys (e.g., [keySuffix, flagKey, subjectKey]), so that should be quite efficient and we don't need to load megabytes into main memory.

Copy link
Member Author

@leoromanovsky leoromanovsky Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did - we used to do this with local storage (during each flag key) but this is challenging because of the way we receive configuration updates; we need to deleting any flag keys that are absent in the received configuration payload. Without doing this we'd have stale flag configuration. This was challenging in local storage since we can't iterate over keys but now that you mention it should be doable in IDB.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided not to do per-key because we are using the HybridStore abstraction; each of these AsyncStores is paired with a sync serving store; on SDK start the serving store (in-memory) is populated; however seems if we switch to per-flag storage we can simplify this and serve directly but I'll leave that for another PR or the datadog implementation.

I did change it to store native javascript objects though instead of serialized json string and no more compression.

servingStoreUpdateStrategy = 'always',
hasChromeStorage = false,
hasWindowLocalStorage = false,
hasIndexedDB = false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: a bit surprised to see hasIndexedDB as a parameter here and as a function below. Are there any cases when we don't want to use that function?

…sion

- Remove LZ-string compression from IndexedDBStorageEngine
- Store configuration as native JavaScript objects using structured clone
- Update JSDoc to reflect accurate storage capacity (gigabytes vs ~50MB)
- Add unit tests for IndexedDB native object storage
Simplify IndexedDBStorageEngine to store JSON strings directly instead of
parsing to native objects. This matches the IStringStorageEngine interface
contract and removes unnecessary JSON.parse/stringify roundtrip overhead.
@leoromanovsky
Copy link
Member Author

Changes from Code Review (Jan 13)

Reverted IndexedDBStorageEngine to store JSON strings directly instead of native JavaScript objects.

Context

The initial implementation stored configuration as native objects in IndexedDB, taking advantage of IndexedDB's
structured clone algorithm. However, the SDK's storage architecture—specifically the IStringStorageEngine
interface and the IsolatableHybridConfigurationStore with its serving/persistent store pattern—expects JSON
strings throughout the pipeline. The StringValuedAsyncStore wraps storage engines and calls JSON.parse() on the
string returned by getContentsJsonString().

Storing native objects meant we were:

  1. Parsing JSON -> native object on write
  2. Storing native object in IndexedDB
  3. Stringifying back to JSON on read
  4. Having StringValuedAsyncStore parse it again

This added overhead without benefit since the interfaces force us back to strings anyway.

What I changed

Simplified to store JSON strings directly, matching what the interface expects. This removes the unnecessary
serialization roundtrip.

Future consideration

To fully leverage IndexedDB's native object storage (avoiding JSON parse/stringify entirely), we'd need a larger
refactor of the storage interfaces: potentially a parallel IObjectStorageEngine interface and corresponding changes
to how the hybrid store handles typed data. That's out of scope for this PR which has an immediate goal to allow storage of configurations that exceed local storage limits.

Copy link
Contributor

@aarsilv aarsilv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! Exciting to potentially take advantage of better tech and fold these learnings into Datadog SDKs as well.

if (chromeStorage) {
const chromeStorageCache = new ChromeStorageAssignmentCache(chromeStorage);
return new HybridAssignmentCache(simpleCache, chromeStorageCache);
} else if (useIndexedDB && hasIndexedDB()) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥

return new Promise<Map<string, string>>((resolve, reject) => {
request.onsuccess = () => {
const data = request.result;
if (data && Array.isArray(data)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but Array.isArray(data) will always return false if data is falsey so need for the first check in that conditional.

if (Array.isArray(data)) {

/** Returns whether IndexedDB is available */
export function hasIndexedDB(): boolean {
try {
return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined' && !!window.indexedDB;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but I don't think we need the typeof window.indexedDB !== 'undefined' check as we'll be covered by !!window.indexedDB

overridesStorageKey?: string;

/**
* Use IndexedDB for storing flag configurations and assignment cache instead of localStorage.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may want to say something Use IndexedDB if available and then alter say how we'll fall back to other options if its not to be more clear on what happens.

mockTransaction.objectStore.mockReturnValue(mockStore);
mockDB.transaction.mockReturnValue(mockTransaction);

// Simulate IndexedDB storage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The joys of testing in node!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants