blastore

September 8, 2025 · View on GitHub

bundlejs

Blazingly fast, type-safe storage wrapper with minimal overhead. A minimal, high-performance storage wrapper for localStorage, memory, or any sync/async backend — with full TypeScript type safety.


The Problem with localStorage / AsyncStorage

  • Most calls are inlined
  • Value type is often assumed rather than validated
  • Lots of copy&paste
// boolean
const value = !!localStorage.getItem('someFlag');
// string
const value = localStorage.getItem('someString') ?? 'defaultValue';

import { format } from 'date-fns';

const someISODateString =
  localStorage.getItem('someISODateString') ?? new Date().toISOString(); // valid ISO string is not guaranteed
format(someISODateString, 'dd-MM'); // can potentially crash
// objects
const value = JSON.parse(localStorage.getItem('someShape')); // common but unsafe pattern
More objects
// safe option but tons of boilerplate for a key
let value;
try {
  value = JSON.parse(localStorage.getItem('someShape'));
} catch (e) {
  value = {}; // defaultValue
}
// reusable helpers, no link between key and value, type safety is basically non existent
function getItem<T>(key, defaultValue: T): T {
  try {
    return JSON.parse(localStorage.getItem(key)) as T;
  } catch (e) {
    return defaultValue;
  }
}

function setItem(key: string, value: any) {
  try {
    return localStorage.setItem(key, JSON.stringify(value));
  } catch (e) {
    console.error(e);
  }
}
// helper per key with proper validation
// commonly used for complex values
import z from 'zod';

const myShape = z.object({ key: z.string() });

function getSomeShape() {
  return (
    myShape.safeParse(JSON.parse(localStorage.getItem('someShape'))).data ?? {}
  );
}

With Blastore, you define your storage schema once, and get type-safe, validated access everywhere else.

import { buildStandard } from 'blastore/standard';
import z from 'zod';

const blastore = buildStandard({
  store: localStorage,
  validate: {
    boolean: z.boolean(),
    date: z.date(),
    myShape: z.union([z.null(), z.object({ key: z.string() })]),
  },
  keyMode: {
    boolean: 'sync',
    date: 'sync',
    myShape: 'sync',
  },
  serialize: {
    date: (v) => v.toISOString(),
    myShape: (v) => JSON.stringify(v),
  },
  deserialize: {
    date: (v) => new Date(String(v)),
    myShape: (v) => JSON.parse(String(v)),
  },
  validateOnGet: true, // to force runtime types on read
  validateOnSet: true, // to validate before write
});

const bool = blastore.get('boolean', false);
const date = blastore.get('date', new Date());
const shape = blastore.get('myShape', null);

blastore.set('boolean', false);
blastore.set('date', new Date());
blastore.set('myShape', { key: 'value' });

Table of Contents


Why blastore?

  • Typed: Static & runtime validation built-in
  • Blazingly fast: Near-native .get() / .set() performance
  • Precompiled dynamic keys: user:${userId}-style access with full type safety
  • Reactivity: Subscribe to changes without external state libraries
  • Featherweight: Zero dependencies, tree-shakable, minimal API
  • Pluggable store: Works with localStorage, memory, or any custom (a|sync) backend

Feature comparison

FeatureBlastoreZustandRedux Toolkit
Type Safety✅ Static + runtimeManual (TypeScript only)Manual (TypeScript only)
Runtime Validation✅ Built-inManualManual
Async Storage✅ Built-inPlugin/manualManual
Dynamic Keys✅ Typed + precompiledManual patternsManual patterns
Pub/Sub✅ Native✅ (listeners)✅ (store.subscribe)
ImmutabilityOptional (adapter)OptionalDefault (Immer)
BackendsPluggableIn-memory onlyIn-memory only

Installation

npm i blastore

Overview

Blastore uses a schema-first approach: you define validation, serialization, and deserialization for each key, and it generates a fully typed API for interacting with your storage backend.


Standard Mode

Use buildStandard() when you want to use Standard Schema

import { buildStandard } from 'blastore/standard';
import z from 'zod';

const blastore = buildStandard({
  store: localStorage,
  validate: {
    isOnboardingComplete: z.boolean(),
    'messageDraft:${threadId}': z.union([
      z.null(),
      z.object({
        content: z.string(),
      }),
    ]),
  },
  keyMode: {
    isOnboardingComplete: 'sync',
    'messageDraft:${threadId}': 'sync',
  },
  serialize: {
    'messageDraft:${threadId}': (v) => JSON.stringify(v),
  },
  deserialize: {
    'messageDraft:${threadId}': (v) => JSON.parse(String(v)),
  },
  validateOnSet: true,
  validateOnGet: true,
});

Usage

blastore.set(
  'messageDraft:${threadId}',
  { content: 'hi' },
  { variables: { threadId: '123' } }
);
const val = blastore.get('messageDraft:${threadId}', null, {
  variables: { threadId: '123' },
});

Dynamic Keys

blastore.set(
  'messageDraft:${threadId}',
  { content: 'text' },
  { variables: { threadId: '123' } }
);
const draft = blastore.get('messageDraft:${threadId}', null, {
  variables: { threadId: '123' },
});

Precompiled Keys

const draftApi = blastore.buildKeyApi('messageDraft:${threadId}', {
  variables: { threadId: '123' },
});
draftApi.set({ content: 'hi' });
draftApi.get(null);

Async Mode

Works the same way as Standard mode, just api is fully asynchronous and gives you flexibility to write custom validators

import AsyncStorage from '@react-native-async-storage/async-storage';
import { buildAsync } from 'blastore/async';
import z from 'zod';

const messageSchema = z.union([
  z.null(),
  z.object({
    content: z.string(),
  }),
]);

const blastore = buildAsync(
  {
    validate: {
      isOnboardingComplete: async (v) =>
        typeof v === 'boolean' ? v : new Error('Invalid type'),
      'messageDraft:${threadId}': async (v) => {
        const res = await messageSchema.safeParseAsync(v);
        return res.success ? res.data : res.error;
      },
    },
    serialize: {
      'messageDraft:${threadId}': async (v) => JSON.stringify(v),
    },
    deserialize: {
      'messageDraft:${threadId}': async (v) => JSON.parse(String(v)),
    },
  },
  AsyncStorage
);

Sync Mode

Works the same way as Standard mode, just api is fully synchronous and gives you flexibility to write custom validators

import { buildSync } from 'blastore/sync';
import z from 'zod';

const messageSchema = z.union([
  z.null(),
  z.object({
    content: z.string(),
  }),
]);

const blastore = buildSync({
  store: localStorage,
  validate: {
    isOnboardingComplete: (v) =>
      typeof v === 'boolean' ? v : new Error('Invalid type'),
    'messageDraft:${threadId}': (v) => {
      const res = messageSchema.safeParse(v);
      return res.success ? res.data : res.error;
    },
  },
  serialize: {
    'messageDraft:${threadId}': (v) => JSON.stringify(v),
  },
  deserialize: {
    'messageDraft:${threadId}': (v) => JSON.parse(String(v)),
  },
  validateOnGet: true,
  validateOnSet: true,
});

With localStorage

window.addEventListener('storage', (e) => {
  if (e.key) {
    if (e.newValue === null) {
      blastore.untypedEmit(e.key, 'remove');
    } else {
      const isEmitted = blastore.untypedEmit(e.key, e.newValue, {
        deserialize: true,
        validate: true,
      });
    }
  }
});

React integration

import { useStandardStore } from 'blastore/use-standard-store';

const {
  isInitialised, // false by default, happens automatically
  value: isOnboardingComplete, // equals to provided defaultValue in this case `false` until initialised
  set: setIsOnboardingComplete,
  remove: removeIsOnboardingComplete,
} = useStandardStore(blastore, 'isOnboardingComplete', false);
import { useAsyncStore } from 'blastore/use-async-store';

const {
  isInitialised, // false by default, happens automatically
  value: isOnboardingComplete, // equals to provided defaultValue in this case `false` until initialised
  set: setIsOnboardingComplete,
  remove: removeIsOnboardingComplete,
} = useAsyncStore(blastore, 'isOnboardingComplete', false);
import { useSyncStore } from 'blastore/use-sync-store';

const {
  value: isOnboardingComplete,
  set: setIsOnboardingComplete,
  remove: removeIsOnboardingComplete,
} = useSyncStore(blastore, 'isOnboardingComplete', false);

Custom backends

import { buildSync } from 'blastore/sync';

const myDb = {};
const customStore = {
  getItem: (k) => myDb[k],
  setItem: (k, v) => {
    myDb[k] = v;
  },
  removeItem: (k) => delete myDb[k],
};

const blastore = buildSync({
  store: customStore,
  validate: {
    isOnboardingComplete: (v) =>
      typeof v === 'boolean' ? v : new Error('Invalid type'),
  },
  validateOnGet: true,
  validateOnSet: true,
});

Advanced

Error handling

  • get returns defaultValue when read failed (validation or some other issue)
  • set returns false when operation failed and true otherwise
  • remove returns false when operation failed and true otherwise

To get actual error you need to use out parameter in options for each of those functions This is done to keep api monomorphic in hot paths, which significantly affects performance

const out = { error: undefined };
blastore.get('key', defaultValue, { out }); // sync
console.error(out.error);

await blastore.get('key', defaultValue, { out }); // async
console.error(out.error);

blastore.set('key', value, { out }); // sync
console.error(out.error);

await blastore.set('key', value, { out }); // async
console.error(out.error);

Pub/sub

Blastore supports basic pub/sub. There are two ways of emitting events.

First is when you emit using a key template, this way should be preferred as this method does not require key look up, is faster and more efficient

const validate = true / false;
const emitted = blastore.emit('key{id}', 'action', value, {
  validate,
  variables: { id: '123' },
}); //sync
const asyncEmitted = await asyncBlastore.emit('key{id}', 'action', value, {
  validate,
  variables: { id: '123' },
}); //async

Second is when you emit using raw key from the storage. This method will attempt to match raw key to one of the templates registered in blastore and if matched, will emit to subscribers of that template. It also supports passing a raw value which can be deserialized before sending to subscribers.

Useful when you want to add support for cross tab localStorage changes or manually trigger changes when storage of your choice is changed outside blastore scope, or anything like that

const validate = true / false;
const deserialize = true / false;
const emitted = blastore.untypedEmit('key123', 'action', value, {
  validate,
  deserialize,
}); //sync
const asyncEmitted = await asyncBlastore.untypedEmit(
  'key123',
  'action',
  value,
  {
    validate,
    deserialize,
  }
); //async

Same goes for subscriptions. You can either subscribe using key template or raw key.

In this case .untypedSubscribe() is more performant, but you will not have static typing to easily track which keys are used in the app.

From DX perspective it is better to use typed .subscribe().

const unsub = blastore.subscribe(
  'key',
  (params) => {
    if (params.action === 'remove') {
      // reserved action type for when item is removed from storage
      // params.value is null
    } else if (params.action === 'set') {
      // reserved action type for when item is changed
      console.log(params.value);
    } else {
      // action in this case can be anything of your choice
      // this will only happen if you use emit events manually and provide custom action
      console.log(params.action, params.value);
    }
  },
  {
    variables: { id: '123' },
  }
);
const unsub1 = blastore.untypedSubscribe('key123', (params) => {
  if (params.action === 'remove') {
    // reserved action type for when item is removed from storage
    // params.value is null
  } else if (params.action === 'set') {
    // reserved action type for when item is changed
    console.log(params.value);
  } else {
    // action in this case can be anything of your choice
    // this will only happen if you use emit events manually and provide custom action
    console.log(params.action, params.value);
  }
});

Performance Guidelines

Blastore itself is fast — but your choice of validators, serializers, and storage backend will affect performance.

For best performance in hot paths you should use precompiled keys and fast runtime validators (if you opt in for runtime validation).

Dynamic keys have significant effect on performance (refer benchmarks section)

To reduce overhead of dynamic keys library uses cheap cache to memoise last key To take advantage of this optimisation you should group operations by key

Example of optimised code
// constant reference to variables object
const opts1 = { variables: { id: '123' } } as const;
// overhead from building the key
blastore.get('key{id}', opts);
// no overhead, read from cache
blastore.set('key{id}', 'someVal', opts);
// no overhead, read from cache
blastore.get('key{id}', opts);

// constant reference to variables object
const opts2 = { variables: { id: '124' } } as const;
// overhead from building the key
blastore.get('key{id}', opts2);
// no overhead, read from cache
blastore.set('key{id}', 'someVal', opts2);
// no overhead, read from cache
blastore.get('key{id}', opts2);
Example of unoptimised code
// new object refence for "variables" in each call leads to cache miss
// overhead from building the key
blastore.get('key{id}', { variables: { id: '123' } });
// overhead from building the key
blastore.set('key{id}', 'someVal', { variables: { id: '123' } });
// overhead from building the key
blastore.get('key{id}', { variables: { id: '123' } });
// operations on keys are mixed
const opts1 = { variables: { id: '123' } } as const;
const opts2 = { variables: { id: '124' } } as const;

// overhead from building the key
blastore.get('key{id}', opts1);
// cache miss as it is different key -> overhead from building the key
blastore.get('key{id}', opts2);
// cache miss as it is different key -> overhead from building the key
blastore.set('key{id}', 'someVal', opts1);
// cache miss as it is different key -> overhead from building the key
blastore.set('key{id}', 'someVal', opts2);
// cache miss as it is different key -> overhead from building the key
blastore.get('key{id}', opts1);
// cache miss as it is different key -> overhead from building the key
blastore.get('key{id}', opts2);

Refer benchmarks sections for details on overhead


Performance Benchmarks

Hardware: CPU: Apple M2 Max; RAM 64GB

Synchronous mode: NodeJS 22.12.0; 10M iterations 100 keys

Node parameters --expose-gc --no-warnings --initial-old-space-size=256 --max-old-space-size=256

ENV NODE_ENV=production

All results
Library / ModeTime (ns/op)
raw object - simple key19.29
raw Map - simple key24.95
zustand - simple key22.55
blastore - simple key33.40
blastore - simple key; no runtime validation31.12
standard blastore - simple key153.13
standard blastore - simple key; no runtime validation52.40
Valtio - simple key1833.03
Jotai - simple key1659.45
MobX - simple key1389.72
MobX - simple key; enforceActions: never1338.77
redux-toolkit - simple key1828.39
raw object - dynamic key71.36
raw Map - dynamic key66.18
zustand - dynamic key81.39
blastore - dynamic key120.39
blastore - dynamic key; mixed key operations184.00
blastore - dynamic key; no runtime validation118.96
blastore - precompiled key46.16
standard blastore - dynamic key244.19
standard blastore - dynamic key; no runtime validation136.39
standard blastore - dynamic key; mixed key operations304.52
standard blastore - dynamic key; mixed key operations; no runtime validation195.95
standard blastore - precompiled key157.64
Valtio - dynamic key2028.92
Jotai - dynamic key1667.62
MobX - dynamic key1856.50
MobX - dynamic key; enforceActions: never1785.68
redux-toolkit - dynamic key27875.59
raw object - simple key; pub/sub45.33
raw Map - simple key; pub/sub53.35
zustand - simple key; pub/sub22.39
blastore - simple key; pub/sub41.17
standard blastore - simple key; pub/sub166.78
standard blastore - simple key; pub/sub; no runtime validation61.42
MobX - simple key; pub/sub2573.66
Valtio - simple key; pub/sub1966.28
Jotai - simple key; pub/sub1711.20
redux-toolkit - simple key; no middleware; pub/sub2964.48
raw object - dynamic key; pub/sub81.98
raw Map - dynamic key; pub/sub76.45
zustand - dynamic key; pub/sub79.27
blastore - dynamic key; pub/sub140.11
blastore - precompiled key; pub/sub60.82
standard blastore - dynamic key; pub/sub268.79
standard blastore - dynamic key; pub/sub; no runtime validation159.96
standard blastore - precompiled key; pub/sub177.83
MobX - dynamic key; pub/sub2441.41
Valtio - dynamic key; pub/sub7119.10
Jotai - dynamic key; atomFamily; pub/sub1661.43
redux-toolkit - dynamic key; no middleware; pub/sub1555156.37
blastore - simple key; immutable adapter84.44
zustand - simple key; immutable134.10
MobX - simple key; immutable19186.31
Jotai - simple key; immutable1899.28
zustand - dynamic key; immutable2519.27
blastore - dynamic key; immutable adapter2472.86
blastore - dynamic key; mixed key operations; immutable adapter2506.91
standard blastore - dynamic key; mixed key operations; immutable adapter2595.50
MobX - dynamic key; immutable2266365.27
Jotai - dynamic key; immutable62046.49
raw object - simple key; immutable; pub/sub53.92
raw map - simple key; immutable; pub/sub107.83
zustand - simple key; immutable; pub/sub141.84
blastore - simple key; immutable adapter; pub/sub94.93
blastore - simple key; immutable adapter; pub/sub; no runtime validation95.00
standard blastore - simple key; immutable adapter; pub/sub214.47
standard blastore - simple key; immutable adapter; pub/sub; no runtime validation118.48
MobX - simple key; immutable; pub/sub99558.23
Jotai - simple key; immutable; pub/sub1916.80
raw object - dynamic key; immutable; pub/sub108635.90
raw map - dynamic key; immutable; pub/sub3552.76
zustand - dynamic key; immutable; pub/sub147978.68
blastore - dynamic key; immutable adapter; pub/sub2460.17
blastore - dynamic key; immutable adapter; pub/sub; no runtime validation98484.15
blastore - precompiled key; immutable adapter; pub/sub2378.07
standard blastore - dynamic key; immutable adapter; pub/sub2584.37
standard blastore - dynamic key; immutable adapter; pub/sub; no runtime validation93113.38
standard blastore - precompiled key; immutable adapter; pub/sub2469.16
Jotai - dynamic key; immutable; pub/sub148397.89
MobX - dynamic key; immutable; pub/sub2936417.02

Simple Keys (Mutable)
Library / ModeTime (ns/op)
raw object - simple key19.29
zustand - simple key22.55
raw Map - simple key24.95
blastore - simple key; no runtime validation31.12
blastore - simple key33.40
standard blastore - simple key; no runtime validation52.40
standard blastore - simple key153.13
MobX - simple key; enforceActions: never1338.77
MobX - simple key1389.72
Jotai - simple key1659.45
redux-toolkit - simple key1828.39
Valtio - simple key1833.03

Takeaway:

  • zustand is closest to raw object.
  • blastore adds ~10ns overhead.
  • standard blastore is 2–5× slower depending on validation.
  • All others are 50–80× slower.

Dynamic Keys (Mutable)
Library / ModeTime (ns/op)
blastore - precompiled key46.16
raw Map - dynamic key66.18
raw object - dynamic key71.36
zustand - dynamic key81.39
blastore - dynamic key; no runtime validation118.96
blastore - dynamic key120.39
standard blastore - dynamic key; no runtime validation136.39
standard blastore - precompiled key157.64
blastore - dynamic key; mixed key operations184.00
standard blastore - dynamic key; mixed key operations; no runtime validation195.95
standard blastore - dynamic key244.19
standard blastore - dynamic key; mixed key operations304.52
Jotai - dynamic key1667.62
MobX - dynamic key; enforceActions: never1785.68
MobX - dynamic key1856.50
Valtio - dynamic key2028.92
redux-toolkit - dynamic key27875.59

Takeaway:

  • blastore precompiled key is even faster than raw object/Map.
  • zustand remains strong.
  • Standard schema introduces 2–3× overhead.
  • Other libs are 20–400× slower.

Pub/sub (Mutable)
Library / ModeTime (ns/op)
zustand - simple key; pub/sub22.39
blastore - simple key; pub/sub41.17
raw object - simple key; pub/sub45.33
raw Map - simple key; pub/sub53.35
blastore - precompiled key; pub/sub60.82
standard blastore - simple key; pub/sub; no runtime validation61.42
raw Map - dynamic key; pub/sub76.45
zustand - dynamic key; pub/sub79.27
raw object - dynamic key; pub/sub81.98
blastore - dynamic key; pub/sub140.11
standard blastore - dynamic key; pub/sub; no runtime validation159.96
standard blastore - simple key; pub/sub166.78
standard blastore - precompiled key; pub/sub177.83
standard blastore - dynamic key; pub/sub268.79
Jotai - dynamic key; atomFamily; pub/sub1661.43
Jotai - simple key; pub/sub1711.20
Valtio - simple key; pub/sub1966.28
MobX - dynamic key; pub/sub2441.41
MobX - simple key; pub/sub2573.66
redux-toolkit - simple key; no middleware; pub/sub2964.48
Valtio - dynamic key; pub/sub7119.10
redux-toolkit - dynamic key; no middleware; pub/sub1555156.37

Takeaway:

  • zustand pub/sub is essentially free.
  • blastore adds ~20ns overhead, standard schema ~160ns.
  • All others are 30–100× slower.

Pub/sub (Immutable)
Library / ModeTime (ns/op)
raw object - simple key; immutable; pub/sub53.92
blastore - simple key; immutable adapter84.44
blastore - simple key; immutable adapter; pub/sub94.93
blastore - simple key; immutable adapter; pub/sub; no runtime validation95.00
raw map - simple key; immutable; pub/sub107.83
standard blastore - simple key; immutable adapter; pub/sub; no runtime validation118.48
zustand - simple key; immutable134.10
zustand - simple key; immutable; pub/sub141.84
standard blastore - simple key; immutable adapter; pub/sub214.47
Jotai - simple key; immutable1899.28
Jotai - simple key; immutable; pub/sub1916.80
blastore - precompiled key; immutable adapter; pub/sub2378.07
blastore - dynamic key; immutable adapter; pub/sub2460.17
standard blastore - precompiled key; immutable adapter; pub/sub2469.16
blastore - dynamic key; immutable adapter2472.86
blastore - dynamic key; mixed key operations; immutable adapter2506.91
zustand - dynamic key; immutable2519.27
standard blastore - dynamic key; immutable adapter; pub/sub2584.37
standard blastore - dynamic key; mixed key operations; immutable adapter2595.50
raw map - dynamic key; immutable; pub/sub3552.76
MobX - simple key; immutable19186.31
Jotai - dynamic key; immutable62046.49
standard blastore - dynamic key; immutable adapter; pub/sub; no runtime validation93113.38
blastore - dynamic key; immutable adapter; pub/sub; no runtime validation98484.15
MobX - simple key; immutable; pub/sub99558.23
raw object - dynamic key; immutable; pub/sub108635.90
zustand - dynamic key; immutable; pub/sub147978.68
Jotai - dynamic key; immutable; pub/sub148397.89
MobX - dynamic key; immutable2266365.27
MobX - dynamic key; immutable; pub/sub2936417.02

Takeaway:

  • Immutable mode costs everyone, but blastore stays in microseconds (2.5k ns).
  • zustand dynamic immutable balloons to ~148k ns.
  • MobX/Jotai reach millisecond territory.

Summary

  • Raw objects/Maps: unbeatable baselines.

  • zustand: fastest mainstream library, especially for simple keys + pub/sub.

  • blastore: ~2–5× slower than raw, but adds type safety, validation, precompiled keys, pub/sub, and backend integration.

  • Standard schema blastore: 2–3× slower than custom validators, still orders of magnitude faster than MobX/Jotai/Valtio/Redux Toolkit.

  • Immutable mode:

    • blastore: stays within 2–3k ns.
    • zustand: 100k+ ns.
    • MobX/Jotai: 100k–3M ns.
  • NOTE: localStorage api is quite slow, based similar benchmarks it is in range of 3100-3500ns/op no matter raw local storage of wrapped with blastore. I ran what I could in service workers to isolate each benchmark as much as I can, results are very close to Node based benchmarks. localStorage is not available inside service workers, so had to run tests in main thread, which is not reliable due to various optimisations' browser does there. I will happily take any advice on browser based benchmarking.

License

MIT © 2025 Sergey Shablenko