1. Introduction
This section is non-normative.
The localStorage
API is widely used, and loved for its simplicity. However, its synchronous nature leads to terrible performance and cross-window synchronization issues.
This specification proposes a new API, called KV storage, which is intended to provide an analogously simple interface, while being asynchronous. Along the way, it embraces some additional goals:
-
Layer on top of Indexed Database. This avoids introducing a new type of storage for user agents and web developers to manage, and allows an upgrade path to full IndexedDB usage if a web developer outgrows the KV storage interface. [INDEXEDDB-2]
-
Modernize the API surface. Modern key/value stores in the platform, such as the
Cache
orHeaders
APIs, have aligned around the operation names given by JavaScript’sMap
. We follow their example. As a bonus, this allows us to avoid the legacy named properties feature that theStorage
interface uses. -
Support isolated storage areas.
localStorage
requires careful namespacing of keys to use robustly in a multi-actor environment. Popular libraries meant to replace it, like localForage, have included a way to create new storage areas beyond the default one.
localStorage
example to use KV storage might look like the following:
< p > You have viewed this page< span id = "count" > an untold number of</ span > time(s).</ p > < script type = "module" > ( async() => { let pageLoadCount= await kvStorage. get( "pageLoadCount" ) || 0 ; ++ pageLoadCount; document. querySelector( "#count" ). textContent= pageLoadCount; await kvStorage. set( "pageLoadCount" , pageLoadCount); })(); </ script >
As a side note, observe how, in contrast to the original example which performs up to five storage operations, our example only performs two. Also, it updates the UI as soon as possible, instead of delaying the UI update until we’ve set the new page load count.
The KV storage API design can take some credit for this, as by forcing us to explicitly state our await
points, it makes it more obvious that we’re performing a potentially-expensive storage operation.
2. The kvStorage
global
partial interface WindowOrWorkerGlobalScope { [SecureContext ]readonly attribute KVStorageArea kvStorage ; };
self .
kvStorage
-
Returns the default storage area. It is a pre-constructed instance of the
KVStorageArea
class, meant to be a convenience similar tolocalStorage
.This property is only present in secure contexts, since persistent storage is a powerful feature.
Every WindowOrWorkerGlobalScope
has an associated default KV Storage area, which is a KVStorageArea
created in that realm with [[DatabaseName]] set to "kv-storage:default
", [[DatabasePromise]] initially set to null, and [[BackingStoreObject]] initially set to null.
The kvStorage
attribute’s getter must return this's default KV Storage area.
3. The KVStorageArea
class
[SecureContext ,Exposed =(Window ,Worker )]interface KVStorageArea {constructor (DOMString );
name Promise <void >set (any ,
key any );
value Promise <any >get (any );
key Promise <void >delete (any );
key Promise <void >clear ();async iterable <any ,any >; [SameObject ]readonly attribute object backingStore ; };
Each KVStorageArea
instance must also contain the [[DatabaseName]], [[DatabasePromise]], and [[BackingStoreObject]] internal slots. The following is a non-normative summary of their meaning:
- [[DatabaseName]]
- A string containing the name of the backing IndexedDB database.
- [[DatabasePromise]]
- A promise for an
IDBDatabase
object, lazily initialized when performing any database operation. - [[BackingStoreObject]]
- The object returned by the
backingStore
getter, cached to ensure identity across gets.
3.1. constructor(name)
storage = new
KVStorageArea
(name)-
Creates a new
KVStorageArea
that provides an async key/value store view onto an IndexedDB database named`kv-storage:${name}`
.This does not actually open or create the database yet; that is done lazily when other methods are called. This means that all other methods can reject with database-related exceptions in failure cases.
-
Set this.[[DatabaseName]] to the concatenation of "
kv-storage:
" and name. -
Set this.[[DatabasePromise]] to null.
-
Set this.[[BackingStoreObject]] to null.
3.2. set(key, value)
await storage.
set
(key, value)-
Asynchronously stores the given value so that it can later be retrieved by the given key.
Keys have to follow the same restrictions as IndexedDB keys: roughly, a key can be a number, string, array,
Date
,ArrayBuffer
,DataView
, typed array, or an array of these. Invalid keys will cause the returned promise to reject with a "DataError
"DOMException
.Values can be any value that can be structured-serialized for storage. Un-serializable values will cause a "
DataCloneError
"DOMException
. The value undefined will cause the corresponding entry to be deleted.The returned promise will fulfill with undefined on success.
-
If key is not allowed as a key, return a promise rejected with a "
DataError
"DOMException
. -
Return the result of performing a database operation given this object, "
readwrite
", and the following steps operating on transaction and store:-
If value is undefined, then
-
Perform the steps listed in the description of
IDBObjectStore
'sdelete()
method on store, given the argument key.
-
-
Otherwise,
-
Perform the steps listed in the description of
IDBObjectStore
'sput()
method on store, given the arguments value and key.
-
-
Let promise be a new promise in the relevant Realm of this.
-
Add a simple event listener to transaction for "
complete
" that resolves promise with undefined. -
Add a simple event listener to transaction for "
error
" that rejects promise with transaction’s error. -
Add a simple event listener to transaction for "
abort
" that rejects promise with transaction’s error. -
Return promise.
-
3.3. get(key)
value = await storage.
get
(key)-
Asynchronously retrieves the value stored at the given key, or undefined if there is no value stored at key.
Values retrieved will be structured-deserialized from their original form.
-
If key is not allowed as a key, return a promise rejected with a "
DataError
"DOMException
. -
Return the result of performing a database operation given this object, "
readonly
", and the following steps operating on transaction and store:-
Let request be the result of performing the steps listed in the description of
IDBObjectStore
'sget()
method on store, given the argument key. -
Let promise be a new promise in the relevant Realm of this.
-
Add a simple event listener to request for "
success
" that resolves promise with request’s result. -
Add a simple event listener to request for "
error
" that rejects promise with request’s error. -
Return promise.
-
3.4. delete(key)
await storage.
delete
(key)-
Asynchronously deletes the entry at the given key. This is equivalent to storage.
set
(key, undefined).The returned promise will fulfill with undefined on success.
-
If key is not allowed as a key, return a promise rejected with a "
DataError
"DOMException
. -
Return the result of performing a database operation given this object, "
readwrite
", and the following steps operating on transaction and store:-
Perform the steps listed in the description of
IDBObjectStore
'sdelete()
method on store, given the argument key. -
Let promise be a new promise in the relevant Realm of this.
-
Add a simple event listener to transaction for "
complete
" that resolves promise with undefined. -
Add a simple event listener to transaction for "
error
" that rejects promise with transaction’s error. -
Add a simple event listener to transaction for "
abort
" that rejects promise with transaction’s error. -
Return promise.
-
3.5. clear()
await storage.
clear
()-
Asynchronously deletes all entries in this storage area.
This is done by actually deleting the underlying IndexedDB database. As such, it always can be used as a fail-safe to get a clean slate, as shown below.
The returned promise will fulfill with undefined on success.
-
Let realm be the relevant Realm of this.
-
If this.[[DatabasePromise]] is not null, return the result of reacting to this.[[DatabasePromise]] with fulfillment and rejection handlers that both perform the following steps:
-
Set this.[[DatabasePromise]] to null.
-
Return the result of deleting the database given this.[[DatabaseName]] and realm.
-
-
Otherwise, return the result of deleting the database given this.[[DatabaseName]] and realm.
-
Let promise be a new promise in realm.
-
Let request be the result of performing the steps listed in the description of
IDBFactory
'sdeleteDatabase()
method on the currentIDBFactory
, given the argument name. -
If those steps threw an exception, catch the exception and reject promise with it.
-
Otherwise:
-
Add a simple event listener to request for "
success
" that resolves promise with undefined. -
Add a simple event listener to request for "
error
" that rejects promise with request’s error.
-
-
Return promise.
// This upgrade to version 100 breaks the "cats" storage area: since StorageAreas // assume a version of 1, "cats" can no longer be used with KV storage. const openRequest= indexedDB. open( "kv-storage:cats" , 100 ); openRequest. onsuccess= () => { openRequest. onsuccess. close(); }; ( async() => { const area= new KVStorageArea( "cats" ); // Due to the above upgrade, all other methods will reject: try { await area. set( "fluffy" , new Cat()); } catch ( e) { // This will be reached and output a "VersionError" DOMException console. error( e); } // But clear() will delete the database entirely: await area. clear(); // Now we can use it again! await area. set( "fluffy" , new Cat()); await area. set( "tigger" , new Cat()); // Also, the version is back down to 1: console. assert( area. backingStore. version=== 1 ); })();
3.6. Iteration
The KVStorageArea
interface supports asynchronous iteration.
for await (const key of storage.
keys()
) { ... }-
Retrieves an async iterator containing the keys of all entries in this storage area.
Keys will be yielded in ascending order; roughly, segregated by type, and then sorted within each type. They will be key round-tripped from their original form.
for await (const value of storage.
values()
) { ... }-
Retrieves an async iterator containing the values of all entries in this storage area.
Values will be yielded in the same order as for
keys()
. They will be structured-deserialized from their original form. for await (const [key, value] of storage.
entries()
) { ... }for await (const [key, value] of storage) { ... }
-
Retrieves an async iterator containing two-element
[key, value]
arrays, each of which corresponds to an entry in this storage area.Entries will be yielded in the same order as for
keys()
. Each key and value will be key round-tripped and structured-deserialized from its original form, respectively.
All of these iterators provide live views onto the storage area: modifications made to entries sorted after the last-returned one will be reflected in the iteration.
KVStorageArea
, given storageArea and asyncIterator, are:
-
Set asyncIterator’s last key to not yet started.
-
Return the result of performing a database operation given storageArea, "
readonly
", and the following steps operating on transaction and store:-
Let range be the result of getting the range for asyncIterator’s last key.
-
Let keyRequest be the result of performing the steps listed in the description of
IDBObjectStore
'sgetKey()
method on store, given the argument range. -
Let valueRequest be the result of performing the steps listed in the description of
IDBObjectStore
'sget()
method on store, given the argument range.Note: The iterator returned from
keys()
discards the value. Implementations could avoid constructing valueRequest in that case. -
Let promise be a new promise in the relevant Realm of asyncIterator.
-
Add a simple event listener to valueRequest for "
success
" that performs the following steps: -
Add a simple event listener to keyRequest for "
error
" that rejects promise with keyRequest’s error. -
Add a simple event listener to valueRequest for "
error
" that rejects promise with valueRequest’s error. -
Return promise.
-
await kvStorage. set( 10 , "value 10" ); await kvStorage. set( 20 , "value 20" ); await kvStorage. set( 30 , "value 30" ); const keysSeen= []; for await( const keyof kvStorage. keys()) { if ( key=== 20 ) { await kvStorage. set( 15 , "value 15" ); await kvStorage. delete ( 20 ); await kvStorage. set( 25 , "value 25" ); } keysSeen. push( key); } console. log( keysSeen); // logs 10, 20, 25, 30
That is, calling keys()
does not create a snapshot as of the time it was called; it returns a live asynchronous iterator, that lazily retrieves the next key after the last-seen one.
KVStorageArea
storage, you could use the following code to send all locally-stored entries to a server:
const entries= []; for await( const entryof kvStorage. entries()) { entries. push( entry); } fetch( "/storage-receiver" , { method: "POST" , body: entries, headers: { "Content-Type" : "application/json" } });
3.7. backingStore
{ database, store, version } = storage.
backingStore
-
Returns an object containing all of the information necessary to manually interface with the IndexedDB backing store that underlies this storage area:
-
database will be a string equal to "
kv-storage:
" concatenated with the database name passed to the constructor. (For the default storage area, it will be "kv-storage:default
".) -
store will be the string "
store
". -
version will be the number 1.
It is good practice to use the
backingStore
property to retrieve this information, instead of memorizing the above factoids. -
-
If this.[[BackingStoreObject]] is null, then:
-
Let backingStoreObject be ObjectCreate(
%ObjectPrototype%
). -
Perform CreateDataProperty(backingStoreObject, "
database
", this.[[DatabaseName]]). -
Perform CreateDataProperty(backingStoreObject, "
store
", "store
"). -
Perform CreateDataProperty(backingStoreObject, "
version
", 1). -
Perform SetIntegrityLevel(backingStoreObject, "
frozen
"). -
Set this.[[BackingStoreObject]] to backingStoreObject.
-
-
Return this.[[BackingStoreObject]].
KVStorageArea
storage like so:
bulbasaur. onchange= () => kvStorage. set( "bulbasaur" , bulbasaur. checked); ivysaur. onchange= () => kvStorage. set( "ivysaur" , ivysaur. checked); venusaur. onchange= () => kvStorage. set( "venusaur" , venusaur. checked); // ...
(Hopefully the developer quickly realizes that the above will be hard to maintain, and refactors the code into a loop. But in the meantime, their repetitive code makes for a good example, so let’s take advantage of that.)
The developer now realizes they want to add an evolution feature, e.g. for when the user transforms their Bulbasaur into an Ivysaur. They might first implement this like so:
bulbasaurEvolve. onclick= async() => { await kvStorage. set( "bulbasaur" , false ); await kvStorage. set( "ivysaur" , true ); };
However, our developer starts getting bug reports from their users: if the users happen to open up the checklist app in a second tab while they’re evolving in the first tab, the second tab will sometimes see that their Bulbasaur has disappeared, without ever turning into an Ivysaur! A Pokémon has gone missing!
The solution here is to step beyond the comfort zone of KV storage, and start using the full power of IndexedDB: in particular, its transactions feature. The backingStore
getter is the gateway to this world:
const { database, store, version} = kvStorage. backingStore; const request= indexedDB. open( database, version); request. onsuccess= () => { const db= request. result; bulbasaurEvolve. onclick= () => { const transaction= db. transaction( store, "readwrite" ); const store= transaction. objectStore( store); store. put( "bulbasaur" , false ); store. put( "ivysaur" , true ); db. close(); }; };
Satisfied with their web app’s Pokémon integrity, our developer is now happy and fulfilled. (At least, until they realize that none of their code has error handling.)
4. Supporting operations and concepts
EventTarget
target, an event type string type, and a set of steps steps:
-
Let jsCallback be a new JavaScript function object, created in the current realm, that performs the steps given by steps. Other properties of the function (such as its
name
andlength
properties, or [[Prototype]]) are unobservable, and can be chosen arbitrarily. -
Let idlCallback be the result of converting jsCallback to an
EventListener
. -
Perform the steps listed in the description of
EventTarget
'saddEventListener()
method on target given the arguments type and idlCallback.
IDBFactory
is the IDBFactory
instance returned by the following steps:
-
Assert: the current global object includes
WindowOrWorkerGlobalScope
. -
Return the result of performing the steps listed in the description of the getter for
WindowOrWorkerGlobalScope
'sindexedDB
attribute on the current global object.
KVStorageArea
area, a mode string mode, and a set of steps steps that operate on an IDBTransaction
transaction and an IDBObjectStore
store:
-
Assert: area.[[DatabaseName]] is a string (and in particular is not null).
-
If area.[[DatabasePromise]] is null, initialize the database promise for area.
-
Return the result of reacting to area.[[DatabasePromise]] with a fulfillment handler that performs the following steps, given database:
-
Let transaction be the result of performing the steps listed in the description of
IDBDatabase
'stransaction()
method on database, given the arguments "store
" and mode. -
Let store be the result of performing the steps listed in the description of
IDBTransaction
'sobjectStore()
method on transaction, given the argument "store
". -
Return the result of performing steps, passing along transaction and store.
-
KVStorageArea
area:
-
Set area.[[DatabasePromise]] to a new promise in the relevant Realm of area.
-
If the current global object does not include
WindowOrWorkerGlobalScope
, reject area.[[DatabasePromise]] with aTypeError
, and return. -
Let request be the result of performing the steps listed in the description of
IDBFactory
'sopen()
method on the currentIDBFactory
, given the arguments area.[[DatabaseName]] and 1. -
If those steps threw an exception, catch the exception, reject area.[[DatabasePromise]] with it, and return.
-
Add a simple event listener to request for "
success
" that performs the following steps:-
Let database be request’s result.
-
Check the database schema for database. If the result is false, reject area.[[DatabasePromise]] with an "
InvalidStateError
"DOMException
and abort these steps. -
Add a simple event listener to database for "
close
" that sets area.[[DatabasePromise]] to null.This means that if the database is closed abnormally, future invocations of perform a database operation will attempt to reopen it.
-
Add a simple event listener to database for "
versionchange
" that performs the steps listed in the description ofIDBDatabase
'sclose()
method on database, and then sets area.[[DatabasePromise]] to null.This allows attempts to upgrade the underlying database, or to delete it (e.g. via the
clear()
method), to succeed. Without this, if twoKVStorageArea
instances were both open referencing the same underlying database,clear()
would hang, as it only closes the connection maintained by theKVStorageArea
it is invoked on. -
Resolve promise with database.
-
-
Add a simple event listener to request for "
error
" that rejects promise with request’s error. -
Add a simple event listener to request for "
upgradeneeded
" that performs the following steps:-
Let database be request’s result.
-
Perform the steps listed in the description of
IDBDatabase
'screateObjectStore()
method on database, given the arguments "store
". -
If these steps throw an exception, catch the exception and reject area.[[DatabasePromise]] with it.
-
IDBDatabase
database:
-
Let objectStores be database’s connection's object store set.
-
If objectStores’s size is not 1, return false.
-
Let store be objectStores[0].
-
If store’s name is not "
store
", return false. -
If store has a key generator, return false.
-
If store has a key path, return false.
-
If any indexes reference store, return false.
-
Return true.
Check the database schema only needs to be called in the initial setup algorithm, initialize the database promise, since once the database connection has been opened, the schema cannot change.
-
If Type(value) is Number or String, return true.
-
If IsArray(value) is true, return true.
-
If value has a [[DateValue]] internal slot, return true.
-
If value has a [[ViewedArrayBuffer]] internal slot, return true.
-
If value has an [[ArrayBufferByteLength]] internal slot, return true.
-
Return false.
Most notably, using the allowed as a key predicate ensures that IDBKeyRange
objects, or any other special object that is accepted as a query in future IndexedDB specification revisions, will be disallowed. Only straightforward key values are accepted by the KV storage API.
Key round-tripping refers to the way in which JavaScript values are processed by first being passed through IndexedDB’s convert a value to a key operation, then converted back through its convert a key to a value operation. Keys returned by the keys()
or entries()
methods will have gone through this process.
Notably, any typed arrays or DataView
s will have been "unwrapped", and returned back as just ArrayBuffer
s containing the same bytes. Also, similar to the structured-serialization/deserialization process, any "expando" properties or other modifications will not be preserved by key round-tripping.
For primitive string or number values, there’s no need to worry about key round-tripping; the values are indistinguishable.
-
If key is not yet started, then return the result of performing the steps listed in the description of the
IDBKeyRange.lowerBound()
static method, given the argument −Infinity.The intent here is to get an unbounded key range, but this is the closest thing we can get that is representable as an
IDBKeyRange
object. It works equivalently for our purposes, but will behave incorrectly if Indexed DB ever adds keys that sort below −Infinity. See some discussion on potential future improvements. -
Otherwise, return the result of performing the steps listed listed in the description of the
IDBKeyRange.lowerBound()
static method, given the arguments key and true.
The special value not yet started can be taken to be any JavaScript value that is not equal to any other program-accessible JavaScript value (but is equal to itself). It is used exclusively as an argument to the get the range for a key algorithm.
A newly created object or symbol, e.g.
or
, would satisfy this definition.
Acknowledgments
The editor would like to thank Andrew Sutherland, Kenneth Rohde Christiansen, Jacob Rask, Jake Archibald, Jan Varga, Joshua Bell, Ms2ger, and Victor Costan for their contributions to this specification.
Conformance
This specification depends on the Infra Standard. [INFRA]