<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Web on ENKR's Blog | Jing Hui PANG</title><link>https://blog.enkr1.com/tags/web/</link><description>Recent content in Web on ENKR's Blog | Jing Hui PANG</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><copyright>ENKR</copyright><lastBuildDate>Sun, 20 Sep 2026 21:52:28 +0800</lastBuildDate><atom:link href="https://blog.enkr1.com/tags/web/index.xml" rel="self" type="application/rss+xml"/><item><title>Should I Use ultrastorage? the Answer Was in My Repo, Not Its README</title><link>https://blog.enkr1.com/should-i-use-ultrastorage/</link><pubDate>Fri, 11 Sep 2026 12:40:12 +0800</pubDate><guid>https://blog.enkr1.com/should-i-use-ultrastorage/</guid><description>&lt;blockquote&gt;
&lt;p&gt;someone sent me a link to a new localStorage library and asked, reasonably, &amp;ldquo;does this help?&amp;rdquo;. i had no problem in mind when i opened it, which is exactly the condition under which every feature on a README looks like it helps. so instead of reading the feature list twice i turned each feature into a grep against my own codebase and counted the hits. the answer was no, and the interesting part was the shape of the no. i had my AI clone the repo, read the source rather than the docs, and measure the bundle and the storage overhead itself. two things i had confidently said in chat turned out to be wrong once the source was open, and both are in here. the personal note at the bottom is mine to write after.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="tldr"&gt;tl;dr
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;the library is good. that is not the question, and treating it as the question is the mistake.&lt;/li&gt;
&lt;li&gt;six advertised features, six greps against my repo. one found any demand at all, at &lt;strong&gt;2 call sites out of 75&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;the largest persistence surface in my app was &lt;strong&gt;out of reach by type signature&lt;/strong&gt;, not by quality: the library takes a synchronous &lt;code&gt;Storage&lt;/code&gt;, my stores sit on IndexedDB behind an async interface.&lt;/li&gt;
&lt;li&gt;reading the source killed two of my own confident claims. worth more than the verdict.&lt;/li&gt;
&lt;li&gt;when the wrapper&amp;rsquo;s value is real, check whether you actually want &lt;strong&gt;its dependency&lt;/strong&gt; instead. here that is &lt;code&gt;devalue&lt;/code&gt;, and it is most of the bundle.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-question"&gt;the question
&lt;/h2&gt;&lt;p&gt;the package is &lt;a class="link" href="https://github.com/yangshun/ultrastorage" target="_blank" rel="noopener"
&gt;ultrastorage&lt;/a&gt; by Yangshun Tay: &amp;ldquo;gives &lt;code&gt;localStorage&lt;/code&gt; superpowers&amp;rdquo;, with rich type serialisation, key expiration, namespacing, and schema validation. MIT, TypeScript, zero open issues, 80 stars at the time of writing.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;does this help?&amp;rdquo; is the wrong shape of question and it is the one everybody actually asks. it invites you to read a feature list and imagine the pain each feature would relieve. imagination is a terrible instrument here, because a README is a description of the problems its author had. those were real problems. they were just in a different codebase.&lt;/p&gt;
&lt;p&gt;the right shape is: &lt;strong&gt;how many places in my code hurt today, in the specific way this thing fixes?&lt;/strong&gt; that number is not a guess. it is a grep.&lt;/p&gt;
&lt;h2 id="six-features-six-greps"&gt;six features, six greps
&lt;/h2&gt;&lt;p&gt;my case is a training PWA: Next.js, Zustand, deployed, real users. the storage surface is not toy sized. here is every advertised feature against what the grep actually returned.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;advertised&lt;/th&gt;
&lt;th&gt;what my repo returned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rich types (&lt;code&gt;Set&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;, &lt;code&gt;BigInt&lt;/code&gt;, circular refs)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.&lt;/strong&gt; none of the 34 non-test writes stores one. everything already flattens to a string or plain JSON.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTL and expiry&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.&lt;/strong&gt; a 60 second one and a 7 day one. that is the whole demand.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;namespacing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;already solved.&lt;/strong&gt; &lt;code&gt;fc_&lt;/code&gt; and &lt;code&gt;companion:&lt;/code&gt; prefixes, plus a key-builder module that is the single source of truth.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;schema validation&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;, and it arrives with a second dependency: zod is installed in my API workspace, not the web one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cross-tab subscription&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;already owned, by something better.&lt;/strong&gt; see below.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pluggable storage backend&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;the wall.&lt;/strong&gt; see below.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;75 non-test call sites touch &lt;code&gt;localStorage&lt;/code&gt; or &lt;code&gt;sessionStorage&lt;/code&gt;. two of them do the thing the library is best at. that is not a close decision that went the wrong way, it is off by more than an order of magnitude.&lt;/p&gt;
&lt;p&gt;the namespacing row deserves a second look, because it is the one that looks like a clean win. my key builder does not just prefix. it scopes a key to whichever user is being acted for, and deliberately keeps the &lt;em&gt;unscoped&lt;/em&gt; key for the common case so that nobody who already saved a preference loses it. that is a migration decision encoded in a key shape. a &lt;code&gt;prefix&lt;/code&gt; option cannot express it, and swapping to one would silently reset every existing user&amp;rsquo;s saved preference. features that look like a superset of what you have sometimes quietly drop the part that was load-bearing.&lt;/p&gt;
&lt;h2 id="the-wall-synchronous-by-type"&gt;the wall: synchronous by type
&lt;/h2&gt;&lt;p&gt;the feature list says the backend is pluggable, and it is. here is the type:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;1&lt;/span&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CreateStorageOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;2&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;prefix?&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;3&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;separator?&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;4&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="cm"&gt;/** The underlying Storage backend. Defaults to `localStorage`. */&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;5&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;storage?&lt;/span&gt;: &lt;span class="kt"&gt;Storage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;6&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;serializer?&lt;/span&gt;: &lt;span class="kt"&gt;Serializer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;7&lt;/span&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Storage&lt;/code&gt; is the web platform interface, and it is synchronous by definition: &lt;code&gt;getItem(key: string): string | null&lt;/code&gt;. there is no &lt;code&gt;Promise&lt;/code&gt; anywhere in the library&amp;rsquo;s own source outside of one schema check.&lt;/p&gt;
&lt;p&gt;meanwhile the persistence that actually matters in my app is five Zustand stores going through &lt;code&gt;persist&lt;/code&gt;, and they do not sit on &lt;code&gt;localStorage&lt;/code&gt; at all. they sit on IndexedDB, which is asynchronous, which works because Zustand&amp;rsquo;s own interface allows it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;1&lt;/span&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PersistStorage&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;S&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;R &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="na"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;2&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;getItem&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;StorageValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;S&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;StorageValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;S&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="na"&gt;null&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;3&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;setItem&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;: &lt;span class="kt"&gt;StorageValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;S&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;4&lt;/span&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;removeItem&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;R&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;5&lt;/span&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;that &lt;code&gt;| Promise&amp;lt;...&amp;gt;&lt;/code&gt; is the whole difference. a library typed against &lt;code&gt;Storage&lt;/code&gt; cannot reach a surface typed like that, and no amount of quality changes it. this is worth naming clearly because it is invisible from the README: &lt;strong&gt;the library is not too weak for my main problem, it is in a different domain from it.&lt;/strong&gt; you find that in the type signature in about ninety seconds, and you never find it by reading the feature list.&lt;/p&gt;
&lt;p&gt;the cross-tab row is the same story with a happier ending. my app does synchronise across tabs, heavily, in two modules sharing one &lt;code&gt;BroadcastChannel&lt;/code&gt;. there are zero &lt;code&gt;storage&lt;/code&gt; event listeners in the whole codebase. &lt;code&gt;BroadcastChannel&lt;/code&gt; carries a structured payload and does not require a write to storage to fire, so it is the stronger mechanism for what i need. adopting the library&amp;rsquo;s &lt;code&gt;subscribe&lt;/code&gt; would not fill a gap, it would add a second, weaker path alongside the good one. two mechanisms doing one job is worse than either alone.&lt;/p&gt;
&lt;h2 id="two-things-i-said-that-the-source-killed"&gt;two things i said that the source killed
&lt;/h2&gt;&lt;p&gt;i had already given a verdict in chat before cloning anything. reading the source overturned two of my reasons, and i would rather publish that than quietly keep a right answer that had wrong support.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;a validating wrapper will throw, and my house rule is never throw.&amp;rdquo;&lt;/strong&gt; false. &lt;code&gt;getItem&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; when the key is missing, when the entry has expired, and when schema validation fails. the only throws are on programmer error: passing both &lt;code&gt;ttl&lt;/code&gt; and &lt;code&gt;expiresAt&lt;/code&gt;, a non-finite expiry, a non-string key segment. that is exactly the design i would have asked for, and my objection was invented.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;the package is one day old.&amp;rdquo;&lt;/strong&gt; unfair framing. the npm package was published on 9 September 2026, but the repository was created on 5 March 2026. six months of work, two days on the registry. the maturity caution survives (it is 0.6.0, and this is production) but &amp;ldquo;one day old&amp;rdquo; described the publish event, not the code, and i was using it as though it described the code.&lt;/p&gt;
&lt;p&gt;what does survive is smaller and duller: expiry reads &lt;code&gt;Date.now()&lt;/code&gt; directly inside the core, seven times. both of my TTL helpers take &lt;code&gt;now&lt;/code&gt; as an injectable parameter, which is why their tests are pure functions with no timer mocking. adopting would trade that for fake timers. minor, real, and nothing like the objection i originally raised.&lt;/p&gt;
&lt;h2 id="the-numbers-it-deserves-credit-for"&gt;the numbers it deserves credit for
&lt;/h2&gt;&lt;p&gt;measured, not quoted. bundled with esbuild, minified, gzip level 9:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;entry point&lt;/th&gt;
&lt;th&gt;gzipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ultrastorage&lt;/code&gt; (default, &lt;code&gt;devalue&lt;/code&gt; bundled)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5,349 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ultrastorage/core&lt;/code&gt; (bring your own serialiser, i passed &lt;code&gt;JSON&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,751 B&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;devalue&lt;/code&gt; &lt;code&gt;stringify&lt;/code&gt; and &lt;code&gt;parse&lt;/code&gt; alone, for comparison&lt;/td&gt;
&lt;td&gt;3,746 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;so the library&amp;rsquo;s own code is roughly 1.75 KB gzipped and the rest is its serialiser. shipping a &lt;code&gt;core&lt;/code&gt; entry that lets you drop &lt;code&gt;devalue&lt;/code&gt; entirely is a genuinely good decision that most packages do not bother making.&lt;/p&gt;
&lt;p&gt;one cost that is easy to miss. every value is wrapped in an envelope carrying a marker, a format version, and an expiry slot. storing a single boolean flag:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;1&lt;/span&gt;&lt;span class="cl"&gt;&amp;#34;1&amp;#34; 1 byte, native
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="ln"&gt;2&lt;/span&gt;&lt;span class="cl"&gt;[{&amp;#34;__us&amp;#34;:1,&amp;#34;version&amp;#34;:2,&amp;#34;value&amp;#34;:1,&amp;#34;expiry&amp;#34;:3},true,1,null] 57 bytes, wrapped
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;that is &lt;code&gt;devalue&lt;/code&gt;&amp;rsquo;s flat array format, which is the correct representation when values can contain cycles. it is also 57x for a flag. irrelevant at ten keys. worth knowing before you namespace a thousand of them.&lt;/p&gt;
&lt;h2 id="when-a-wrapper-does-pay"&gt;when a wrapper does pay
&lt;/h2&gt;&lt;p&gt;the general rule falls out of the counting. a wrapper earns its slot when&lt;/p&gt;
&lt;p&gt;$$ n_{\text{hurt}} \times s ;&amp;gt;; C_{\text{migrate}} + C_{\text{carry}} $$&lt;/p&gt;
&lt;p&gt;where $n_{\text{hurt}}$ is call sites that hurt &lt;em&gt;today&lt;/em&gt; (not sites that exist, and not sites you can imagine), $s$ is lines saved per site, $C_{\text{migrate}}$ is every site and test you have to touch to get there, and $C_{\text{carry}}$ is the permanent cost of one more thing in the tree that can break, drift, or go unmaintained.&lt;/p&gt;
&lt;p&gt;mine: two sites, about five lines each, so ten lines of benefit, against 75 call sites and roughly 40 test files that mock storage, plus the carry forever. not close.&lt;/p&gt;
&lt;p&gt;this is the same crossover as the one in https://blog.enkr1.com/build-vs-buy/, which is worth reading if you want the maths drawn properly, with one substitution: the x-axis there is &lt;strong&gt;time&lt;/strong&gt;, and here it is &lt;strong&gt;count&lt;/strong&gt;. build-versus-buy asks how long you will keep the thing. adopt-or-hand-roll asks how many places you will use it. everything else about the shape is identical.&lt;/p&gt;
&lt;p&gt;concrete thresholds i would now use for this class of thing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;2 sites: hand-roll.&lt;/strong&gt; the duplication is not real duplication yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;around 10 sites: a local helper.&lt;/strong&gt; thirty lines, no dependency, and it can keep your injectable clock and your never-throw contract, both of which a general library has to decide for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;30 or more, heterogeneous, with real rich types or a genuine multi-app namespace collision: a library.&lt;/strong&gt; now you are writing the library anyway, badly, and you should stop.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;and one that is not about count at all: &lt;strong&gt;greenfield flips it.&lt;/strong&gt; on a new project the incumbent is nothing, the migration cost is zero, and the bar is on the floor. the same package that fails a mature repo is an easy yes on day one. this is the selection-pressure argument from https://blog.enkr1.com/curation-beats-collection/ showing up in a different costume: a mature codebase rejects good things, and that is the codebase working, not the thing being bad.&lt;/p&gt;
&lt;h2 id="take-the-dependency-not-the-wrapper"&gt;take the dependency, not the wrapper
&lt;/h2&gt;&lt;p&gt;the last move is the one i keep relearning. suppose the rich-type feature &lt;em&gt;had&lt;/em&gt; found demand. the honest next step is not &amp;ldquo;install the wrapper&amp;rdquo;, it is &amp;ldquo;install the thing the wrapper installs&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;devalue&lt;/code&gt; is what does that work. Zustand&amp;rsquo;s &lt;code&gt;PersistStorage&lt;/code&gt; already lets you own serialisation completely, and &lt;code&gt;createJSONStorage&lt;/code&gt; even takes a &lt;code&gt;reviver&lt;/code&gt; and &lt;code&gt;replacer&lt;/code&gt;. so plugging &lt;code&gt;devalue&lt;/code&gt; into the storage layer i already have is one small file, and it reaches the async IndexedDB stores that the wrapper structurally cannot.&lt;/p&gt;
&lt;p&gt;i wrote a whole note six days ago about exactly this reflex, that the best answer to a tool comparison was often https://blog.enkr1.com/pick-the-layer-not-the-tool/ rather than any of the tools being compared. it turned up again within a week, in a completely different corner of the stack, which is a decent sign it is a real pattern and not a one-off.&lt;/p&gt;
&lt;h2 id="key-takeaways"&gt;key takeaways
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;a feature list is a description of the author&amp;rsquo;s codebase. the only translation into yours is a grep, and it costs ten minutes.&lt;/li&gt;
&lt;li&gt;count call sites that hurt &lt;strong&gt;today&lt;/strong&gt;. imagined future sites are how every unnecessary dependency gets justified.&lt;/li&gt;
&lt;li&gt;check the &lt;strong&gt;type signature&lt;/strong&gt; of the extension point before anything else. &amp;ldquo;pluggable&amp;rdquo; plus a synchronous type equals unreachable for your async layer, and no amount of quality fixes that.&lt;/li&gt;
&lt;li&gt;a feature you already have solved a better way is a &lt;strong&gt;negative&lt;/strong&gt;, not a neutral. adding a second mechanism for one job costs more than skipping it.&lt;/li&gt;
&lt;li&gt;if a wrapper&amp;rsquo;s one useful feature comes from its dependency, evaluate the dependency directly. it is usually smaller and it usually reaches further.&lt;/li&gt;
&lt;li&gt;publish the reasons that turned out wrong. the verdict here survived, but two of the four reasons i gave for it did not, and only opening the source showed that.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="a-personal-note"&gt;a personal note
&lt;/h2&gt;&lt;p&gt;wip &amp;hellip;&lt;/p&gt;
&lt;h2 id="sources-and-further-reading"&gt;sources and further reading
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;a class="link" href="https://github.com/yangshun/ultrastorage" target="_blank" rel="noopener"
&gt;ultrastorage&lt;/a&gt; by Yangshun Tay. repository created 5 March 2026, npm &lt;code&gt;0.6.0&lt;/code&gt; published 9 September 2026, MIT. all source claims here are read off a shallow clone of &lt;code&gt;main&lt;/code&gt; and the published tarball, on 11 September 2026, not off the README.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://github.com/sveltejs/devalue" target="_blank" rel="noopener"
&gt;devalue&lt;/a&gt;, the serialiser underneath it, from the Svelte team.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://github.com/standard-schema/standard-schema" target="_blank" rel="noopener"
&gt;Standard Schema&lt;/a&gt;, the shared validator interface it accepts, so zod, valibot and arktype all work.&lt;/li&gt;
&lt;li&gt;&lt;a class="link" href="https://zustand.docs.pmnd.rs/integrations/persisting-store-data" target="_blank" rel="noopener"
&gt;Zustand &lt;code&gt;persist&lt;/code&gt; middleware&lt;/a&gt;, where &lt;code&gt;PersistStorage&lt;/code&gt; and its async &lt;code&gt;getItem&lt;/code&gt; are documented.&lt;/li&gt;
&lt;li&gt;bundle figures produced locally with esbuild, &lt;code&gt;--bundle --minify --format=esm&lt;/code&gt;, then &lt;code&gt;gzip -9&lt;/code&gt;. the envelope figure is a real write against an in-memory &lt;code&gt;Storage&lt;/code&gt; shim, printed verbatim.&lt;/li&gt;
&lt;/ul&gt;</description></item></channel></rss>