<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss-styles.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><title>Ilias Trichopoulos&apos;s Blog</title><description>Articles about web development, performance, and open source.</description><link>https://www.nop33.com/</link><language>en-us</language><lastBuildDate>Sat, 01 Aug 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://www.nop33.com/rss.xml" rel="self" type="application/rss+xml"/><item><title>How to Combine TanStack Query Data in React</title><link>https://www.nop33.com/blog/combining-tanstack-query-data/</link><guid isPermaLink="true">https://www.nop33.com/blog/combining-tanstack-query-data/</guid><description>Four ways to derive a value from several TanStack Query endpoints, and the trade-off each one makes. Query composition and derived queries, compared.</description><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Who this is for.&lt;/strong&gt; You already use TanStack Query and are comfortable with &lt;code&gt;useQuery&lt;/code&gt; and query keys. If you are not there yet, read &lt;a href=&quot;https://tkdodo.eu/blog/practical-react-query&quot;&gt;TkDodo’s React Query series&lt;/a&gt; first. It is the best writing there is on this library, and everything below assumes it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;One common TanStack Query pattern is that you can create your own wrapper hooks around the primitive hooks the library provides, especially if you want to reuse them across several components or other hooks, or just want to keep your components’ code clean:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// src/components/Asset.tsx
const Asset = ({ assetId }) =&amp;gt; {
  const { data } = useAssetPrice(assetId)

  return (/* ... */)
}

// src/api/queries.ts
const useAssetPrice = (assetId) =&amp;gt; useQuery(priceQuery(assetId))

const priceQuery = (assetId) =&amp;gt;
  queryOptions({
    queryKey: [&apos;prices&apos;],
    queryFn: () =&amp;gt; fetchPrices(),
    select: (prices) =&amp;gt; prices[assetId]
  })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TkDodo recommends exactly this in &lt;a href=&quot;https://tkdodo.eu/blog/practical-react-query#create-custom-hooks&quot;&gt;Practical React Query&lt;/a&gt;, and it is good advice. The query key and the fetcher live in one place. The component doesn’t need to know how the data arrives, it just uses the data it needs.&lt;/p&gt;
&lt;p&gt;An interesting question then is: What goes &lt;strong&gt;inside&lt;/strong&gt; these wrapper hooks?&lt;/p&gt;
&lt;p&gt;For an example like the one above where the data the component needs comes directly from a single API endpoint, the answer is simple: just the &lt;code&gt;useQuery&lt;/code&gt; call. But what if the shape of the data the component needs has to &lt;strong&gt;be derived by combining data&lt;/strong&gt; from multiple API endpoints?&lt;/p&gt;
&lt;p&gt;To illustrate what I mean: Take the example of a portfolio app that supports multiple accounts and displays the total value. You create &lt;code&gt;usePortfolioValue()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The endpoints you have available for deriving the value are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;// get assets list of 1 account
GET /accounts/:id/assets -&amp;gt; [{ assetId, amount }]

// get prices of all assets from an external market-data service
GET /prices              -&amp;gt; { assetId: price }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;How would you do it?&lt;/p&gt;
&lt;p&gt;A &lt;a href=&quot;https://tkdodo.eu/blog/react-query-data-transformations&quot;&gt;detailed catalogue of options for transforming data&lt;/a&gt; already exists. Combining is a different problem, and only partially addressed online. If your backend can do the &lt;code&gt;JOIN&lt;/code&gt; for you, take it. But if it can’t, keep reading.&lt;/p&gt;
&lt;p&gt;You will see this called different things depending on where you read about it. Derived queries. Query composition. Combining queries. Same question.&lt;/p&gt;
&lt;p&gt;In this article I am exploring different ways the community and myself have thought of to solve this problem, each with its own trade-offs.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Spoiler alert&lt;/em&gt;: There is no one best solution, but a solution listed here might help you in your particular situation.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Back to the portfolio example. The user connects several accounts, let’s say 3 accounts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;stocks account&lt;/li&gt;
&lt;li&gt;precious metals account&lt;/li&gt;
&lt;li&gt;crypto account&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each account holds several assets. The overview page looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Total value
$48,150.00                                 

Asset   Allocation              %     Value
───────────────────────────────────────────
AAPL    ██████░░░░░░░░░░░░░░   32%  $15,408
VWRL    █████░░░░░░░░░░░░░░░   24%  $11,556
BTC     ████░░░░░░░░░░░░░░░░   18%   $8,667
MSFT    ███░░░░░░░░░░░░░░░░░   16%   $7,704
GOLD    ██░░░░░░░░░░░░░░░░░░   10%   $4,815
───────────────────────────────────────────
                              100%  $48,150&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Given the 2 available &lt;code&gt;GET&lt;/code&gt; endpoints mentioned above, let’s try to derive the total portfolio value.&lt;/p&gt;
&lt;h2&gt;1. Combine in the component&lt;/h2&gt;
&lt;p&gt;The most obvious one is to combine the data in the render function of the component, the body of our wrapper hook, by calling &lt;code&gt;useQuery&lt;/code&gt;/&lt;code&gt;useQueries&lt;/code&gt; for each piece of data and stitching them together:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const usePortfolioValue = () =&amp;gt; {
  const accountIds = useAccountIds()

  const { data: prices } = useQuery(pricesQuery())
  const { data: assets } = useQueries({
    queries: accountIds.map(assetsQuery),
    combine: mergeAssets
  })

  return useMemo(() =&amp;gt; 
    reduceTotalValue(assets, prices),
    [assets, prices]
  )
}

// module scope, so the reference stays stable
const mergeAssets = (assetsPerAccounts) =&amp;gt; 
  assetsPerAccounts.flatMap((account) =&amp;gt; account.data ?? [])&lt;/code&gt;&lt;/pre&gt;
&lt;aside&gt;  🤔 Aside  &lt;div&gt;&lt;p&gt;You might object and say &lt;em&gt;“Hey, you are also combining in &lt;code&gt;useQueries&lt;/code&gt;, you’re not just combining them in the &lt;code&gt;useMemo()&lt;/code&gt; call”&lt;/em&gt;. And you would be right. This is &lt;strong&gt;extremely&lt;/strong&gt; necessary.&lt;/p&gt;&lt;p&gt;First of all, why &lt;code&gt;useQueries&lt;/code&gt;? Because the number of accounts is not known ahead of time, and you can’t use &lt;code&gt;useQuery&lt;/code&gt; in a loop. Secondly, without &lt;code&gt;combine&lt;/code&gt; the results the &lt;code&gt;useQueries&lt;/code&gt; call spits out will be a freshly built array. On every render. This means the &lt;code&gt;useMemo()&lt;/code&gt; deps would change every time. Your &lt;code&gt;reduceTotalValue()&lt;/code&gt; function then runs on every render, defeating the point of using &lt;code&gt;useMemo()&lt;/code&gt;. So, use &lt;code&gt;combine&lt;/code&gt; when using &lt;code&gt;useQueries&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt; &lt;/aside&gt; 
&lt;p&gt;This is a totally valid approach. It is important, however, to understand &lt;a href=&quot;https://tkdodo.eu/blog/inside-react-query&quot;&gt;what happens under the hood&lt;/a&gt;. Let’s do some arithmetic. Our &lt;code&gt;usePortfolioValue()&lt;/code&gt; hook will create several Observers once it’s called, one for each Query. One for the prices data, and one for each account data. In our example of 3 accounts, that is &lt;strong&gt;4 Observers per consumer&lt;/strong&gt;, one for prices and one for each account, for every use of &lt;code&gt;usePortfolioValue()&lt;/code&gt; across our app.&lt;/p&gt;
&lt;p&gt;Another thing worth noticing is what happens when our cached data changes.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If prices change, the prices Observer notifies the component and it rerenders. The &lt;code&gt;mergeAssets()&lt;/code&gt; function, however, won’t run. It lives in a &lt;em&gt;different&lt;/em&gt; Observer, the one watching the accounts, and none of the queries that Observer watches has changed. &lt;code&gt;combine&lt;/code&gt; is memoized per Observer, so it hands back the previous result untouched. The &lt;code&gt;useMemo()&lt;/code&gt; deps do change, of course, so we get a new portfolio value.&lt;/li&gt;
&lt;li&gt;If an account’s data change, the corresponding Observer will run the &lt;code&gt;combine&lt;/code&gt; method (our &lt;code&gt;mergeAssets()&lt;/code&gt;) which will return new results. &lt;code&gt;useMemo()&lt;/code&gt; runs again and our components rerender.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. Combine in &lt;code&gt;combine&lt;/code&gt; of &lt;code&gt;useQueries&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Did you notice how useful that &lt;code&gt;combine&lt;/code&gt; memoization is? This next approach hands &lt;em&gt;all&lt;/em&gt; the queries to one &lt;code&gt;useQueries&lt;/code&gt; call, so the whole derivation gets it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const usePortfolioValue = () =&amp;gt; {
  const accountIds = useAccountIds()

  const { data } = useQueries({
    queries: [
      ...accountIds.map(assetsQuery),
      pricesQuery()
    ],
    combine: combineResults
  })

  return data
}

// prices was listed last, so everything before it is an account
const combineResults = (results) =&amp;gt; {
  const assetsPerAccounts = results.slice(0, -1)
  const prices = results.at(-1)?.data ?? {}

  return reduceTotalValue(mergeAssets(assetsPerAccounts), prices)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;reduceTotalValue()&lt;/code&gt; and &lt;code&gt;mergeAssets()&lt;/code&gt; stay the same as before, nothing new here about them. What is new is that &lt;code&gt;reduceTotalValue()&lt;/code&gt; is no longer called at the component/hook render function level but rather in the Observer level, just like our &lt;code&gt;mergeAssets()&lt;/code&gt; from before. That means the result is memoized, and the output of &lt;code&gt;combine&lt;/code&gt; runs through &lt;a href=&quot;https://tanstack.com/query/v5/docs/framework/react/guides/render-optimizations#structural-sharing&quot;&gt;structural sharing&lt;/a&gt; before it reaches the consumer, so the components rerender only when the portfolio value actually changes. If, for example, prices of two assets change together and they cancel each other out, the components won’t rerender. I wouldn’t judge you if you think this is a stupid example. But at least it demonstrates my point.&lt;/p&gt;
&lt;p&gt;Did you notice something, though? What happens now if only 1 price changes? The Observer will run &lt;code&gt;combineResults()&lt;/code&gt; which itself will run &lt;code&gt;mergeAssets()&lt;/code&gt; and the new portfolio value is returned. In our previous approach, &lt;code&gt;mergeAssets()&lt;/code&gt; didn’t need to run. The assets didn’t change, only a price changed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You traded one optimization for another.&lt;/strong&gt; If your &lt;code&gt;mergeAssets()&lt;/code&gt; is the expensive one, prefer the first approach. If your &lt;code&gt;reduceTotalValue()&lt;/code&gt; is the expensive one, prefer the latter one.&lt;/p&gt;
&lt;p&gt;(&lt;em&gt;I’d add that you’re also trading some code readability.&lt;/em&gt;)&lt;/p&gt;
&lt;p&gt;As for the number of Observers, nothing changed. Still &lt;strong&gt;4 Observers per consumer&lt;/strong&gt;. Same queries, just combined in a different place.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;You are happy with your &lt;code&gt;usePortfolioValue()&lt;/code&gt; hook, you pat yourself on the back, and you start using it around in your codebase. In fact, you realize you need to calculate the allocation percentage for each asset row, so you decide to use your new hook on each asset row. Now, &lt;code&gt;usePortfolioValue()&lt;/code&gt; has 6 consumers, the header value, and each row.&lt;/p&gt;
&lt;p&gt;(&lt;em&gt;If you think this example is not realistic and that the developer should instead prop-drill the value or put it in a Context, you have a point. That is a discussion of its own and I cover it in a follow-up. Here it is just a way to illustrate the other approaches.&lt;/em&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Total value
$48,150.00                                   &amp;lt;- usePortfolioValue()

Asset   Allocation              %     Value
───────────────────────────────────────────
AAPL    ██████░░░░░░░░░░░░░░   32%  $15,408  &amp;lt;- usePortfolioValue()
VWRL    █████░░░░░░░░░░░░░░░   24%  $11,556  &amp;lt;- usePortfolioValue()
BTC     ████░░░░░░░░░░░░░░░░   18%   $8,667  &amp;lt;- usePortfolioValue()
MSFT    ███░░░░░░░░░░░░░░░░░   16%   $7,704  &amp;lt;- usePortfolioValue()
GOLD    ██░░░░░░░░░░░░░░░░░░   10%   $4,815  &amp;lt;- usePortfolioValue()
───────────────────────────────────────────
                              100%  $48,150&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You think to yourself, &lt;em&gt;“hmm… so much wasted computation. Every time I use &lt;code&gt;usePortfolioValue()&lt;/code&gt;, the results of &lt;code&gt;reduceTotalValue()&lt;/code&gt; and &lt;code&gt;mergeAssets()&lt;/code&gt; need to be recalculated.”&lt;/em&gt; And you would be correct. No matter which of the above approaches you use, the results are cached either at the component level (in &lt;code&gt;useMemo()&lt;/code&gt;) or in the Observer level (in &lt;code&gt;combine&lt;/code&gt;). The results are cached &lt;strong&gt;per consumer&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;There are several ideas on how to share a cache between consumers. &lt;a href=&quot;https://tkdodo.eu/blog/react-query-selectors-supercharged#the-final-boss&quot;&gt;One idea is to move the memoization outside of the components and the Observers&lt;/a&gt; completely by using a separate library like &lt;a href=&quot;https://github.com/caiogondim/fast-memoize.js&quot;&gt;fast-memoize.js&lt;/a&gt;. There are, however, a few more ideas that don’t require an additional package, which you might find useful.&lt;/p&gt;
&lt;h2&gt;3. Combine in &lt;code&gt;queryFn&lt;/code&gt; with &lt;code&gt;Promise.all&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;One of these ideas is to move the derived value cache one level deeper. In the QueryCache itself. Who controls what goes in the QueryCache? The &lt;code&gt;queryFn&lt;/code&gt; function of a Query. So &lt;a href=&quot;https://github.com/TanStack/query/discussions/764#discussioncomment-44804&quot;&gt;here’s a take on it&lt;/a&gt;, using &lt;code&gt;Promise.all()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const usePortfolioValue = () =&amp;gt; {
  const accountIds = useAccountIds()
  
  const { data } = useQuery(portfolioQuery(accountIds))

  return data
}

const portfolioQuery = (accountIds) =&amp;gt;
  queryOptions({
    queryKey: [&apos;portfolio&apos;, accountIds],
    queryFn: async () =&amp;gt; {
      const assetsPromises = Promise.all(accountIds.map(fetchAccountAssets))
      const [assetsPerAccounts, prices] = await Promise.all([
        assetsPromises,
        fetchPrices()
      ])

      return reduceTotalValue(assetsPerAccounts.flat(), prices)
    }
  })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The derivation of the portfolio value now happens at the Query level and stored in the QueryCache. It is calculated on every refetch. It is not calculated per consumer anymore. When all 6 of the above components mount, the calculation will run only once. Your components now have a cache they can share. But at what cost?&lt;/p&gt;
&lt;p&gt;Since the QueryCache now stores only the derived value, any component that simply needs the price of an asset needs to fetch it again:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const { data: prices } = useQuery(pricesQuery())&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So one cost of a shared derived cache is additional network requests. You’ve lost granularity.&lt;/p&gt;
&lt;p&gt;Another cost is error handling. I purposefully left loading and error states out of scope to keep the article content simple. But it’s worth talking about it here. With the above &lt;code&gt;Promise.all()&lt;/code&gt; implementation, if &lt;em&gt;any&lt;/em&gt; of the API calls returns an error, the whole Query throws and will be retried again, depending on your configuration. Imagine an extreme scenario where your app has 100 accounts, not 3. Fire 101 requests at the same time and you will most probably hit some rate limiting and &lt;code&gt;429&lt;/code&gt;s. Even if only 1 out of those 101 returns a &lt;code&gt;429&lt;/code&gt;, all 101 requests will then be retried.&lt;/p&gt;
&lt;p&gt;This again, is a trade-off against additional network requests, and an even more dangerous one that you need to watch out for. You could, of course, reach for &lt;code&gt;Promise.allSettled()&lt;/code&gt; but that wouldn’t solve your retry problem, unless you re-implement retries within the &lt;code&gt;queryFn&lt;/code&gt; but let’s not get there.&lt;/p&gt;
&lt;p&gt;Last thing worth mentioning: you are now down to &lt;strong&gt;1 Observer per consumer&lt;/strong&gt;, from 4. The whole fan-out moved inside the &lt;code&gt;queryFn&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;4. Combine in &lt;code&gt;queryFn&lt;/code&gt; with &lt;code&gt;fetchQuery&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;This loss of granularity really bugs you. But then you remember: The &lt;code&gt;queryFn&lt;/code&gt; doesn’t care what’s inside as long as it’s a &lt;code&gt;Promise&lt;/code&gt;. API calls return promises, of course, but so do &lt;code&gt;queryClient.fetchQuery&lt;/code&gt; calls. What if we replaced our raw API calls from the example above with calls to the QueryCache?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const usePortfolioValue = () =&amp;gt; {
  const accountIds = useAccountIds()
  
  const { data } = useQuery(portfolioQuery(accountIds))

  return data
}

const portfolioQuery = (accountIds) =&amp;gt;
  queryOptions({
    queryKey: [&apos;portfolio&apos;, accountIds],
    queryFn: async () =&amp;gt; {
      const assetsPromises = Promise.all(
        accountIds.map((id) =&amp;gt; queryClient.fetchQuery(assetsQuery(id)))
      )
      const [assets, prices] = await Promise.all([
        assetsPromises,
        queryClient.fetchQuery(pricesQuery())
      ])

      return reduceTotalValue(assets.flat(), prices)
    }
  })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, look at that. With only a two-line change, each account keeps its own cache. Prices too. So we can still make the below calls anywhere in our app and we will hit the same cache:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;useQuery(assetsQuery(accountId))
useQuery(pricesQuery())&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Depending on &lt;code&gt;staleTime&lt;/code&gt;, &lt;code&gt;fetchQuery&lt;/code&gt; returns the cached value for everything still fresh and only fetches what was invalidated. And the composed result gets an entry too, computed once and read by everyone. I felt very proud when I came up with this idea, and very happy when I later discovered that &lt;a href=&quot;https://github.com/TanStack/query/discussions/2178#discussioncomment-3615704&quot;&gt;TkDodo pointed at this solution as well&lt;/a&gt;, in a discussion titled, aptly, &lt;em&gt;Derived Queries&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;You must realize though what you have just traded, once again. A derived entry in the QueryCache is a snapshot. Not a subscription. If the prices query is invalidated, no consumer of &lt;code&gt;usePortfolioValue()&lt;/code&gt; will re-render. The &lt;code&gt;portfolioQuery&lt;/code&gt; has not been invalidated. Query invalidation for the portfolio value is now on your hands. I’ll save you the Uncle Ben quote here.&lt;/p&gt;
&lt;p&gt;Nevertheless, it can be useful. If your app is &lt;a href=&quot;https://tkdodo.eu/blog/react-query-as-a-state-manager#smart-refetches&quot;&gt;built around manual invalidations&lt;/a&gt;, this can be a totally valid approach. In an &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-desktop-wallet/&quot;&gt;app I’ve worked on&lt;/a&gt; I use a &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling/&quot;&gt;“change detection” query&lt;/a&gt; that asks the server “are there new data?” and invalidates several queries only when the answer is yes.&lt;/p&gt;
&lt;p&gt;And like the previous approach, you are down to &lt;strong&gt;1 Observer per consumer&lt;/strong&gt;. Unlike it, each source keeps its own cache entry.&lt;/p&gt;
&lt;h2&gt;Which one to reach for&lt;/h2&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Observers per consumer&lt;/th&gt;&lt;th&gt;Derivation runs&lt;/th&gt;&lt;th&gt;What you give up&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1. Combine in the component&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;once per consumer&lt;/td&gt;&lt;td&gt;nothing shared between consumers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2. Combine in &lt;code&gt;combine&lt;/code&gt;&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;once per consumer&lt;/td&gt;&lt;td&gt;any query data change re-runs the merge too&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3. &lt;code&gt;Promise.all&lt;/code&gt; in &lt;code&gt;queryFn&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;once, shared&lt;/td&gt;&lt;td&gt;granular caches, and one failure retries everything&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4. &lt;code&gt;fetchQuery&lt;/code&gt; in &lt;code&gt;queryFn&lt;/code&gt;&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;once, shared&lt;/td&gt;&lt;td&gt;you own invalidation of the derived entry&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Reading it as a decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Few consumers?&lt;/strong&gt; Stop at approach 1 or 2. The sharing the others buy you is not worth the trade. Pick between them by which function is the expensive one, &lt;code&gt;mergeAssets()&lt;/code&gt; or &lt;code&gt;reduceTotalValue()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Many consumers, and nothing else in the app needs the raw sources?&lt;/strong&gt; Approach 3 is the simplest thing that shares the work. Watch the error handling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Many consumers, and other screens do need the sources?&lt;/strong&gt; Approach 4. Pay for it by invalidating the derived entry yourself. Or use an external library, which comes with its own trade-offs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these is the right answer in general. They are four different answers to &lt;em&gt;“where should this value be computed and cached”&lt;/em&gt;. Each of your use-cases can have a different answer. You are not restricted to using only one of them.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;All above approaches are different ideas of how to implement your &lt;code&gt;useQuery&lt;/code&gt;/&lt;code&gt;useQueries&lt;/code&gt; hook wrappers. We started simple, by deriving and memoizing the data in the render functions, moving to memoizing them in the Observer level, and finally bringing them inside the QueryCache so that all consumers can have access to the same derived data. There are some things though that I alluded to but haven’t talked about.&lt;/p&gt;
&lt;p&gt;I keep counting Observers and I never said why it matters. That turns out to be an article of its own, because it is really a question about where you call these hooks rather than how you write them.&lt;/p&gt;
&lt;p&gt;TanStack Query encourages you to call them wherever you need the data. That advice is good, and it is not free. I built a slight variation of this portfolio page in nine different ways and measured each one. I’ll present my research in a follow-up article.&lt;/p&gt;
&lt;p&gt;What do you think about these approaches? Have you thought of other ones? Did I miss a hidden trade-off? I’d be very happy to hear about it!&lt;/p&gt;
&lt;h2&gt;Reference of community discussions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/discussions/764&quot;&gt;https://github.com/TanStack/query/discussions/764&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/discussions/2178&quot;&gt;https://github.com/TanStack/query/discussions/2178&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/discussions/6337&quot;&gt;https://github.com/TanStack/query/discussions/6337&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/issues/7129&quot;&gt;https://github.com/TanStack/query/issues/7129&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/combining-tanstack-query-data/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><enclosure url="https://www.nop33.com/_astro/cover.LanmWS_-_Z1kAT1d.webp" length="0" type="image/webp"/></item><item><title>Instant Cold Starts with a Persisted TanStack Query Cache (Part 5)</title><link>https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start/</link><guid isPermaLink="true">https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start/</guid><description>Persist the TanStack Query cache per wallet for instant cold starts, with sync writes at lifecycle edges plus batching and retries under rate limits.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;nav&gt; &lt;p&gt; A TanStack Query Case Study (5 Part Series) &lt;/p&gt; &lt;ol&gt; &lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;  1  Why Async State Broke Our Wallets &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;  2  Migrating Redux to TanStack Query &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;  3  Change Detection for Fewer Requests &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;  4  Derived State Without Melting the CPU &lt;/a&gt; &lt;/li&gt;&lt;li&gt;   5  Persisting Cache for Cold Starts  &lt;/li&gt; &lt;/ol&gt; &lt;/nav&gt; 
&lt;p&gt;&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt; improved performance: requests gated, derivations cached and shared. But one path none of that touches is the cold start. When the app launches with an empty cache, a big wallet still has to fetch everything before it can paint a single balance. This final part is about persisting the cache.&lt;/p&gt;
&lt;aside&gt;  ⚡ TL;DR  &lt;div&gt;&lt;ul&gt;
&lt;li&gt;Cold launches were slow because a big wallet refetches everything. I persist the cache per wallet and restore it on launch.&lt;/li&gt;
&lt;li&gt;I write synchronously, only at lifecycle edges (background on mobile, before-quit on desktop).&lt;/li&gt;
&lt;li&gt;The end state, measured: a warm relaunch of a 42-address wallet costs &lt;strong&gt;7 backend requests&lt;/strong&gt; and under two seconds of total blocking time. The pre-TanStack baseline: 1,595 requests, 61% of them rejected with &lt;code&gt;429&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt; &lt;/aside&gt; 
&lt;h2&gt;Persisting the cache for instant cold starts&lt;/h2&gt;
&lt;p&gt;Even with the gate and the compute-once layer, a fresh launch is brutal: a big wallet has to refetch everything before it can render. The fix is to persist the TanStack cache to disk and restore it on launch, so the app paints from last session’s data instantly and only refetches what the gate says is stale.&lt;/p&gt;
&lt;p&gt;Measured on today’s build with a 42-address wallet: a launch with an empty cache fires &lt;strong&gt;165 backend requests&lt;/strong&gt; before the dashboard is fully live. A launch with a restored cache fires &lt;strong&gt;seven&lt;/strong&gt;: four latest-transaction gate polls and three price refreshes. The dashboard paints from last session’s data immediately, and the gate revalidates in the background. (The full before-and-after table, including the pre-TanStack baseline, closes this post and brings this series to a conclusion.)&lt;/p&gt;
&lt;p&gt;The interesting part is when to write. The obvious approach, persist on every cache change, felt wrong, and the stock persisters throttle the write to once per second. But the throttle is also a trap: it defers the write, so when the app is backgrounded or the wallet is switched, the write loses the race against process suspension and you restore nothing. So I hand-rolled the persisters to write &lt;strong&gt;synchronously, only at lifecycle edges.&lt;/strong&gt; Here is the mobile one, on top of MMKV:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { MMKV } from &apos;react-native-mmkv&apos;
import { stringify } from &apos;@alephium/web3&apos;

// We deliberately DON&apos;T use createSyncStoragePersister: its 1000ms throttle defers the write,
// so the actual MMKV write is lost when the app is backgrounded or the wallet is switched.
export const createTanstackAsyncStoragePersister = (key) =&amp;gt; {
  const storage = new MMKV({ id: key })
  return {
    persistClient: (client) =&amp;gt; storage.set(CACHE_KEY, stringify(client)),
    restoreClient: () =&amp;gt; { const s = storage.getString(CACHE_KEY); return s ? JSON.parse(s) : undefined },
    removeClient: () =&amp;gt; storage.delete(CACHE_KEY)
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/apps/mobile-wallet/src/persistent-storage/tanstackAsyncStoragePersister.ts&quot;&gt;&lt;code&gt;tanstackAsyncStoragePersister.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The trigger is a single &lt;code&gt;AppState&lt;/code&gt; listener that fires the write on the transition to background:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;useEffect(() =&amp;gt; {
  const sub = AppState.addEventListener(&apos;change&apos;, (next) =&amp;gt; {
    if (next.match(/inactive|background/) &amp;amp;&amp;amp; appState.current === &apos;active&apos; &amp;amp;&amp;amp; walletId) {
      persistQueryCache(walletId)
    }
    appState.current = next
  })
  return () =&amp;gt; sub.remove()
}, [persistQueryCache, walletId])&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/apps/mobile-wallet/src/features/persistQueryCache/usePersistQueryCacheOnBackground.ts&quot;&gt;&lt;code&gt;usePersistQueryCacheOnBackground.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The desktop equivalent is an Electron &lt;code&gt;before-quit&lt;/code&gt; dance: the main process intercepts the quit, sends an IPC message to the renderer, the renderer awaits the (async, IndexedDB) write, and only then lets the app actually exit.&lt;/p&gt;
&lt;p&gt;One honest correction worth stating plainly: I used to justify this design with performance, but the real justification is correctness, avoiding data loss on suspend. When I profiled the pre-optimization build sitting idle on the dashboard, the throttled persister did turn out to be the single largest CPU consumer of a wallet at rest, re-serializing the entire cache on every poll tick, roughly 4% CPU spent persisting data in which nothing had visibly changed. Wasteful, then. But never the jank I used to blame on it: the main thread was 92% idle throughout. The data-loss reasoning is airtight; the performance reasoning was mostly a phantom.&lt;/p&gt;
&lt;h3&gt;Design choices&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;await persistQueryClientSave({
  queryClient,
  persister: createPersister(getPersisterKey(walletId)), // one cache file per wallet
  dehydrateOptions: {
    // Never write testnet/devnet data to disk.
    shouldDehydrateQuery: (query) =&amp;gt;
      query.meta?.isMainnet === false ? false : defaultShouldDehydrateQuery(query)
  }
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/persistQueryClientContext.tsx&quot;&gt;&lt;code&gt;persistQueryClientContext.tsx&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Per-wallet namespacing.&lt;/strong&gt; Each wallet gets its own cache blob, restored on unlock, so switching wallets never bleeds data across.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Amounts as strings.&lt;/strong&gt; Every balance is stored as a string, not a &lt;code&gt;bigint&lt;/code&gt;, because &lt;code&gt;bigint&lt;/code&gt; is not JSON-serializable. This keeps the persisted payload plain JSON and is the invariant the whole persistence layer quietly depends on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;maxAge: Infinity&lt;/code&gt;, and a deliberate asymmetry.&lt;/strong&gt; Desktop wipes its cache on a version mismatch; mobile does not, because mobile updates ship silently and clearing the cache on every update would force a full refetch and a visibly slow launch for infrequent users. The right fix is not a time-based &lt;code&gt;maxAge&lt;/code&gt; (which punishes the once-a-month user) but a hand-bumped &lt;code&gt;buster&lt;/code&gt; schema version that only changes when the shape of cached data changes, keeping the cache across normal updates and invalidating it precisely when it would otherwise be unsafe.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The cold-start payoff&lt;/h3&gt;
&lt;p&gt;Here is the scenario that justifies persisting computed data, not just raw responses. A user connects to a dApp in their browser, which launches the desktop wallet. The wallet rehydrates and immediately opens a “select an address to connect” modal, which needs the per-address search strings so the user can filter. If those search strings are not already in the restored cache, they recompute on the main thread the instant the modal mounts, and the UI janks.&lt;/p&gt;
&lt;p&gt;This is why “persist sources, derive on read”, the tidy principle jotai would nudge me toward, is wrong for this path. Cold-start-critical derived data must be persisted already computed, so it is there the microsecond the modal opens. My architecture already does the right thing here, and it is the most concrete argument against the lazier alternative.&lt;/p&gt;
&lt;h3&gt;Persistence trade-offs&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No size guard.&lt;/strong&gt; Because the balance and token queries are &lt;code&gt;gcTime: Infinity&lt;/code&gt;, they are never garbage-collected, so a heavily-used whale wallet’s persisted blob grows unbounded, and on mobile it is &lt;code&gt;stringify&lt;/code&gt;-d synchronously on the JS thread at the exact moment the OS is suspending the app. If this ever bites, the surgical fix is to trim persisted infinite-transaction history to its first page on dehydrate (the biggest contributor, and not cold-start-critical), not to drop the derived queries the connect modal depends on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serialization fidelity differs across platforms.&lt;/strong&gt; Mobile round-trips through JSON; desktop’s IndexedDB uses structured clone, which natively handles &lt;code&gt;bigint&lt;/code&gt;, &lt;code&gt;Map&lt;/code&gt;, and &lt;code&gt;Set&lt;/code&gt;. Today this is moot because everything is a string, but it is an undocumented invariant one refactor away from a hard-to-reproduce corruption bug.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Batching&lt;/strong&gt; cuts the request count, &lt;strong&gt;throttling&lt;/strong&gt; caps the rate of what is left, &lt;strong&gt;retrying&lt;/strong&gt; recovers the residue, &lt;strong&gt;persistence&lt;/strong&gt; eliminates most cold-start requests entirely, and &lt;strong&gt;the gate&lt;/strong&gt; (&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt;) kills steady-state requests. Each layer covers the failure mode of the one below it.&lt;/p&gt;
&lt;h2&gt;The series, measured&lt;/h2&gt;
&lt;p&gt;One table to close the series. Same 42-address wallet (203 tokens, 360 NFTs), same machine, same four actions (unlock, wait for it to settle, open the Addresses page, return to the Overview), development builds throughout. Three eras: the Redux architecture whose problems started this series in &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;Part 1&lt;/a&gt;, the naive TanStack migration that &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt; profiled, and today’s architecture with the gate, the compute-once layer, and persistence.&lt;/p&gt;






















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Redux (v2.3.6)&lt;/th&gt;&lt;th&gt;Naive TanStack&lt;/th&gt;&lt;th&gt;Today, cold start&lt;/th&gt;&lt;th&gt;Today, warm start&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Backend requests&lt;/td&gt;&lt;td&gt;1,595&lt;/td&gt;&lt;td&gt;210&lt;/td&gt;&lt;td&gt;165&lt;/td&gt;&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;429&lt;/code&gt; responses&lt;/td&gt;&lt;td&gt;981 (61%)&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Total blocking time&lt;/td&gt;&lt;td&gt;2.4s&lt;/td&gt;&lt;td&gt;207.9s&lt;/td&gt;&lt;td&gt;3.8s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1.9s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Worst single main-thread task&lt;/td&gt;&lt;td&gt;0.5s&lt;/td&gt;&lt;td&gt;65.4s&lt;/td&gt;&lt;td&gt;0.7s&lt;/td&gt;&lt;td&gt;0.8s&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Main thread busy&lt;/td&gt;&lt;td&gt;38%&lt;/td&gt;&lt;td&gt;86%&lt;/td&gt;&lt;td&gt;25%&lt;/td&gt;&lt;td&gt;&lt;strong&gt;8%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Balances visible&lt;/td&gt;&lt;td&gt;after a ~40s trickle&lt;/td&gt;&lt;td&gt;after the freezes&lt;/td&gt;&lt;td&gt;~5s to first, ~15s to all&lt;/td&gt;&lt;td&gt;instantly, from the restored cache&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The arc is honest: the Redux era was never CPU-bound. It drowned the network and starved the user of data. The naive TanStack migration fixed the network in one shot, request deduplication and batching cut the flood to 210 requests and killed the &lt;code&gt;429&lt;/code&gt;s outright, but it melted the CPU instead. The architecture of Parts 3, 4, and 5 holds both at once: a main thread quieter than the Redux era ever managed (25% busy on a cold start versus Redux’s 38%, with balances arriving in seconds instead of a 40-second trickle), warm-relaunch requests down 228x (1,595 to 7), blocking time down 107x against the naive migration (207.9s to 1.9s), and not a single &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Final words&lt;/h2&gt;
&lt;p&gt;If you are deep in TanStack Query and any of this makes you wince, especially the side-effect-in-&lt;code&gt;queryFn&lt;/code&gt; gate or the manual coherence model, I would genuinely like to hear it. The best architectures get sharper under exactly that kind of scrutiny.&lt;/p&gt;
&lt;p&gt;This wraps up the series. It started with &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;three problems&lt;/a&gt;, a tangled flow, a request flood, and slow updates, and closed with a layered TanStack Query architecture that answers all three. Thanks for reading.&lt;/p&gt;
&lt;p&gt;Useful resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://tanstack.com/query/v5/docs/framework/react/guides/request-waterfalls&quot;&gt;TanStack Query, Request Waterfalls guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tanstack.com/query/v5/docs/framework/react/plugins/persistQueryClient&quot;&gt;TanStack Query, &lt;code&gt;persistQueryClient&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tkdodo.eu/blog/react-query-selectors-supercharged&quot;&gt;TkDodo, React Query Selectors, Supercharged&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tkdodo.eu/blog/react-query-as-a-state-manager&quot;&gt;TkDodo, React Query as a State Manager&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tkdodo.eu/blog/react-query-and-react-context&quot;&gt;TkDodo, React Query and React Context&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/TanStack/query/discussions/2178&quot;&gt;Derived queries discussion (#2178)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><category>React Native</category><category>Electron</category><category>Performance</category></item><item><title>Derived Wallet State Without Melting the CPU (TanStack Query, Part 4)</title><link>https://www.nop33.com/blog/tanstack-query-derived-state-performance/</link><guid isPermaLink="true">https://www.nop33.com/blog/tanstack-query-derived-state-performance/</guid><description>Stop re-deriving wallet state per component: compose in cached queries and hoist fan-outs into Context so expensive work runs once.</description><pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;nav&gt; &lt;p&gt; A TanStack Query Case Study (5 Part Series) &lt;/p&gt; &lt;ol&gt; &lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;  1  Why Async State Broke Our Wallets &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;  2  Migrating Redux to TanStack Query &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;  3  Change Detection for Fewer Requests &lt;/a&gt; &lt;/li&gt;&lt;li&gt;   4  Derived State Without Melting the CPU  &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;  5  Persisting Cache for Cold Starts &lt;/a&gt; &lt;/li&gt; &lt;/ol&gt; &lt;/nav&gt; 
&lt;p&gt;&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt; got the request count under control by gating everything behind a single cheap poll. But the migration that cut the requests also created a bottleneck of its own: &lt;strong&gt;the CPU&lt;/strong&gt;. Almost no component wants a raw API response. It wants data derived from several of them, and deriving that data inside every component is what made the app janky. The old Redux architecture never had this problem, because it computed each derivation once, into the store (the exact hesitation from &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;Part 2&lt;/a&gt;). The naive TanStack version multiplied it by every component that asked.&lt;/p&gt;
&lt;aside&gt;  ⚡ TL;DR  &lt;div&gt;&lt;ul&gt;
&lt;li&gt;Deriving wallet data inside each component made the app sluggish. My derive functions were cheap; what melted the CPU was TanStack’s per-observer bookkeeping, multiplied across thousands of &lt;code&gt;QueryObserver&lt;/code&gt;s.&lt;/li&gt;
&lt;li&gt;Fix 1: push composition into a &lt;code&gt;queryFn&lt;/code&gt; that pulls its dependencies via &lt;code&gt;queryClient.fetchQuery&lt;/code&gt;, so the computed result is cached once and shared everywhere.&lt;/li&gt;
&lt;li&gt;Fix 2: hoist the wallet-wide &lt;code&gt;useQueries&lt;/code&gt; fan-out into a React Context (dependency injection, not a second store) so it runs once for the whole tree.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;select&lt;/code&gt; is the lighter tool for single-source slicing; &lt;code&gt;fetchQuery&lt;/code&gt;-in-&lt;code&gt;queryFn&lt;/code&gt; is for cross-source composition, at the cost of owning invalidation.&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt; &lt;/aside&gt; 
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;h3&gt;Wallet worth calculation&lt;/h3&gt;
&lt;p&gt;To calculate the worth of the wallet, we need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;find out the balances of each token for every address&lt;/li&gt;
&lt;li&gt;find out the price of each token&lt;/li&gt;
&lt;li&gt;multiply token price with its balance and sum the results&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To get the token balances of every address, we need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;call the address ALPH balance endpoint&lt;/li&gt;
&lt;li&gt;call the token balances endpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The above will return a list of token IDs and their balances.
To get prices we need the token symbol (like USDT, ALPH, etc.).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;get token symbols by fetching the latest token list from GitHub.
This is a JSON file mapping token IDs to metadata (including the token’s symbol, like WETH).&lt;/li&gt;
&lt;li&gt;get token prices by calling the token prices endpoint and passing the list of symbols&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We can now multiply the balance with the price and sum the results for every address to finally calculate the wallet’s worth.&lt;/p&gt;
&lt;h2&gt;The symptom&lt;/h2&gt;
&lt;p&gt;The dashboard janked. Scrolling the token list stuttered, and every background refresh blocked the UI. It was not a network problem (&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt; had already tamed the requests); it was the CPU.&lt;/p&gt;
&lt;p&gt;I loaded my wallet, and profiled the Electron renderer through four minutes of completely ordinary usage: unlock the wallet, wait for it to settle, click to the Addresses page, click back to the Overview. (Development build, so the absolute numbers are inflated. The shape is what matters.)&lt;/p&gt;
&lt;section&gt; &lt;p&gt;&lt;img src=&quot;https://www.nop33.com/_astro/alephium-wallet-profiler-hell.CgrhIHHv_2jcmnM.webp&quot; alt=&quot;A DevTools Performance trace of the desktop wallet: the CPU track is solid yellow for minutes at a time, the Frames track is one long red stretch, and the Bottom-Up view is topped by garbage collection and TanStack Query internals&quot; width=&quot;3680&quot; height=&quot;2382&quot; /&gt;&lt;/p&gt; &lt;/section&gt;
&lt;p&gt;The trace reads like a crime scene:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Total blocking time: 207,861 ms.&lt;/strong&gt; Three and a half minutes of blocked main thread in a four-minute recording.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unlocking&lt;/strong&gt; ran back-to-back main-thread tasks of 47.7s, 43.0s, and 17.9s: about 109 seconds of continuous freeze.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Navigating to the Addresses page&lt;/strong&gt; blocked for about 7 seconds. &lt;strong&gt;Navigating back to the Overview&lt;/strong&gt; produced a single 65-second task followed by a 27-second follow-up: about 93 seconds, from one click.&lt;/li&gt;
&lt;li&gt;The Frames track shows stretches of 30 to 40 seconds where the app shipped &lt;strong&gt;zero frames&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For calibration: the same four actions on the pre-TanStack build cost &lt;strong&gt;2.4 seconds&lt;/strong&gt; of total blocking time. This jank was not inherited. It was new, and it was mine.&lt;/p&gt;
&lt;p&gt;Two details in that trace taught me the most. First, the 65-second block is &lt;em&gt;one task&lt;/em&gt;: with React’s legacy sync rendering, the entire notify-and-re-render avalanche completes in a single turn of the event loop, which is why the app cannot even repaint while it happens. Second, the asymmetry: leaving the dashboard cost 7 seconds, returning to it cost 93. The page being &lt;em&gt;mounted&lt;/em&gt; pays the observer bill, and the dashboard, with its wallet worth total and token lists fanning out across every address, has by far the biggest bill.&lt;/p&gt;
&lt;p&gt;The profiler pointed the finger at long-running functions &lt;strong&gt;inside the TanStack Query library itself&lt;/strong&gt;, not my own code. But &lt;em&gt;which&lt;/em&gt; functions surprised me, and it is worth being precise about, because I had misdiagnosed it until I measured.&lt;/p&gt;
&lt;h2&gt;The problem, in code&lt;/h2&gt;
&lt;p&gt;Almost no component wants a raw API response. They want derived data: the wallet’s total balance across all addresses, the list of tokens separated into listed fungibles, unlisted fungibles, and NFTs, the fiat value of a holding. Early on, every component that needed this computed it itself, with a hook shaped like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// The naive version: every component that needed the wallet&apos;s tokens called a hook like this.
const useWalletTokensByType = () =&amp;gt; {
  const addressHashes = useUnsortedAddressesHashes()

  return useQueries({
    queries: addressHashes.map((hash) =&amp;gt; addressTokensBalancesQuery({ hash, networkId, isNodeOnline })),
    // This combine re-derives the same result on every render, in every component that calls the hook.
    combine: (results) =&amp;gt; separateTokensByType(results) // sort, group by standard, merge metadata, fold balances…
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twenty components calling a hook like that created twenty sets of observers and ran &lt;code&gt;separateTokensByType&lt;/code&gt; twenty times on every render.&lt;/p&gt;
&lt;p&gt;For a long time, my diagnosis was structural sharing: TanStack’s &lt;code&gt;replaceEqualDeep&lt;/code&gt; walking large token arrays once per observer. The profiler says otherwise. &lt;code&gt;replaceEqualDeep&lt;/code&gt; accounts for well under a tenth of a second, and my &lt;code&gt;combine&lt;/code&gt; callbacks for even less, in traces where the main thread was pegged for minutes. What actually dominated was the bookkeeping TanStack performs &lt;strong&gt;for each observer&lt;/strong&gt;: &lt;code&gt;defaultQueryOptions&lt;/code&gt; (re-defaulting the options of every query, of every hook, on every render), &lt;code&gt;trackResult&lt;/code&gt;’s property-tracking wrappers, &lt;code&gt;createResult&lt;/code&gt;, query-key hashing, and a garbage collector spending 13 to 24 percent of the entire trace sweeping up after all of it.&lt;/p&gt;
&lt;p&gt;The mechanic that makes this multiply is the one TkDodo documents for &lt;code&gt;select&lt;/code&gt;: it &lt;a href=&quot;https://tkdodo.eu/blog/react-query-selectors-supercharged&quot;&gt;runs once per &lt;code&gt;QueryObserver&lt;/code&gt;&lt;/a&gt;, and so does all the machinery around it. Nothing about an observer’s work is deduplicated across components: thirty components calling the same hook means thirty observers per underlying query, each paying the full bookkeeping bill. With a wallet of dozens of addresses, hooks like the one above multiplied into thousands of observers. While optimizing, &lt;a href=&quot;https://github.com/alephium/alephium-frontend/pull/1037&quot;&gt;we counted a single hook being called 1,398 times&lt;/a&gt; during one wallet unlock. Stabilizing the selector with &lt;code&gt;useCallback&lt;/code&gt;, the usual first advice, does nothing here, because the cost is per-&lt;em&gt;observer&lt;/em&gt;, not per-render. The fix had to do two things: run the expensive composition once, and drastically cut the number of observers.&lt;/p&gt;
&lt;h2&gt;Fix 1: compute once, cache the result&lt;/h2&gt;
&lt;p&gt;The first move was to push the composition into a &lt;code&gt;queryFn&lt;/code&gt;, and let TanStack cache the computed result under its own key. A query’s &lt;code&gt;queryFn&lt;/code&gt; can pull its dependencies straight from the cache with &lt;code&gt;queryClient.fetchQuery&lt;/code&gt; (which deduplicates and shares the cache with any &lt;code&gt;useQuery&lt;/code&gt; on the same key). This is exactly the &lt;a href=&quot;https://github.com/TanStack/query/discussions/2178&quot;&gt;pattern Dominik Dorfmeister endorses&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“you can use &lt;code&gt;queryClient.fetchQuery&lt;/code&gt; inside a &lt;code&gt;queryFn&lt;/code&gt; of another query.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is what the &lt;code&gt;level:N&lt;/code&gt; keys are about: they are a derived-data dependency graph. Level 1 composes the two level-0 balance queries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const addressBalancesQuery = ({ addressHash, networkId, isNodeOnline, skip }) =&amp;gt;
  queryOptions({
    queryKey: [&apos;address&apos;, addressHash, &apos;level:1&apos;, &apos;balances-all&apos;, { networkId }],
    ...getQueryConfig({ staleTime: Infinity, gcTime: Infinity, networkId }),
    queryFn: shouldSkip(isNodeOnline, skip)
      ? skipToken
      : async () =&amp;gt; {
          // Compose from the cached level-0 queries (fetched once, shared everywhere).
          const { balances: alph } = await queryClient.fetchQuery(addressAlphBalancesQuery({ addressHash, networkId, isNodeOnline, skip }))
          const { balances: tokens } = await queryClient.fetchQuery(addressTokensBalancesQuery({ addressHash, networkId, isNodeOnline, skip }))

          return {
            addressHash,
            balances: alph.totalBalance !== &apos;0&apos; ? [{ id: ALPH.id, ...alph }, ...tokens] : tokens
          }
        }
  })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/queries/addressQueries.ts&quot;&gt;&lt;code&gt;addressQueries.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The consumer is now trivial, &lt;code&gt;useQuery(addressBalancesQuery(...))&lt;/code&gt;, with &lt;strong&gt;zero computation in the component.&lt;/strong&gt; The expensive composition happens once, is stored once, and is structurally shared once. The same idea powers &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/queries/tokenQueries.ts&quot;&gt;&lt;code&gt;tokenQuery&lt;/code&gt;&lt;/a&gt;, which resolves any token ID by chaining &lt;code&gt;fetchQuery&lt;/code&gt; calls: check the token list, then the token type, then the appropriate metadata, caching the resolved token under its own key.&lt;/p&gt;
&lt;h2&gt;Fix 2: hoist the fan-out into a context&lt;/h2&gt;
&lt;p&gt;A “fan-out” is one logical need that expands into many parallel requests: “show the wallet’s tokens” becomes one query per address. &lt;code&gt;fetchQuery&lt;/code&gt;-in-&lt;code&gt;queryFn&lt;/code&gt; solves cross-query composition, but a hook that does &lt;code&gt;useQueries&lt;/code&gt; across all 50 addresses still creates 50 observers per component that calls it. So I hoist those wallet-wide fan-outs into a React Context that runs the &lt;code&gt;useQueries&lt;/code&gt; once for the whole tree. A small factory builds these:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;export const createDataContext = ({ useDataHook, combineFn, defaultValue }) =&amp;gt; {
  const DataContext = createContext({ data: defaultValue, isLoading: false, isFetching: false, error: false })

  const DataContextProvider = ({ children }) =&amp;gt; {
    // The expensive useQueries fan-out + combine runs ONCE here, not in every consumer.
    const { data, isLoading, isFetching, error } = useDataHook(combineFn)
    const value = useMemo(() =&amp;gt; ({ data, isLoading, isFetching, error }), [data, isLoading, isFetching, error])
    return &amp;lt;DataContext.Provider value={value}&amp;gt;{children}&amp;lt;/DataContext.Provider&amp;gt;
  }

  return { useData: () =&amp;gt; useContext(DataContext), DataContextProvider }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/context/createDataContext.tsx&quot;&gt;&lt;code&gt;createDataContext.tsx&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This is React Context used the way &lt;a href=&quot;https://tkdodo.eu/blog/react-query-and-react-context&quot;&gt;TkDodo recommends&lt;/a&gt;, as dependency injection, not as a second state store. The single source of truth is still the query cache; the context just hoists the subscription and the &lt;code&gt;combine&lt;/code&gt; to one place so consumers read a ready-made value.&lt;/p&gt;
&lt;p&gt;The payoff was measured twice. During the optimization itself, &lt;a href=&quot;https://github.com/alephium/alephium-frontend/pull/1037&quot;&gt;we counted hook invocations&lt;/a&gt; on a single wallet unlock: &lt;code&gt;useFetchWalletBalancesAlphByAddress&lt;/code&gt; went from &lt;strong&gt;1,398 calls to 3&lt;/strong&gt;, and the refactor cut &lt;code&gt;combine&lt;/code&gt; executions by 99.7% overall. And profiling today’s build through the same four actions as the trace above: total blocking time fell from &lt;strong&gt;207.9 seconds to 1.9&lt;/strong&gt;, a 107x reduction, and the click back to the Overview went from 93 seconds of freeze to about a quarter of a second.&lt;/p&gt;
&lt;h2&gt;Why two tools, not one&lt;/h2&gt;
&lt;p&gt;The split is deliberate, and it maps onto where each tool can reach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single-source derivation&lt;/strong&gt; (slice one query) uses &lt;code&gt;select&lt;/code&gt;. It is the lighter tool, it recomputes reactively from live data, and crucially it carries &lt;strong&gt;no manual-invalidation burden.&lt;/strong&gt; I use it for things like “this one token’s balance out of the address’s balance list.”&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-source composition&lt;/strong&gt; (combine multiple queries) uses &lt;code&gt;fetchQuery&lt;/code&gt;-in-&lt;code&gt;queryFn&lt;/code&gt;, because &lt;code&gt;select&lt;/code&gt; only ever sees one query’s data. The price is that the derived entry caches a snapshot and does not auto-recompute when its sources change, so I own its invalidation. Concretely, when the &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt; gate detects a change it does not just refetch the raw balances; it invalidates the derived queries in dependency order, level 0 before the level 1+ entries that read it, so each one recomputes on fresh inputs. That ordering is exactly what the &lt;code&gt;level:N&lt;/code&gt; key names encode.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;An alternative I weighed: aggregates as cached queries&lt;/h2&gt;
&lt;p&gt;I could have gone further and discarded React Context entirely, turning each wallet-wide aggregate (the total balance, the tokens-by-type list) into its own &lt;code&gt;fetchQuery&lt;/code&gt;-in-&lt;code&gt;queryFn&lt;/code&gt; query keyed on the address set. It is worth saying why I mostly did not.&lt;/p&gt;
&lt;p&gt;What that buys you: one mechanism instead of two, and, more interestingly, the aggregate becomes a cached entry that persists to disk. On cold start the wallet total would be there instantly, with no recompute, which is exactly the philosophy of &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;Part 5&lt;/a&gt;. Context-derived values are recomputed on every mount.&lt;/p&gt;
&lt;p&gt;What it costs you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reactivity.&lt;/strong&gt; &lt;code&gt;useQueries&lt;/code&gt; + &lt;code&gt;combine&lt;/code&gt; re-runs automatically when any underlying address query changes. A cached aggregate is a snapshot you must fold into the invalidation cascade by hand, more of the coherence-ownership tax from &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial loading state.&lt;/strong&gt; &lt;code&gt;combine&lt;/code&gt; can report “some addresses are still loading” and let the UI fill in incrementally as each one resolves. A single aggregate query is all-or-nothing: nothing renders until the whole &lt;code&gt;queryFn&lt;/code&gt; finishes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dynamic address sets.&lt;/strong&gt; With &lt;code&gt;useQueries&lt;/code&gt; the address list is just the queries array. As a query, the set goes into the key, so adding an address spawns a fresh entry and leaves the old one lingering until garbage collection.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The sharp version of the trade: move the all-or-nothing aggregates that benefit from persistence (the wallet total) to cached queries, and keep Context only where consumers need partial, incremental loading across a changing address set. I kept Context for the dashboard aggregates precisely because the per-section loading states matter there.&lt;/p&gt;
&lt;h2&gt;A direction I want to explore: jotai&lt;/h2&gt;
&lt;p&gt;Full disclosure: I did not weigh &lt;a href=&quot;https://jotai.org/docs/extensions/query&quot;&gt;jotai-tanstack-query&lt;/a&gt; against this design, for the unglamorous reason that I did not know it existed at the time. I want to look into it, because on paper it addresses things I solved by hand. Jotai derived atoms compute once, are shared across all subscribers, recompute automatically when their sources change, and re-render only the components reading them. That would, in principle, collapse my hand-written level cascade: derived atoms would recompute themselves when a source changes, instead of me invalidating six levels in order.&lt;/p&gt;
&lt;p&gt;Two things make me cautious for these particular apps. The wallets already run Redux Toolkit, so adding Jotai means a third state paradigm and a “which state lives where” tax. And jotai’s derive-on-read recomputes derived data lazily, which is the opposite of what the cold-start path needs (the subject of &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;Part 5&lt;/a&gt;). So it sits firmly on my “investigate later” list rather than being a regret. If you have shipped jotai-tanstack-query at this kind of scale, I would genuinely like to compare notes.&lt;/p&gt;
&lt;h2&gt;Trade-offs and what I would improve&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;I own invalidation for every derived query.&lt;/strong&gt; Forget to invalidate a level and it serves a stale snapshot forever. The &lt;code&gt;level:N&lt;/code&gt; convention makes this systematic, but it is a convention, not a guarantee, a refactor away from a subtle bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context still fans out re-renders.&lt;/strong&gt; Any single address’s balance change produces a new memoized context value and re-renders every consumer. I measured this and it is fine today, but at whale-wallet scale &lt;code&gt;use-context-selector&lt;/code&gt; or finer-grained contexts would localize it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I predicted the invalidation cascade would be the next hotspot, and measurement proved me wrong.&lt;/strong&gt; For a while the largest remaining TanStack cost in my profiles was &lt;code&gt;partialMatchKey&lt;/code&gt;/&lt;code&gt;matchQuery&lt;/code&gt; walking the cache, and I assumed the &lt;code&gt;level:N&lt;/code&gt; cascade was to blame, since &lt;code&gt;invalidateQueries&lt;/code&gt; is O(cache size) and the cascade calls it once per level, per address. Attributed properly (by caller chain, not raw self time), the cascade was ~20ms. The real cost, 3.2 seconds on a cold start, was a single &lt;code&gt;useIsFetching({ predicate })&lt;/code&gt; in the always-mounted header refresh button: TanStack re-runs that predicate against &lt;strong&gt;every cache entry on every cache event&lt;/strong&gt;, and mine did an O(addresses) &lt;code&gt;includes&lt;/code&gt; on top. Swapping it for a small incremental cache subscription (O(1) per event) took the scans to zero, and collapsed the &lt;code&gt;notifyManager&lt;/code&gt; timer churn with it, since each of those events had also been scheduling a &lt;code&gt;setTimeout(0)&lt;/code&gt; flush. The lesson:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;useIsFetching&lt;/code&gt; with a predicate is a full-cache scan per cache event and does not belong in an always-mounted component.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Learnings&lt;/h2&gt;
&lt;h3&gt;Own your coherence when you leave the library’s guarantees&lt;/h3&gt;
&lt;p&gt;The single most important thing I internalized is that the moment you step outside &lt;code&gt;staleTime&lt;/code&gt; + &lt;code&gt;invalidateQueries&lt;/code&gt;, the moment you build a change detector, or cache a derived snapshot, &lt;strong&gt;you have taken ownership of cache coherence from the library.&lt;/strong&gt; Every one of this architecture’s real bugs (the locked-balance staleness from &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt;) lives exactly at the seam where my hand-rolled invariant leaks. That is not an argument against doing it; the request savings are enormous and real. It is an argument for knowing, precisely, which invariant you are now responsible for upholding, and auditing where it can break.&lt;/p&gt;
&lt;h3&gt;Compute once, cache many, invalidate precisely&lt;/h3&gt;
&lt;p&gt;The through-line of the whole design is the same shape repeated at three scales: do not recompute what you can cache (this part’s derived queries), do not refetch what cannot have changed (&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3&lt;/a&gt;’s gate), do not re-derive on a cold path what you can persist already computed (&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;Part 5&lt;/a&gt;’s connect modal). TanStack’s cache is the natural home for all three, as long as you respect that &lt;code&gt;select&lt;/code&gt; and &lt;code&gt;combine&lt;/code&gt; are per-observer and that a cached derivation is a snapshot, not a subscription.&lt;/p&gt;
&lt;h3&gt;My constants are calibrated by vibes&lt;/h3&gt;
&lt;p&gt;If there is one honest weakness that cuts across the whole series, it is this: every numeric boundary in the system, the 60-second and 5-minute poll tiers, the 30-day active/dormant split, the 10-req/s throttle, the retry count, was chosen by feel and validated by the absence of complaints. The architecture is sound; its calibration is intuition. The cheapest, highest-leverage improvement I could make is to replace those magic numbers with measured, server-header-driven values so the system tunes itself instead of relying on my guesses.&lt;/p&gt;
&lt;h2&gt;What comes next&lt;/h2&gt;
&lt;p&gt;Even with requests gated and derivations cached, one path stays slow: the cold start, when there is nothing in the cache yet and a big wallet has to fetch everything before it can paint a single balance. &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;Part 5 is about persisting the cache to disk&lt;/a&gt; so the app restores last session’s data instantly, and the batch, throttle, and retry layers that keep the whole system under the rate limit.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><category>Performance</category><category>Architecture</category><category>Blockchain</category></item><item><title>Cut Wallet API Calls with Change Detection in TanStack Query (Part 3)</title><link>https://www.nop33.com/blog/tanstack-query-change-detection-polling/</link><guid isPermaLink="true">https://www.nop33.com/blog/tanstack-query-change-detection-polling/</guid><description>Gate expensive wallet queries behind a cheap &quot;latest transaction&quot; poll so balances refetch only when an address actually changes.</description><pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;nav&gt; &lt;p&gt; A TanStack Query Case Study (5 Part Series) &lt;/p&gt; &lt;ol&gt; &lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;  1  Why Async State Broke Our Wallets &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;  2  Migrating Redux to TanStack Query &lt;/a&gt; &lt;/li&gt;&lt;li&gt;   3  Change Detection for Fewer Requests  &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;  4  Derived State Without Melting the CPU &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;  5  Persisting Cache for Cold Starts &lt;/a&gt; &lt;/li&gt; &lt;/ol&gt; &lt;/nav&gt; 
&lt;p&gt;&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;Part 1&lt;/a&gt; laid out the three problems that made the old architecture untenable: a request flow that was hard to reason about, a self-inflicted request flood, and painfully slow data updates. &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;Part 2&lt;/a&gt; moved the whole async layer off Redux thunks and into the TanStack Query cache, which untangled that flow. This part addresses the root cause of the request flood. The trick is to stop polling the expensive endpoints altogether and gate every one of them behind a single cheap query per address.&lt;/p&gt;
&lt;aside&gt;  ⚡ TL;DR  &lt;div&gt;&lt;ul&gt;
&lt;li&gt;A wallet with N addresses fans out into hundreds of requests just to render: measured on the last pre-TanStack release, my 42-address wallet fired 1,709 in one 43-second session. Polling all of them on a short &lt;code&gt;staleTime&lt;/code&gt; would keep hammering the backend.&lt;/li&gt;
&lt;li&gt;I gate every expensive query behind one cheap “latest transaction” poll per address. If the latest-transaction hash has not changed, balances cannot have changed, so nothing downstream refetches.&lt;/li&gt;
&lt;li&gt;A new transaction hash invalidates that address’s downstream queries. Every balance and token query uses &lt;code&gt;staleTime: Infinity&lt;/code&gt;, so freshness is event-driven, not time-driven.&lt;/li&gt;
&lt;li&gt;This is hand-built change detection and not the &lt;a href=&quot;https://tanstack.com/query/v5/docs/framework/react/guides/dependent-queries&quot;&gt;docs’ dependent-query pattern&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The end state, measured: relaunching that same wallet today costs &lt;strong&gt;seven backend requests&lt;/strong&gt; and zero &lt;code&gt;429&lt;/code&gt;s.&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt; &lt;/aside&gt; 
&lt;p&gt;As we covered in &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;Part 1&lt;/a&gt;, the fan-out was brutal: measured on the last pre-TanStack release, my 42-address wallet fired 1,709 requests in a single 43-second session, six endpoints per address plus retries, and 61% of the 1,595 that reached our backend were rejected as &lt;code&gt;429&lt;/code&gt;. The naive approach is to put a short &lt;code&gt;staleTime&lt;/code&gt; on every query and let TanStack refetch whenever a component mounts. That, however, would not bring us any closer to the goal of reducing the number of requests.&lt;/p&gt;
&lt;h2&gt;Change detection&lt;/h2&gt;
&lt;p&gt;The key insight that unlocks everything is that &lt;strong&gt;an address’s balances cannot change unless a new transaction touches that address.&lt;/strong&gt; So instead of polling the expensive endpoints, I poll one cheap thing, the latest transaction hash for each address, and treat it as a gate. If the hash has not changed, nothing downstream can have changed, so I do not fetch any of it. It essentially asks:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Has this address sent or received any transactions since the last time I checked?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The gate is a single, small query per address. Its &lt;code&gt;queryFn&lt;/code&gt; fetches the latest transaction, compares the new hash against the previously cached one, and, only on a change, imperatively invalidates everything that depends on it:&lt;/p&gt;
&lt;section&gt; &lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const addressLatestTransactionQuery = ({ addressHash, networkId, isExplorerOnline, skip }) =&amp;gt;
  queryOptions({
    queryKey: [&apos;address&apos;, addressHash, &apos;transaction&apos;, &apos;latest&apos;, { networkId }],
    ...getQueryConfig({ staleTime: ONE_MINUTE_MS, gcTime: FIVE_MINUTES_MS, networkId }),
    queryFn: shouldSkip(isExplorerOnline, skip)
      ? skipToken
      : async ({ queryKey }) =&amp;gt; {
          const latestTx = await throttledClient.explorer.addresses.getAddressesAddressLatestTransaction(addressHash)
          const cached = queryClient.getQueryData(queryKey)

          if (latestTx !== undefined &amp;amp;&amp;amp; latestTx.hash !== cached?.latestTx?.hash) {
            await invalidateAddressQueries(addressHash)
            await invalidateWalletQueries()
            await invalidateTokenPrices()
          }

          return {
            addressHash,
            latestTx: latestTx ? {
              hash: latestTx.hash,
              timestamp: latestTx.timestamp,
            } : undefined,
          }
        }
  })&lt;/code&gt;&lt;/pre&gt; &lt;/section&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/queries/transactionQueries.ts&quot;&gt;&lt;code&gt;transactionQueries.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The gate is polled per address by a single hook mounted at the root of the authenticated app, with &lt;code&gt;notifyOnChangeProps: []&lt;/code&gt; so the polling instances never trigger a re-render. They exist purely to keep the cache warm and run that side effect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;useQueries({
  queries: frequentlyUsedAddressHashes.map((addressHash) =&amp;gt; ({
    ...addressLatestTransactionQuery({ addressHash, networkId, isExplorerOnline }),
    refetchInterval: FREQUENT_ADDRESSES_TRANSACTIONS_REFRESH_INTERVAL, // 60s
    notifyOnChangeProps: []
  }))
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/features/dataPolling/useAddressesDataPolling.ts&quot;&gt;&lt;code&gt;useAddressesDataPolling.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;Side note: A polling strategy per address&lt;/h3&gt;
&lt;p&gt;Polling every address on one fixed interval is wasteful: most addresses in a wallet are dormant, and a dormant address does not need to be checked every minute. So rather than a single hard-coded interval, the polling rate is chosen per address by a small &lt;strong&gt;strategy pattern&lt;/strong&gt;: each address is assigned a polling strategy based on how recently it transacted. I considered an address that transacted in the last 30 days as “frequent” and polls every 60 seconds. A dormant one polls every 5 minutes. This is about to go away, however, with the introduction of the &lt;a href=&quot;https://github.com/alephium/explorer-backend/issues/669&quot;&gt;explorer API’s new “latest activity for many addresses” endpoint&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Freshness without staleTime&lt;/h2&gt;
&lt;p&gt;The consequence of all this is that every balance, token, and NFT query is configured with &lt;code&gt;staleTime: Infinity&lt;/code&gt; and &lt;code&gt;gcTime: Infinity&lt;/code&gt;. They are fetched once and then never refetch on their own. The only thing that makes them refetch is the gate detecting a new transaction. Freshness is event-driven, not time-driven.&lt;/p&gt;
&lt;p&gt;Here is what that buys, measured on today’s build with the same 42-address wallet: relaunching the app with a warm cache (how the cache survives restarts is &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;Part 5&lt;/a&gt;’s subject) costs &lt;strong&gt;seven backend requests&lt;/strong&gt;: four latest-transaction gate polls, because only four of the 42 addresses are in the frequent tier, plus three price refreshes. Zero &lt;code&gt;429&lt;/code&gt;s. The same wallet on the pre-TanStack build fired 1,595.&lt;/p&gt;
&lt;h2&gt;Where this diverges from the docs (and why that matters)&lt;/h2&gt;
&lt;p&gt;It is easy to wave at the TanStack &lt;a href=&quot;https://tanstack.com/query/v5/docs/framework/react/guides/request-waterfalls&quot;&gt;“request waterfalls”&lt;/a&gt; guide and claim this is just dependent queries. It is not.&lt;/p&gt;
&lt;p&gt;The docs’ dependent-query pattern gates on &lt;strong&gt;data availability&lt;/strong&gt;, do not fetch a user’s projects until you know the user’s ID, and they are blunt that even this “by definition constitutes a form of request waterfall, which hurts performance.” My gate is different: it gates on &lt;strong&gt;change detection&lt;/strong&gt;, do not refetch B unless A changed. TanStack Query has no native concept for “refetch B only if A changed”; the idiomatic tools are &lt;code&gt;staleTime&lt;/code&gt; and &lt;code&gt;invalidateQueries&lt;/code&gt;, and I have hand-built a change detector on top of them.&lt;/p&gt;
&lt;p&gt;That is a perfectly legitimate thing to do, but it means &lt;strong&gt;cache coherence is now my responsibility, not the library’s.&lt;/strong&gt; The whole model rests on one invariant, balances change if and only if the latest transaction hash changes, and the interesting bugs all live where that invariant leaks.&lt;/p&gt;
&lt;h2&gt;Trade-offs and what I would improve&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The gate is O(addresses) with no batching.&lt;/strong&gt; A 50-address hot wallet emits roughly 50 latest-transaction polls per minute, forever, even when nothing changes. The single highest-leverage change would be a batched endpoint that collapses those N polls into one request. Which is something &lt;a href=&quot;https://github.com/alephium/explorer-backend/issues/669&quot;&gt;I am working on together with my colleague&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The locked-balance coherence hole.&lt;/strong&gt; Alephium supports time-locked outputs. When a lockup expires, the balance moves from locked to available at a block height, with no new transaction. The gate keys on transaction hashes, so it never fires, and the locked/available split silently goes stale. The clean fix is event-driven (&lt;a href=&quot;https://github.com/alephium/alephium-frontend/issues/1672&quot;&gt;#1672&lt;/a&gt;): when a balance carries a lock, read the UTXO’s &lt;code&gt;lockTime&lt;/code&gt; and schedule a one-shot invalidation at exactly that moment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The side effect lives inside the &lt;code&gt;queryFn&lt;/code&gt;.&lt;/strong&gt; Reading the previous value via &lt;code&gt;getQueryData&lt;/code&gt; to diff against the new one is neat. It gets the old hash at exactly the right moment without a side map. But it couples the gate’s loading state to the entire downstream cascade. A &lt;code&gt;queryCache.subscribe&lt;/code&gt; observer would decouple detection from fetching, at the cost of tracking the previous hash myself.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What comes next&lt;/h2&gt;
&lt;p&gt;With the flood metered down to one cheap poll per address, the next bottleneck stopped being the network and started being the CPU, a bottleneck the migration itself had introduced. &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt; is about composing all this cached data into the shapes components actually render, the wallet’s worth, the tokens split by type, without re-deriving it in every component and melting the main thread.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><category>Architecture</category><category>Performance</category><category>Blockchain</category></item><item><title>You Can&apos;t Wipe a Secret in JavaScript, but You Should Try</title><link>https://www.nop33.com/blog/clearing-secrets-from-memory/</link><guid isPermaLink="true">https://www.nop33.com/blog/clearing-secrets-from-memory/</guid><description>How I found the Alephium wallet&apos;s 24-word recovery phrase sitting in plain text in RAM on every unlock, and the defense-in-depth work it took to wipe secrets from memory across an Electron and a React Native wallet.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A self-custody wallet has exactly one job it cannot get wrong: keep the user’s secrets secret. For the &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-desktop-wallet&quot;&gt;Alephium desktop wallet&lt;/a&gt; (Electron) and the &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-mobile-wallet&quot;&gt;mobile wallet&lt;/a&gt; (React Native), those secrets come in three forms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;mnemonic&lt;/strong&gt;, the 24-word BIP39 recovery phrase the whole wallet derives from,&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;seed / HD root key&lt;/strong&gt; computed from the mnemonic, and&lt;/li&gt;
&lt;li&gt;the per-address &lt;strong&gt;private keys&lt;/strong&gt; derived from that root.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Encrypting them &lt;em&gt;at rest&lt;/em&gt; is the easy part, and the part everyone remembers to do. The harder, quieter problem is what happens while the wallet is &lt;em&gt;unlocked&lt;/em&gt;, when those secrets necessarily exist in plain text somewhere in the process’s memory. This is the story of discovering just how exposed that memory was, and the work it took to clear it up.&lt;/p&gt;
&lt;p&gt;A spoiler on the honesty front, because it matters: in a JavaScript runtime you cannot &lt;em&gt;guarantee&lt;/em&gt; a secret is wiped from memory. What you can do is shrink its lifetime, change its form, and reduce its blast radius. Everything below is one of those three. It’s defense in depth, not a magic wand, and I’ll be precise about the difference at the end.&lt;/p&gt;
&lt;h2&gt;The threat model&lt;/h2&gt;
&lt;p&gt;It helps to name what we’re defending against, because it changes which work is worth doing.&lt;/p&gt;
&lt;p&gt;The realistic adversary here is &lt;strong&gt;another process reading this process’s memory&lt;/strong&gt;: a memory-scraping infostealer, someone poking around with a tool like &lt;a href=&quot;https://systeminformer.sourceforge.io/&quot;&gt;System Informer (formerly Process Hacker)&lt;/a&gt;, a crash dump that ends up in a bug report, secrets paged out to a swap file, or a secret accidentally shipped off-device inside a crash report or analytics event.&lt;/p&gt;
&lt;p&gt;What this work does &lt;em&gt;not&lt;/em&gt; defend against is an attacker already running code as the user. If they own the machine, they can keylog the password and read the seed at the moment you legitimately use it. No amount of buffer-wiping fixes a compromised host.&lt;/p&gt;
&lt;h2&gt;Why JavaScript makes this genuinely hard&lt;/h2&gt;
&lt;p&gt;Three properties of the runtime fight you the whole way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Strings are immutable.&lt;/strong&gt; You cannot overwrite a &lt;code&gt;string&lt;/code&gt;’s contents. The engine may keep the old bytes around until garbage collection, and may have copied them when the string was interned or concatenated. The only real fix is to &lt;em&gt;never put a secret in a &lt;code&gt;string&lt;/code&gt;&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Garbage collection is non-deterministic.&lt;/strong&gt; Even a buffer you zero out may have been copied by the runtime, and the original copy lingers until the GC decides to reclaim it. Wiping is therefore &lt;em&gt;best-effort&lt;/em&gt;, and has to happen as early as possible to keep the window short.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Step 1: get the secrets out of Redux&lt;/h2&gt;
&lt;p&gt;The first move was structural: introduce a single &lt;code&gt;Keyring&lt;/code&gt; (a module modeled closely on &lt;a href=&quot;https://github.com/MetaMask/eth-hd-keyring&quot;&gt;MetaMask’s &lt;code&gt;eth-hd-keyring&lt;/code&gt;&lt;/a&gt;) that owns every secret in memory and exposes &lt;em&gt;operations&lt;/em&gt;, not &lt;em&gt;material&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The public surface is things like &lt;code&gt;signTransaction&lt;/code&gt;, &lt;code&gt;signMessageHash&lt;/code&gt;, and &lt;code&gt;exportPublicKeyOfAddress&lt;/code&gt;. Private keys are derived lazily and cached inside the keyring; the mnemonic is used to initialize and then dropped. Nothing secret ever touches Redux, and nothing secret is ever serialized.&lt;/p&gt;
&lt;p&gt;The cleanup lifecycle is explicit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;public clear = () =&amp;gt; {
  this.addresses.forEach((address) =&amp;gt; {
    if (address.privateKey) {
      resetArray(address.privateKey) // zero the bytes in place
      address.privateKey = null
    }
  })

  this.hdWallet = null
  this.addresses = []
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;code&gt;resetArray&lt;/code&gt; is exactly as unglamorous as it should be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;export const resetArray = (array: Uint8Array) =&amp;gt; {
  for (let i = 0; i &amp;lt; array.length; i++) {
    array[i] = 0
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2: stop storing the mnemonic as a string&lt;/h2&gt;
&lt;p&gt;Notice that &lt;code&gt;resetArray&lt;/code&gt; takes a &lt;code&gt;Uint8Array&lt;/code&gt;, not a &lt;code&gt;string&lt;/code&gt;. That’s the whole point. Because strings can’t be wiped, the mnemonic had to stop being one.&lt;/p&gt;
&lt;p&gt;Instead of persisting &lt;code&gt;&quot;absurd swing tornado ...&quot;&lt;/code&gt;, the wallet stores the mnemonic as a compact &lt;code&gt;Uint8Array&lt;/code&gt; of the &lt;strong&gt;indices&lt;/strong&gt; of each word in the BIP39 word list. It’s smaller, and (most importantly) it’s &lt;em&gt;mutable&lt;/em&gt;, so it can be zeroed in place the moment it’s no longer needed.&lt;/p&gt;
&lt;p&gt;The format is versioned at rest (&lt;code&gt;version: 1&lt;/code&gt; = the legacy word-string, &lt;code&gt;version: 2&lt;/code&gt; = the &lt;code&gt;Uint8Array&lt;/code&gt;), so older wallets keep working through a migration. And the one place a string mnemonic is still legitimately needed is behind a function whose name is the warning label:&lt;/p&gt;
&lt;section&gt; &lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// It will convert the mnemonic from Uint8Array to string, leaking it to the
// memory. Use only when absolutely needed, ie: displaying the mnemonic for backup.
export const dangerouslyConvertUint8ArrayMnemonicToString = (mnemonic: Uint8Array) =&amp;gt; /* ... */&lt;/code&gt;&lt;/pre&gt; &lt;/section&gt;
&lt;h2&gt;Step 3: the part where I found the mnemonic in RAM anyway&lt;/h2&gt;
&lt;p&gt;After all of the above, I did the thing you should always do after claiming a security improvement: I tried to break it. On a Windows VM I unlocked the wallet, attached Process Hacker to the process, and searched its memory for a couple of words from my recovery phrase.&lt;/p&gt;
&lt;p&gt;There it was. The &lt;strong&gt;complete 24-word mnemonic, in plain text&lt;/strong&gt;, freshly present in memory every single time the wallet unlocked.&lt;/p&gt;

&lt;p&gt;My keyring wasn’t the culprit. The leak was &lt;em&gt;below&lt;/em&gt; my code, inside the BIP39 library doing the seed derivation. Deep in &lt;code&gt;mnemonicToSeed&lt;/code&gt;, the library builds an intermediate &lt;code&gt;Uint8Array&lt;/code&gt; (the actual input to PBKDF2) and, having derived the seed, simply returns and lets that array fall out of scope. “Out of scope” is not “erased.” Until the GC got around to it, the decoded mnemonic sat there in full.&lt;/p&gt;
&lt;p&gt;You can’t fix a dependency’s internals from your own code, but you &lt;em&gt;can&lt;/em&gt; vendor a patch. Using &lt;code&gt;pnpm patch&lt;/code&gt;, I wrapped the derivation so the intermediate buffer is zeroed the instant the seed exists:&lt;/p&gt;
&lt;section&gt; &lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function mnemonicToSeedSync(mnemonic, wordlist, passphrase = &apos;&apos;) {
  const encodedMnemonicUint8Array = encodeMnemonicForSeedDerivation(mnemonic, wordlist)
  const seed = pbkdf2(sha512, encodedMnemonicUint8Array, salt(passphrase), { c: 2048, dkLen: 64 })

  resetUint8Array(encodedMnemonicUint8Array) // ← the fix

  return seed
}&lt;/code&gt;&lt;/pre&gt; &lt;/section&gt;
&lt;p&gt;After the patch, the same Process Hacker search came up empty. The string form of the mnemonic no longer survived an unlock.&lt;/p&gt;
&lt;p&gt;This find is also what pushed my to use MetaMask’s hardened crypto stack, swapping &lt;code&gt;bip39&lt;/code&gt;/&lt;code&gt;bip32&lt;/code&gt; for &lt;a href=&quot;https://www.npmjs.com/package/@metamask/scure-bip39&quot;&gt;&lt;code&gt;@metamask/scure-bip39&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://www.npmjs.com/package/@scure/bip32&quot;&gt;&lt;code&gt;@scure/bip32&lt;/code&gt;&lt;/a&gt;, on the principle that the most-attacked wallet in the ecosystem has already paid for a lot of this scrutiny.&lt;/p&gt;
&lt;h2&gt;Step 4: wipe on every path, especially the failing ones&lt;/h2&gt;
&lt;p&gt;The happy path is easy to wipe. The path that throws halfway through derivation is the one that quietly leaves secrets lying around, and it’s the one attackers are happy to trigger on purpose.&lt;/p&gt;
&lt;p&gt;So every keyring init clears first, then blanks its inputs the moment it’s done with them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;public initFromEncryptedMnemonic = async (encryptedMnemonic, password, passphrase) =&amp;gt; {
  const { version, decryptedMnemonic } = await decryptMnemonic(encryptedMnemonic, password)

  this.clear()
  this._initFromMnemonic(decryptedMnemonic, passphrase)

  encryptedMnemonic = &apos;&apos;
  password = &apos;&apos;
  passphrase = &apos;&apos;
  resetArray(decryptedMnemonic)

  return version
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and a follow-up pass made sure the keyring’s secrets are cleared &lt;strong&gt;on errors as well&lt;/strong&gt;, not just on success.&lt;/p&gt;
&lt;p&gt;One detail in that snippet is worth calling out, because it’s the honest exception to the “never put a secret in a &lt;code&gt;string&lt;/code&gt;” rule. The &lt;code&gt;password&lt;/code&gt; and the optional BIP39 &lt;code&gt;passphrase&lt;/code&gt; arrive from a UI text field, and a text field only ever hands you a &lt;code&gt;string&lt;/code&gt;. You can’t choose &lt;code&gt;Uint8Array&lt;/code&gt; for input the platform delivers as text, and you can’t zero a string once you hold it. So the best available move is the one you see here: drop the reference the instant you’re done (&lt;code&gt;password = &apos;&apos;&lt;/code&gt;, &lt;code&gt;passphrase = &apos;&apos;&lt;/code&gt;) so the original becomes garbage-collectable as fast as possible. It’s weaker than wiping a buffer in place, and it’s worth being upfront that these two user-entered values are exactly where the rule bends.&lt;/p&gt;
&lt;h2&gt;Step 5: don’t forget the exits&lt;/h2&gt;
&lt;p&gt;A secret sitting in RAM is one risk. A secret &lt;strong&gt;leaving the device&lt;/strong&gt; inside telemetry is a worse one, because now it’s on someone else’s server. A stack trace can carry a variable; an analytics event can carry a property; either can carry a key.&lt;/p&gt;
&lt;p&gt;So the last piece was making sure none of this leaves the device by accident: sanitizing exceptions before they’re reported, stripping potentially sensitive fields from analytics events, and scrubbing error payloads so that the act of &lt;em&gt;diagnosing&lt;/em&gt; a problem can never become the act of &lt;em&gt;leaking&lt;/em&gt; a secret.&lt;/p&gt;
&lt;h2&gt;What this actually buys you (and what it doesn’t)&lt;/h2&gt;
&lt;p&gt;Let me keep the promise I made at the top and be exact:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The seed and the active private keys &lt;strong&gt;must&lt;/strong&gt; exist in plain text in memory while the wallet is unlocked. You cannot sign a transaction without them. This work shrinks how long and in how many forms they exist. It does not, and cannot, make them not exist.&lt;/li&gt;
&lt;li&gt;Wiping is &lt;strong&gt;best-effort&lt;/strong&gt;. A zeroed buffer may have been copied by the runtime; GC timing is not under your control.&lt;/li&gt;
&lt;li&gt;The library patch removed the &lt;strong&gt;string&lt;/strong&gt; leak of the mnemonic at unlock. The derived &lt;strong&gt;seed&lt;/strong&gt; still lives in the keyring until you &lt;code&gt;clear()&lt;/code&gt; it, which is exactly why step 4 exists.&lt;/li&gt;
&lt;li&gt;None of this saves a machine that’s already running attacker code as the user.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What you get for the effort is a real, measurable reduction in exposure: secrets out of persisted storage, out of long-lived strings, out of crash reports, wiped on every code path, and cleared from the keyring on lock. The bar an attacker has to clear goes from “read the process memory once” to “own the machine at the exact moment of use.” That’s the whole game with this class of defense: not invincibility, but moving the cost.&lt;/p&gt;
&lt;h2&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;If you handle secrets in a JS/TS app, the checklist that fell out of this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Never put a secret in a &lt;code&gt;string&lt;/code&gt; when you control its representation.&lt;/strong&gt; Use &lt;code&gt;Uint8Array&lt;/code&gt; so you can zero it. Credentials typed into a UI field are the exception you can’t dodge: drop the reference as early as possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep secrets out of global stores and anything that persists&lt;/strong&gt; (Redux, &lt;code&gt;redux-persist&lt;/code&gt;, async storage).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wipe early, and wipe on the error path too.&lt;/strong&gt; Derivation that throws is where secrets leak.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify with a memory inspector.&lt;/strong&gt; Process Hacker / System Informer takes ten minutes and will humble you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audit your dependencies’ internals.&lt;/strong&gt; The leak was below my code, in a well-regarded library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don’t leak through the exits.&lt;/strong&gt; Scrub crash reports and analytics before they leave the device.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of this is novel cryptography. It’s disciplined plumbing, and for a wallet, the plumbing &lt;em&gt;is&lt;/em&gt; the product.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/clearing-secrets-from-memory/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>Security</category><category>Cryptography</category><category>Electron</category><category>React Native</category><category>Blockchain</category><enclosure url="https://www.nop33.com/_astro/cover.CzN1UxuX_ZWchqL.webp" length="0" type="image/webp"/></item><item><title>Migrating Redux Server State to TanStack Query (Part 2)</title><link>https://www.nop33.com/blog/redux-to-tanstack-query-migration/</link><guid isPermaLink="true">https://www.nop33.com/blog/redux-to-tanstack-query-migration/</guid><description>How I moved server state from Redux Toolkit into TanStack Query in two production wallets, one query at a time, without a big-bang rewrite.</description><pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;nav&gt; &lt;p&gt; A TanStack Query Case Study (5 Part Series) &lt;/p&gt; &lt;ol&gt; &lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;  1  Why Async State Broke Our Wallets &lt;/a&gt; &lt;/li&gt;&lt;li&gt;   2  Migrating Redux to TanStack Query  &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;  3  Change Detection for Fewer Requests &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;  4  Derived State Without Melting the CPU &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;  5  Persisting Cache for Cold Starts &lt;/a&gt; &lt;/li&gt; &lt;/ol&gt; &lt;/nav&gt; 
&lt;p&gt;&lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;Part 1&lt;/a&gt; of the series focused on explaining the problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;tangled request flow&lt;/li&gt;
&lt;li&gt;self-inflicted DDoS&lt;/li&gt;
&lt;li&gt;slow app launch and data updates.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We were using Redux as an async-state manager, a job it was never designed for. This part is the migration to a tool that &lt;em&gt;was&lt;/em&gt; designed for that job: TanStack Query. The migration was not an one-shotted rewrite, but a months-long effort, with incremental shipments.&lt;/p&gt;
&lt;aside&gt;  ⚡ TL;DR  &lt;div&gt;&lt;ul&gt;
&lt;li&gt;The old architecture stored &lt;strong&gt;server state&lt;/strong&gt; (balances, tokens, prices, transactions) in Redux and hand-managed loading, error, and refetching for every slice. That is why the request flow in Part 1 was so hard to follow.&lt;/li&gt;
&lt;li&gt;The mental shift was to realize that &lt;strong&gt;server state is not client state.&lt;/strong&gt; TanStack Query owns the server cache and Redux keeps what the user and the device own (theme, open modals, settings, wallet metadata).&lt;/li&gt;
&lt;li&gt;Redux’s own &lt;strong&gt;RTK Query&lt;/strong&gt; got a real trial first. It shipped to production, then lost a same-branch, head-to-head migration attempt to TanStack Query.&lt;/li&gt;
&lt;li&gt;I migrated &lt;strong&gt;incrementally&lt;/strong&gt;, one domain at a time, with TanStack Query running next to Redux for the better part of a year.&lt;/li&gt;
&lt;li&gt;Components stopped selecting from a global store and started asking for exactly what they need through thin &lt;code&gt;useFetch*&lt;/code&gt; hooks. One hesitation almost stopped me: those hooks re-derive computed data &lt;em&gt;per observer&lt;/em&gt;, not once. That is the cliffhanger into &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt; &lt;/aside&gt; 
&lt;h2&gt;Redux was doing a job it was never built for&lt;/h2&gt;
&lt;p&gt;Redux is an excellent client-state manager. But we were using it as a network cache. Every resource the wallet read from the backend, ALPH balances, token balances, token metadata, prices, transactions, had a slice, a handful of async thunks, a status flag or two, a set of selectors, and a &lt;code&gt;useEffect&lt;/code&gt; somewhere that decided when to fire the thunk.&lt;/p&gt;
&lt;p&gt;Here is what that looked like for address data. A thunk fanned out into more thunks and toggled a “syncing” flag so it could not re-enter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// before: server state modeled as Redux actions. One thunk orchestrates three more,
// and a &quot;syncing&quot; flag guards against overlapping runs.
export const syncAddressesData = createAsyncThunk(
  &apos;addresses/syncAddressesData&apos;,
  async (payload, { getState, dispatch }) =&amp;gt; {
    dispatch(syncingAddressDataStarted())
    const addresses = payload ?? (getState() as RootState).addresses.ids

    await dispatch(syncAddressesBalances(addresses))
    await dispatch(syncAddressesTokens(addresses))
    return await dispatch(syncAddressesTransactions(addresses)).unwrap()
  }
)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The trigger was pulled by a &lt;code&gt;useEffect&lt;/code&gt; in &lt;code&gt;App.tsx&lt;/code&gt;, reading a pile of selectors, nested a few conditionals deep, deciding on behalf of the whole app when it was time to sync:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// before: App.tsx is the fetch orchestrator. A pile of selectors feeds one nested effect.
const addressHashes = useAppSelector(selectAddressIds)
const addressesStatus = useAppSelector((s) =&amp;gt; s.addresses.status)
const isSyncingAddressData = useAppSelector((s) =&amp;gt; s.addresses.syncingAddressData)
const isLoadingTokensMetadata = useAppSelector((s) =&amp;gt; s.assetsInfo.loading)
// ...a dozen more selectors...

useEffect(() =&amp;gt; {
  if (network.status !== &apos;online&apos;) return

  if (assetsInfo.status === &apos;uninitialized&apos; &amp;amp;&amp;amp; !isLoadingTokensMetadata) {
    dispatch(syncNetworkTokensInfo())
  }
  if (addressesStatus === &apos;uninitialized&apos; &amp;amp;&amp;amp; !isSyncingAddressData &amp;amp;&amp;amp; addressHashes.length &amp;gt; 0) {
    dispatch(syncAddressesData())
  }
}, [addressHashes.length, addressesStatus, isSyncingAddressData, assetsInfo.status, isLoadingTokensMetadata])&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The component that renders an address balance is nowhere near this code.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The decision to fetch lives in &lt;code&gt;App.tsx&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The fetch lives in a thunk.&lt;/li&gt;
&lt;li&gt;The result lands in a slice.&lt;/li&gt;
&lt;li&gt;The component reads it through a selector.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To trace one balance from screen to network you hop through four files and an effect dependency array. This is the “hard to reason about” flow from &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state&quot;&gt;Part 1&lt;/a&gt;. That’s what you get when you model server state as client state. You end up re-implementing caching, deduplication, background refetching, and loading and error tracking by hand, on top of a library that does none of it for you.&lt;/p&gt;
&lt;h2&gt;Server state is not client state&lt;/h2&gt;
&lt;p&gt;The idea that unlocked the whole migration is one I first saw articulated in &lt;a href=&quot;https://tkdodo.eu/blog/react-query-as-a-state-manager&quot;&gt;TkDodo’s “React Query as a State Manager”&lt;/a&gt;: most apps do not have one kind of state, they have two, and those two want very different things.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Client state&lt;/strong&gt; is synchronous, you own it, and it is the single source of truth. Which modal is open, the current theme, the user’s settings. Redux is superb at this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server state&lt;/strong&gt; is asynchronous, you do &lt;em&gt;not&lt;/em&gt; own it, and you only ever hold a snapshot of a truth that lives somewhere else. The balances, token metadata, prices, transactions, etc, live on the Alephium explorer backend and node and can change at any time.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;TanStack Query offers caching, request deduplication, background refetching, stale tracking, loading and error states, retries, all of it out of the box. So the migration was really one decision applied everywhere:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;if the source of truth is the &lt;strong&gt;blockchain&lt;/strong&gt;, it belongs in the &lt;strong&gt;query cache&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;if the source of truth is the &lt;strong&gt;user or the device&lt;/strong&gt;, it stays in &lt;strong&gt;Redux&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;“Move everything to React Query” is not the right approach. To make the above lines more concrete:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Moved to TanStack Query (server state)&lt;/th&gt;&lt;th&gt;Stayed in Redux (client state)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;ALPH and token balances&lt;/td&gt;&lt;td&gt;Which modals are open&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Token metadata and token type&lt;/td&gt;&lt;td&gt;Settings: theme, language, region&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Token prices&lt;/td&gt;&lt;td&gt;Network settings&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Transactions&lt;/td&gt;&lt;td&gt;Address labels and contacts (user-authored metadata)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;The detour: we tried RTK Query first&lt;/h2&gt;
&lt;p&gt;Accepting that server state needs its own tool does not automatically point at TanStack Query. We were already deep in the Redux ecosystem, so the obvious first candidate was &lt;a href=&quot;https://redux-toolkit.js.org/rtk-query/overview&quot;&gt;RTK Query&lt;/a&gt;, Redux Toolkit’s own answer to server state.&lt;/p&gt;
&lt;p&gt;We did not just evaluate it on paper: the desktop wallet had been fetching the ALPH price through RTK Query since late 2022, &lt;a href=&quot;https://github.com/alephium/alephium-frontend/pull/493&quot;&gt;NFT collection data moved to it&lt;/a&gt; in April 2024, both shipped to production, and we were &lt;a href=&quot;https://github.com/alephium/alephium-frontend/issues/497&quot;&gt;planning to adopt its retry machinery&lt;/a&gt; next.&lt;/p&gt;
&lt;p&gt;The full migration attempt is where it fell apart. We tried to move the wallet’s whole fan-out onto RTK Query’s endpoint-centric &lt;code&gt;createApi&lt;/code&gt; model and ran straight into its gap: there is no &lt;code&gt;useQueries&lt;/code&gt;, no first-class way to fire a &lt;em&gt;dynamic list&lt;/em&gt; of queries, which is the one thing a wallet with N addresses does all day. We caught ourselves hand-rolling a &lt;code&gt;useLoopedQueries&lt;/code&gt; hook, rebuilding the exact machinery we were trying to stop building. We pivoted to TanStack Query. Our explorer app had been running TanStack Query in production &lt;a href=&quot;https://github.com/alephium/explorer/pull/210&quot;&gt;since mid-2023&lt;/a&gt;, &lt;code&gt;useQueries&lt;/code&gt; fit the fan-out natively, and the cache persistence we had already tried there became a pillar of the final architecture. We didn’t reject RTK Query in the abstract. We tried it, shipped it, but it was out-competed on our hardest use case.&lt;/p&gt;
&lt;h2&gt;The after: the component asks for what it needs&lt;/h2&gt;
&lt;p&gt;On the TanStack side, a resource is one &lt;code&gt;queryOptions&lt;/code&gt; object: its key, its fetcher, and its config, together, with no slice, no thunk, and no status flag to maintain.&lt;/p&gt;
&lt;section&gt; &lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// after: the entire definition of a resource, key + fetch + config in one object.
export const addressBalancesQuery = ({ addressHash, networkId }) =&amp;gt;
  queryOptions({
    queryKey: [&apos;address&apos;, addressHash, &apos;balance&apos;, { networkId }],
    queryFn: () =&amp;gt; throttledClient.explorer.addresses.getAddressesAddressBalance(addressHash)
  })&lt;/code&gt;&lt;/pre&gt; &lt;/section&gt;
&lt;p&gt;The real queries carry more than this, cache lifetimes, skip conditions, and the derived-data keys that &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt; leans on, but the shape is always this: one object per resource. Components never touch it directly. They call a thin hook, and the hook hands back exactly the async state Redux made us track by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// after: no selector, no dispatch, no effect. Ask for the token, get back { data, isLoading }.
export const useFetchToken = (id: TokenId) =&amp;gt; {
  const networkId = useNetworkId()

  const { data, isLoading } = useQuery(tokenQuery({ id, networkId }))

  return { data, isLoading }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared-react/src/api/apiDataHooks/token/useFetchToken.ts&quot;&gt;&lt;code&gt;useFetchToken.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Two things changed at once here:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The trigger moved &lt;em&gt;down&lt;/em&gt; the tree. Instead of &lt;code&gt;App.tsx&lt;/code&gt; deciding when to fetch on behalf of the whole app, each component asks for its own data where it renders it, and TanStack deduplicates so a hundred components asking for the same token still make one request. This is &lt;a href=&quot;https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster&quot;&gt;state colocation&lt;/a&gt; applied to server state: keep the request next to the thing that needs it, and lift it up only when you must.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;App.tsx&lt;/code&gt; was massively simplified. The fetch-orchestration hub, the dozen selectors and the nested effect, are now gone leaving &lt;code&gt;App.tsx&lt;/code&gt; with simple lifecycle wiring.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;Migrating without stopping the world&lt;/h2&gt;
&lt;p&gt;TanStack Query and Redux ran side by side for the better part of a year. The migration was tracked as &lt;a href=&quot;https://github.com/alephium/alephium-frontend/issues/712&quot;&gt;one epic&lt;/a&gt; with three explicit goals, quoted here because they are a good summary of &lt;em&gt;why&lt;/em&gt; you would take this on at all:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Reduce load on explorer backend (less requests).
Improve devX by having to manage less async operations through side-effects.
Improve app performance.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The path across was deliberately gradual:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start at the leaves.&lt;/strong&gt; The first queries were isolated reads with no Redux entanglement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Convert domain by domain.&lt;/strong&gt; Slice by slice, the Redux state moved to Tanstack Query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do the hardest domain last.&lt;/strong&gt; Transactions, with infinite pagination and pending mempool state, are the hardest thing to model as a cache, so they went last. I opened the &lt;a href=&quot;https://github.com/alephium/alephium-frontend/pull/861&quot;&gt;pull request that finished them&lt;/a&gt; with the line:&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;“This PR is the final boss of the migration from Redux data fetching to TanStack Query.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extract, then reuse.&lt;/strong&gt; Once the desktop patterns were proven, they moved into a shared package so the React Native wallet could adopt the same query factories and hooks, which is how the mobile migration wrapped up in mid-2025.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;The hesitation that almost kept me on Redux&lt;/h2&gt;
&lt;p&gt;Truth be told, I was not a big fan of migrating to Tanstack Query in the beginning. After the initial investigation through the docs and some prototyping, I worried that it will be hard to combine data from multiple sources into a single data model.&lt;/p&gt;
&lt;p&gt;With everything in Redux, derived state was computed &lt;strong&gt;once&lt;/strong&gt;, at the request flow. As raw data arrived from the API, the Redux thunks calculated the derived values and wrote them into the store for all components to consume. That calculation happened once, and the result was cached.&lt;/p&gt;
&lt;p&gt;The TanStack model inverts that. There is no store to write the derived value into, so each component’s hook re-derives it from the cached raw API responses, and TanStack’s &lt;code&gt;select&lt;/code&gt; and &lt;code&gt;combine&lt;/code&gt; run &lt;strong&gt;once per observer&lt;/strong&gt;, not once per app. A dashboard where thirty components need the same computed shape is thirty recomputations on every refresh. For a while this looked like a genuine reason not to migrate. I would be trading a tangled-but-compute-efficient store for a clean-but-wasteful one.&lt;/p&gt;
&lt;p&gt;The solution to this comes in &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;Part 4&lt;/a&gt;, where I push composition into cached queries and hoist the expensive fan-outs so the derivation runs once again, this time without giving up the clean model.&lt;/p&gt;
&lt;h2&gt;What comes next&lt;/h2&gt;
&lt;p&gt;The async layer now lives in a &lt;strong&gt;cache&lt;/strong&gt; instead of a &lt;strong&gt;store&lt;/strong&gt;. That untangles the flow from Part 1, but it does nothing yet for the flood. In fact, a naive cache, as the defaults of TanStack Query, will cheerfully refetch all the time. &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;Part 3 is where the flood finally gets solved&lt;/a&gt;, by gating every expensive query behind a single cheap “latest transaction” poll per address, so balances refetch only when a transaction actually touches them.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><category>Redux</category><category>Architecture</category><category>State Management</category></item><item><title>Why Async State Broke Our Crypto Wallets (TanStack Query Case Study, Part 1)</title><link>https://www.nop33.com/blog/tanstack-query-wallet-async-state/</link><guid isPermaLink="true">https://www.nop33.com/blog/tanstack-query-wallet-async-state/</guid><description>Case study from two Alephium wallets: tangled Redux thunks, a self-inflicted API DDoS, and slow updates that pushed us to TanStack Query.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;nav&gt; &lt;p&gt; A TanStack Query Case Study (5 Part Series) &lt;/p&gt; &lt;ol&gt; &lt;li&gt;   1  Why Async State Broke Our Wallets  &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;  2  Migrating Redux to TanStack Query &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-change-detection-polling&quot;&gt;  3  Change Detection for Fewer Requests &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-derived-state-performance&quot;&gt;  4  Derived State Without Melting the CPU &lt;/a&gt; &lt;/li&gt;&lt;li&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-persist-cache-cold-start&quot;&gt;  5  Persisting Cache for Cold Starts &lt;/a&gt; &lt;/li&gt; &lt;/ol&gt; &lt;/nav&gt; 
&lt;p&gt;A pretty common need on the client side of web and mobile applications is managing &lt;strong&gt;asynchronous state&lt;/strong&gt;, data that lives on the server. The Alephium mobile and desktop wallets are no exception. This series is a case study of using &lt;a href=&quot;https://tanstack.com/query/latest&quot;&gt;TanStack Query&lt;/a&gt; to manage async state in real production apps. In it, I describe the problems I faced building them, and the solutions I architected and implemented to address them.&lt;/p&gt;
&lt;p&gt;This first article sets the scene. Before any solution makes sense, I want you to feel the three problems that made the old architecture untenable. The solutions come in the articles that follow. I would recommend not skipping ahead so that it all makes sense!&lt;/p&gt;
&lt;h2&gt;The apps&lt;/h2&gt;
&lt;p&gt;Here is a quick overview of what these apps are and what they run on.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-desktop-wallet&quot;&gt;Alephium desktop wallet&lt;/a&gt; is an Electron app for macOS, Windows, and Linux. It lets the user create and securely store cryptocurrency wallets. A wallet, to its core, is simply a list of 24 words called a “mnemonic”. Multiple addresses (think IBAN) can be derived from this mnemonic. The user can send funds to and from these addresses. The user can create as many addresses as they want. Funds can be ALPH (the native token), tokens (USDT, wETH, and so on), and NFTs. The &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-mobile-wallet&quot;&gt;Alephium mobile wallet&lt;/a&gt; is a React Native app for Android and iOS, in feature parity with the desktop wallet.&lt;/p&gt;
&lt;p&gt;Both trigger a massive &lt;strong&gt;fan-out architecture&lt;/strong&gt; from the frontend’s perspective: one wallet holds many addresses, and each address needs its balances, its tokens, its NFTs, and their metadata, each from a different API endpoint. The apps must manage, trigger, and resolve all these concurrent, sometimes independent and sometimes interdependent requests.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;On launch the wallet fires a burst of requests for its balances, tokens, and prices. While they&apos;re in flight the UI
    shows a skeleton loader; as the responses land, the real data (the wallet&apos;s worth) is calculated and replaces it.&lt;/em&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state/&quot;&gt;View the animation on nop33.com&lt;/a&gt;&lt;/p&gt;   
&lt;p&gt;The rest of this post is about what happens when that simple picture meets a real wallet with dozens of addresses and hundreds of tokens.&lt;/p&gt;
&lt;h2&gt;Problem 1: the request flow is hard to reason about&lt;/h2&gt;
&lt;p&gt;As I explain on my &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-desktop-wallet&quot;&gt;portfolio page for the desktop wallet&lt;/a&gt;, the very first implementation kept both app state and async state in a plain &lt;code&gt;React.Context&lt;/code&gt;, with network requests fired by &lt;code&gt;fetch&lt;/code&gt; inside &lt;code&gt;useEffect&lt;/code&gt; hooks. That was the state of things when I joined the team in 2021. Soon after I joined, and once we started hitting some performance bottlenecks, I proposed migrating to more sophisticated state management with Redux Toolkit. This was the first major project that I owned. Network requests moved into async thunks, triggered from &lt;code&gt;useEffect&lt;/code&gt; hooks, and the resulting (computed) async state lived in Redux. This made a lot of sense at the time, considering that the blockchain did not yet support smart contracts. There was no need for something more complex. A few years down the line, however, the Alephium blockchain was supercharged with smart contract support, which led to a Cambrian explosion of tokens and NFTs.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;On wallet unlock: localStorage seeds the addresses, a &lt;code&gt;useEffect&lt;/code&gt; dispatches the thunk, the thunk talks to the server, calculates the computed state and writes the result into the Redux slice, and that state update re-renders the App (which re-runs the effect).&lt;/em&gt; &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state/&quot;&gt;View the animation on nop33.com&lt;/a&gt;&lt;/p&gt;   
&lt;p&gt;The migration to Redux bought us an efficient rendering strategy: when a slice of state changed, only the components consuming that exact slice re-rendered. The cost was that as the project evolved the logic &lt;em&gt;triggering&lt;/em&gt; those network requests became genuinely hard to follow.&lt;/p&gt;
&lt;p&gt;Here is the happy path on app unlock:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The app initializes Redux with the list of addresses found in &lt;code&gt;localStorage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That list sits in the dependency array of a &lt;code&gt;useEffect&lt;/code&gt; in &lt;code&gt;App.tsx&lt;/code&gt;, which calls the &lt;code&gt;fetchAddressesBalances&lt;/code&gt; thunk.&lt;/li&gt;
&lt;li&gt;The thunk makes its network requests, writes the balances into Redux, and flips &lt;code&gt;isBalancesInitialized&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Another&lt;/em&gt; &lt;code&gt;useEffect&lt;/code&gt; has that flag in its dependency array. When it becomes &lt;code&gt;true&lt;/code&gt;, it fires &lt;code&gt;fetchTokensDetails&lt;/code&gt; to fetch metadata (name, logo, symbol) for the token IDs discovered in the previous step.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So a single unlock moves back and forth between Redux state, &lt;code&gt;useEffect&lt;/code&gt; dependency arrays, and async thunks. To trace one user action the developer has to hold the whole zig-zag in their head.&lt;/p&gt;
&lt;h2&gt;Problem 2: too many requests&lt;/h2&gt;
&lt;p&gt;The desktop wallet’s user base grew, and the server grew overloaded with requests. The wallets were essentially DDoS-ing our own backend. The backend team had to add rate-limiting, which fixed the server side but created a new problem on the client side: the server now frequently responded with &lt;code&gt;429 (&quot;Too Many Requests&quot;)&lt;/code&gt; and the apps displayed inaccurate data.&lt;/p&gt;
&lt;p&gt;The patch was obvious: throttle the number of requests the clients send, and add a retry policy for &lt;code&gt;429&lt;/code&gt; responses. A combination of &lt;a href=&quot;https://www.npmjs.com/package/p-throttle&quot;&gt;&lt;code&gt;p-throttle&lt;/code&gt;&lt;/a&gt; and &lt;a href=&quot;https://www.npmjs.com/package/fetch-retry&quot;&gt;&lt;code&gt;fetch-retry&lt;/code&gt;&lt;/a&gt; replaced the bare &lt;code&gt;fetch&lt;/code&gt; calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import pThrottle from &apos;p-throttle&apos;
import fetchRetry from &apos;fetch-retry&apos;

const throttle = pThrottle({
  limit: 10,
  interval: 1000
})

const throttledFetch = throttle((url, options = {}) =&amp;gt; {
  fetch(url, options)
})

const MAX_API_RETRIES = 3

const exponentialBackoffFetchRetry = fetchRetry(throttledFetch, {
  retryOn: (_, __, response: Response | null) =&amp;gt; {
    return !!response &amp;amp;&amp;amp; response.status === 429
  },
  retryDelay: (attempt: number) =&amp;gt; {
    return Math.pow(2, attempt) * 1000
  },
  retries: MAX_API_RETRIES
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This saved the day. The server could keep up, and the client &lt;em&gt;slowly&lt;/em&gt; received all the data it needed, eventually showing up-to-date information. But, as you may already suspect, this created a &lt;em&gt;new&lt;/em&gt; problem.&lt;/p&gt;
&lt;h2&gt;Problem 3: painfully slow data updates on app launch&lt;/h2&gt;
&lt;p&gt;Throttling solved the symptom but not the cause. The client still fired &lt;em&gt;way&lt;/em&gt; too many requests on app launch. It just fired them out slowly. This meant that the user would stare at skeleton loaders for a considerable amount of time each time they launched their app until they were able to see their balances.&lt;/p&gt;
&lt;p&gt;Here’s a breakdown of a profiling session of a power user’s wallet of 42 addresses, 203 tokens, and 360 NFTs. The actions performed were to unlock the wallet, wait for it to settle, click to the Addresses page, click back to the Overview. It revealed some interesting insights:&lt;/p&gt;
&lt;section&gt; &lt;p&gt;&lt;img src=&quot;https://www.nop33.com/_astro/alephium-wallet-profiler-pre-tanstack.CrxyCg_o_jhs2B.webp&quot; alt=&quot;A DevTools Performance recording of the pre-TanStack wallet: the network track is a dense wall of requests for over 40 seconds and the console badge shows 1,561 errors, while the CPU track stays mostly quiet&quot; width=&quot;3680&quot; height=&quot;2382&quot; /&gt;&lt;/p&gt; &lt;/section&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;1,709 requests fired in 43 seconds.&lt;/strong&gt; The real fan-out was six endpoints per address.&lt;/li&gt;
&lt;li&gt;700 of them left in the first ten seconds. The server rejected &lt;strong&gt;981 with &lt;code&gt;429&lt;/code&gt;&lt;/strong&gt;, and the exponential-backoff retries stretched the data trickle past the 40-second mark.&lt;/li&gt;
&lt;li&gt;With no request deduplication, the same price-chart URL was fetched 17 times within the recording.&lt;/li&gt;
&lt;li&gt;The CPU, meanwhile, was mostly idle (total blocking time: 2.4 seconds). The app was not slow because it was working hard. It was slow because it was waiting.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What’s worse is that every async state update even after the initial launch would also fire the same number of requests, taking the same amount of time.&lt;/p&gt;
&lt;p&gt;We had to attack the real problem: &lt;strong&gt;fire dramatically fewer requests in the first place.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Network requests batching&lt;/h2&gt;
&lt;p&gt;A low-hanging fruit was to batch the network requests for token metadata. Token metadata is the worst fan-out: resolving 200 tokens used to be 200 requests. The idea to reach for &lt;a href=&quot;https://github.com/yornaath/batshit&quot;&gt;&lt;code&gt;@yornaath/batshit&lt;/code&gt;&lt;/a&gt; came from my colleague &lt;a href=&quot;https://x.com/mikalph&quot;&gt;Mika&lt;/a&gt;, and it turned out to be a very effective lever against the &lt;code&gt;429&lt;/code&gt;s. It collects individual lookups inside a 10ms window and fires one batched POST of up to 80:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import { create, windowedFiniteBatchScheduler } from &apos;@yornaath/batshit&apos;

// Up to 80 individual token-metadata lookups made within a 10ms window collapse into one POST.
const createFTMetadataBatcher = () =&amp;gt;
  create({
    fetcher: throttledClient.explorer.tokens.postTokensFungibleMetadata,
    resolver: (results, queryTokenId) =&amp;gt; results.find(({ id }) =&amp;gt; id === queryTokenId),
    scheduler: windowedFiniteBatchScheduler({ maxBatchSize: 80, windowMs: 10 })
  })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Source: &lt;a href=&quot;https://github.com/alephium/alephium-frontend/blob/4d85289ce20d1b2eea5a3960ec2800750cefe7ca/packages/shared/src/api/queryBatchers.ts&quot;&gt;&lt;code&gt;queryBatchers.ts&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The biggest gains in reducing the number of requests, however, comes next.&lt;/p&gt;
&lt;h2&gt;What comes next&lt;/h2&gt;
&lt;p&gt;I worked closely with the backend team, and together we landed on a multi-layer solution that addressed &lt;strong&gt;all three problems&lt;/strong&gt; at once: the tangled flow, the request flood, and the slow updates. The foundation of that solution is TanStack Query.&lt;/p&gt;
&lt;p&gt;In the next parts of this series I’ll walk through that architecture: how I migrated the whole async layer off Redux and onto TanStack Query, how I gate expensive queries behind a single cheap poll so balances only refetch when something actually changes, how I compose derived state without melting the CPU, how I persist the cache for instant cold starts, and the layered defense that keeps all of it under the rate limit. Each layer is a direct answer to one of the three problems above.&lt;/p&gt;
&lt;p&gt;Onward to &lt;a href=&quot;https://www.nop33.com/blog/redux-to-tanstack-query-migration&quot;&gt;Part 2, where I migrate the async layer off Redux and onto TanStack Query&lt;/a&gt;.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/tanstack-query-wallet-async-state/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TanStack Query</category><category>React</category><category>Redux</category><category>Architecture</category><category>Blockchain</category></item><item><title>Speeding Up Cold Start of a React Native app</title><link>https://www.nop33.com/blog/improving-react-native-cold-start/</link><guid isPermaLink="true">https://www.nop33.com/blog/improving-react-native-cold-start/</guid><description>A Metro and lifecycle post-mortem of optimizing the startup time of the Alephium mobile wallet.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When building a &lt;a href=&quot;https://www.nop33.com/portfolio/alephium-mobile-wallet&quot;&gt;production-hardened crypto wallet in React Native&lt;/a&gt;, your dependency tree can quickly become an absolute galaxy of cryptographic primitives, polyfills, Web3 clients, and heavy wallet connection frameworks. Don’t take my word for it, have a look at several good open-source React Native wallets such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/MetaMask/metamask-mobile&quot;&gt;Metamask&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/rainbow-me/rainbow&quot;&gt;Rainbow Wallet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/LedgerHQ/ledger-live/&quot;&gt;Ledger Live&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Recently, I noticed that the Alephium mobile wallet hit a wall: cold-boot startup times crept up to a grueling 7+ seconds. Some users reported waiting up to 15s. I did not manage to reproduce such long times on my devices. It would still take 2s on my Pixel 8 Pro and 4s on my old OnePlus 6T. Nevertheless, I could see through profiling that the native UI thread sat frozen on the splash screen while the single-threaded JavaScript engine spent its precious initialization cycles parsing, evaluating, and spinning up heavy background modules.&lt;/p&gt;
&lt;p&gt;By restructuring the compilation pipeline, decoupling native build profiles, and leveraging deferred runtime lifecycles, I managed to drop visual launch times down to a clean 2 seconds with fluid native transitions.&lt;/p&gt;
&lt;h2&gt;1. The Gatekeeper: Global &lt;code&gt;inlineRequires&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;On the web, code-splitting (through &lt;code&gt;React.lazy&lt;/code&gt;) physically chunks the code to save network bandwidth. On mobile, 100% of the JS bundle is already compiled and sitting locally on the device’s flash storage. The bottleneck isn’t network download size but CPU execution time instead.&lt;/p&gt;
&lt;p&gt;In all Expo projects by default, standard top-level static imports (&lt;code&gt;import X from &apos;Y&apos;&lt;/code&gt;) force the Hermes engine to synchronously execute those modules the exact millisecond the app boots. The breakthrough came from flipping a single global transformer switch in &lt;code&gt;metro.config.js&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;config.transformer.getTransformOptions = async () =&amp;gt; ({
  transform: {
    inlineRequires: true
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;What this actually does&lt;/h3&gt;
&lt;p&gt;Metro automatically traverses the source files during bundling and rewrites static imports into lazy getters behind the scenes. The heavy screens and services remain completely un-evaluated in memory until the exact microsecond the app explicitly mounts or calls them.&lt;/p&gt;
&lt;h3&gt;Profiling in Android Studio&lt;/h3&gt;
&lt;p&gt;To shed some light into the problem, I opened the &lt;code&gt;/android&lt;/code&gt; folder in Android Studio to use the profiling tools that the React Native ecosystem is lacking. In the system trace below it can be seen that the massive, pitch-black void on the Native UI thread (&lt;code&gt;alephium.wallet&lt;/code&gt;) is doing absolutely nothing. The native side of the app is completely functional, but it is idling in an empty loop, stubbornly displaying the splash screen because it has received zero instructions from the JavaScript engine on what layout to draw.&lt;/p&gt;
&lt;p&gt;In contrast, at the bottom is the JS thread (&lt;code&gt;mqt_v_js&lt;/code&gt;). That solid, unbroken turquoise bar running continuously past the 5-second mark is the Hermes JavaScript Engine. It is running a single, massive, synchronous CPU task: parsing the entire codebase and executing all top-level module code. The UI thread cannot drop the splash screen until this turquoise block finishes its initial pass and sends the layout instructions over the bridge.&lt;/p&gt;
&lt;section&gt; &lt;p&gt;&lt;img src=&quot;https://www.nop33.com/_astro/profiling-before.zSWwjYRT_Z2nAta2.webp&quot; alt=&quot;Profiling before optimizations&quot; width=&quot;3680&quot; height=&quot;2382&quot; /&gt;&lt;/p&gt; &lt;/section&gt;
&lt;p&gt;The solution was to let the Metro bundler lazily evaluate the dependencies automatically. By enabling inline requires, Metro automatically rewrites the top-level static imports (&lt;code&gt;import { Web3Provider } from &apos;walletconnect&apos;&lt;/code&gt;) into localized requests (&lt;code&gt;require(&apos;walletconnect&apos;)&lt;/code&gt;) behind the scenes. This means that heavy depedencies such as WalletConnect are completely ignored during the initial startup script (bringing the boot time down) and they are loaded incrememtally only when an explicit function call executes them later on.&lt;/p&gt;
&lt;p&gt;The visual profile has completely shifted:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Native UI thread (&lt;code&gt;alephium.wallet&lt;/code&gt;) awakens: Instead of a long black void of idling, you see a dense cluster of vibrant, multi-colored execution blocks firing early in the timeline (around the 1-second mark). These blocks represent native Android view layout measurements, &lt;code&gt;MainActivity&lt;/code&gt; lifecycle passes, and paint calls.&lt;/li&gt;
&lt;li&gt;The JavaScript thread (&lt;code&gt;mqt_v_js&lt;/code&gt;): While the turquoise bar still runs to process code, its initial execution payload is split. It processes the root entry logic rapidly, unblocks the bridge early, and allows the native system to start drawing UI elements and managing transitions while other deferred modules are processed in the background.&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt; &lt;p&gt;&lt;img src=&quot;https://www.nop33.com/_astro/profiling-after.C8LX55b9_65Jkp.webp&quot; alt=&quot;Profiling after optimizations&quot; width=&quot;3680&quot; height=&quot;2382&quot; /&gt;&lt;/p&gt; &lt;/section&gt;
&lt;h3&gt;Why does changing the Metro bundler configuration suddenly wake up the native Android thread?&lt;/h3&gt;
&lt;p&gt;Without &lt;code&gt;inlineRequires&lt;/code&gt;, the bundle acts like a giant synchronous waterfall. The app cannot display the first screen until every single file, even code for deep sub-navigation screens the user has’t opened yet, is fully evaluated by the CPU. The native UI thread is held hostage by the JavaScript thread.&lt;/p&gt;
&lt;p&gt;When we flip &lt;code&gt;inlineRequires: true&lt;/code&gt;, Metro rewrites our top-level static imports into lazy function wrappers (&lt;code&gt;require()&lt;/code&gt;). This completely changes the low-level thread execution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The initial JS payload shrinks: Only the absolute bare-minimum framework code is executed on boot.&lt;/li&gt;
&lt;li&gt;The bridge fires early: The JS thread finishes its initial pass in under a second and immediately sends the root view structure to the native side.&lt;/li&gt;
&lt;li&gt;True parallel execution: As seen in the optimized trace, the native UI thread goes to work immediately drawing the initial views and preparing transitions, while the Hermes engine processes deferred modules and heavy crypto packages in parallel when they are explicitly called.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Step-by-step anatomy of a RN cold start&lt;/h3&gt;
&lt;p&gt;Let’s look under the hood on what’s happening when we write and build our project and when we launch the app.&lt;/p&gt;
&lt;h4&gt;Phase 1: Compile&lt;/h4&gt;
&lt;p&gt;When we write code, we use startard ES6 static imports at the top of the files for readability and type safety:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;// Inside SendScreen.tsx
import WalletConnectClient from &apos;~/services/walletConnectService&apos;;

export const SendScreen = () =&amp;gt; {
  // Uses WalletConnectClient...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When we build the project, the Metro bundler traverses the project files. Because &lt;code&gt;inlineRequires&lt;/code&gt; is set to &lt;code&gt;true&lt;/code&gt;, Metro modifies the source code as it compresses it into the final &lt;code&gt;index.android.bundle&lt;/code&gt; file. It strips away the top-level import and wraps it in a lazy JS getter property (hence the “inline” naming):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// What Metro outputs
Object.defineProperty(exports, &quot;WalletConnectClient&quot;, {
  get: function() {
    return require(&apos;~/services/walletConnectService&apos;); 
  }
});

const SendScreen = () =&amp;gt; {
  // Every time you call &apos;WalletConnectClient&apos;, it triggers the getter function above
};&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Phase 2: App launch (cold start)&lt;/h4&gt;
&lt;p&gt;When the user taps the app icon on their phone, a strict sequence of events plays out across the native OS and the JS engine:&lt;/p&gt;
&lt;h5&gt;Step 1: Native process forking (0.0s)&lt;/h5&gt;
&lt;p&gt;The Android OS forks a native Linux process for the app. The Android Runtime (ART) initializes the app’s container, reads the &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, creates the main native thread, and calls &lt;code&gt;bindApplication&lt;/code&gt;.&lt;/p&gt;
&lt;h5&gt;Step 2: Native splash screen mounts (~0.2s)&lt;/h5&gt;
&lt;p&gt;The native UI thread (&lt;code&gt;alephium.wallet&lt;/code&gt;) loads the theme configurations and instantly draws the native splash screen layout. The phone is now frozen displaying this layout, waiting for instructions on what to replace it with.&lt;/p&gt;
&lt;h5&gt;Step 3: The JS thread spawns (~0.4s)&lt;/h5&gt;
&lt;p&gt;The native side boots up the Hermes JS engine on a dedicated background thread (&lt;code&gt;mqt_v_js&lt;/code&gt;). Hermes allocates its memory heap and reads the production bundle file (&lt;code&gt;index.android.bundle&lt;/code&gt;) from the phone’s internal storage into its RAM.&lt;/p&gt;
&lt;h5&gt;Step 4: Grobal script evaluation&lt;/h5&gt;
&lt;p&gt;This is where the big performance difference happens. Hermes must execute the entire global scope of the bundle to discover the registered application components.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Without inline requires (the old way)&lt;/strong&gt;: Hermes runs the entry file. It sees a top-level static import for &lt;code&gt;SendScreen&lt;/code&gt;. To resolve it, it must immediately jump to &lt;code&gt;SendScreen.tsx&lt;/code&gt;, parse it, and execute it. Inside &lt;code&gt;SendScreen&lt;/code&gt;, it hits the static import for WalletConnect. It stops, jumps to WalletConnect, parses its dependencies, instantiates the cryptographic polyfills, and runs heavy initialization setups. This reates a big domino effect. Thousands of files are synchronously read, parsed, and executed in one continuous, blocking CPU loop. The JS thread pins at 100% (the long turquoise bar), and the native UI thread sits in total darkness, unable to drop the splash screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;With inline requires (the optimized way)&lt;/strong&gt;: Hermes runs the entry file. It encounters the lazy properties created by Metro. It skips the contents, sinice its code is wrapped in a getter closure. Instead of evaluating thousands of files, Hermes merely maps out the lightweight getter references in memory. The entire initial bundle execution pass finishes in a fraction of a second.&lt;/p&gt;
&lt;h4&gt;Phase 3: Handover and lazy runtime evaluation&lt;/h4&gt;
&lt;h5&gt;Step 5: Unlocking the bridge (~1.5s)&lt;/h5&gt;
&lt;p&gt;Because Hermes finished its initial script evaluation pass quickly, it immediately reaches the root &lt;code&gt;AppRegistry.registerComponent&lt;/code&gt; call. It executes &lt;code&gt;App.tsx&lt;/code&gt; and sends a layout tree over the native bridge to the native UI thread.&lt;/p&gt;
&lt;h5&gt;Step 6: Splash screen drops (~1.8s)&lt;/h5&gt;
&lt;p&gt;The native UI thread receives its first set of design instructions from JS. It tears down the native splash screen layout, mounts the root &lt;code&gt;SafeAreaProvider&lt;/code&gt; container, and displays the compiled view structure. The visual boot process is complete.&lt;/p&gt;
&lt;h5&gt;Step 7: On-demand lazy execution at runtime&lt;/h5&gt;
&lt;p&gt;Now the app is open and fully interactive. Eventually, a deferred layout hook forces the code to read an object belonging to the WalletConnect service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;const client = await WalletConnectService.init();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exact microsecond theat variable name is evaluated, the JS engine triggeres that hidden &lt;code&gt;get()&lt;/code&gt; function Metro generated back in Phase 1. Hermes synchronously pauses for a few microseconds, reads the local file bytes for the WalletConnect client, evaluates the cryptographic libraries for the first time, caches the instance in memory so it never has to do it again, and completes the function call.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;React Native cold start thread timelines, before and after enabling global inline requires: the UI thread sits frozen on the splash screen for over 7 seconds while the JS thread runs one synchronous evaluation loop, and afterwards the UI thread is interactive at 1.8 seconds with the heavy frameworks loaded lazily on demand.&lt;/em&gt; &lt;a href=&quot;https://www.nop33.com/blog/improving-react-native-cold-start/&quot;&gt;View the diagram on nop33.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The app structure went from a big upfront waterfall to an incremental on-demand execution system. The binary footprint stays exactly the same but the hardware treats it with efficiency.&lt;/p&gt;
&lt;h2&gt;2. Reducing bundle size&lt;/h2&gt;
&lt;p&gt;The bundle size affects startup time. Expo provides a useful dev package called &lt;code&gt;expo-atlas&lt;/code&gt;. It can analyze the JS bundle and give useful information to regarding depedencies and code. The initial bundle size of the mobile wallet was &lt;code&gt;37.8MB&lt;/code&gt; with &lt;code&gt;6622&lt;/code&gt; modules. After several rounds of optimizations, I managed to reduce it down to &lt;code&gt;27.2MB&lt;/code&gt; with &lt;code&gt;4351&lt;/code&gt; modules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Replaced &lt;code&gt;lucide-react-native&lt;/code&gt; with &lt;code&gt;@react-native-vector-icons/lucide&lt;/code&gt;: The initial icons library took &lt;code&gt;~9%&lt;/code&gt; of the total bundle size. This was a low-hanging fruit.&lt;/li&gt;
&lt;li&gt;Got rid of &lt;code&gt;viem&lt;/code&gt;: I was surprised to see that we needed this library. Using &lt;code&gt;pnpm why viem&lt;/code&gt; revealed that the only package using it is &lt;code&gt;@walletconnect/utils&lt;/code&gt;. With an investigation on GitHub I realized that this dependency does not exist in a following minor release of the depedency. So I edited &lt;code&gt;package.json&lt;/code&gt; and updated the &lt;code&gt;overrides&lt;/code&gt; section to load the new version.&lt;/li&gt;
&lt;li&gt;Commented out devtools: I was even more surprised to see that the devtools contributed to production release bundle size.&lt;/li&gt;
&lt;li&gt;Optimized Lottie animations: A particular lottie file was almost &lt;code&gt;2%&lt;/code&gt; of the bundle size. Simply replacing it with a more optimized one shaved off several KB.&lt;/li&gt;
&lt;/ul&gt;
&lt;section&gt; &lt;p&gt;&lt;img src=&quot;https://www.nop33.com/_astro/atlas.D0rCaYVT_ZccImi.webp&quot; alt=&quot;Expo atlas analysis&quot; width=&quot;3456&quot; height=&quot;1692&quot; /&gt;&lt;/p&gt; &lt;/section&gt;
&lt;h2&gt;Learnings&lt;/h2&gt;
&lt;h3&gt;The &lt;code&gt;React.lazy&lt;/code&gt; trap: Why web optimizations fail on mobile&lt;/h3&gt;
&lt;p&gt;Coming from a React web background, my immediate instinct to fix a heavy screen is to reach for &lt;code&gt;React.lazy(() =&amp;gt; import(&apos;./SendScreen&apos;))&lt;/code&gt; and wrap it in a &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary. On a desktop browser, this is a gold standard. On a native mobile device, it is an anti-pattern that actively degrades user experience. The friction comes down to a fundamental misunderstanding of what we are optimizing for on different platforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;On the web: The primary constraint is &lt;strong&gt;network bandwidth&lt;/strong&gt;. &lt;code&gt;React.lazy&lt;/code&gt; instructs the bundler to split a screen into a separate chunk (&lt;code&gt;sendScreen.chunk.js&lt;/code&gt;) so the user doesn’t waste data downloading code they might never look at over cellular networks.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;On mobile: The primary constraint is &lt;strong&gt;CPU execution&lt;/strong&gt;. 100% of the JavaScript bundle is already compiled and sitting locally on the physical storage of the phone. There is zero network latency when moving between screens.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When wrapping a mobile layout component in &lt;code&gt;React.lazy&lt;/code&gt;, you force React to treat a local, instantaneous file read as a slow, asynchronous Promise. When a user taps a navigation button, the native stack navigator immediately tells the GPU to kick off a fluid, hardware-accelerated slide or cross-fade transition. But because of &lt;code&gt;React.lazy&lt;/code&gt;, the destination screen hits an unexpected &lt;code&gt;&amp;lt;Suspense fallback={null}&amp;gt;&lt;/code&gt; wall. The native transition drops frames, jitters, or flashes a blank background while the JavaScript engine waits for the micro-task queue to click over and resolve the promise.&lt;/p&gt;
&lt;p&gt;Instead of fighting the architecture with manual web code-splitting, keep the imports standard and static:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ts&quot;&gt;import SendScreen from &apos;~/navigation/SendScreen&apos;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By leaning entirely on global &lt;code&gt;inlineRequires&lt;/code&gt; in the Metro configuration, the bundler defers parsing the file until it’s called, but handles the resolution synchronously from local flash storage. This gives you total protection against cold-start bloat without sacrificing buttery-smooth, native hardware transitions.&lt;/p&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;To setup Android Studio, these are some things I had to do:&lt;/p&gt;
&lt;h3&gt;A problem occurred starting process ‘command ‘node’&lt;/h3&gt;
&lt;p&gt;Launch app from the &lt;code&gt;/android&lt;/code&gt; directory with&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;open -a &quot;Android Studio&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Inconsistent JVM-target compatibility detected for tasks ‘compileDebugJavaWithJavac’ (17) and ‘compileDebugKotlin’ (21).&lt;/h3&gt;
&lt;p&gt;Even though I am using Expo 54 and React Native 0.81, the &lt;a href=&quot;https://reactnative.dev/docs/set-up-your-environment&quot;&gt;docs still recommend using Java 17&lt;/a&gt;. Enforce a matching target version globally.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;npx expo install expo-build-properties&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open your root &lt;code&gt;app.config.js&lt;/code&gt; and add the plugin configuration to explicitly pin the Java version to 17 across both toolchains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;expo&quot;: {
    &quot;plugins&quot;: [
      [
        &quot;expo-build-properties&quot;,
        {
          &quot;android&quot;: {
            &quot;javaVersion&quot;: &quot;17&quot;
          }
        }
      ]
    ]
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Regenerate native directories with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;npx expo prebuild --clean&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Invalid Gradle JDK configuration found.&lt;/h3&gt;
&lt;p&gt;Change Gradle JDK location and select &lt;code&gt;JAVA_HOME&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;White screen when starting profiling process&lt;/h3&gt;
&lt;p&gt;It helps to first build the release variant with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;npx expo android:run --variant release&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Making app profilable&lt;/h3&gt;
&lt;p&gt;Update your &lt;code&gt;app.config.js&lt;/code&gt; file with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Injects the &amp;lt;profileable android:shell=&quot;true&quot; /&amp;gt; tag into your production manifest
const withProfileableManifest = (config) =&amp;gt; {
  return withAndroidManifest(config, (modConfig) =&amp;gt; {
    const mainApplication = modConfig.modResults.manifest.application[0];
    if (!mainApplication[&apos;profileable&apos;]) {
      mainApplication[&apos;profileable&apos;] = [{
        $: { &apos;android:shell&apos;: &apos;true&apos; }
      }];
    }
    return modConfig;
  });
};

module.exports = {
  expo: {
    // ... your other existing configurations
    plugins: [
      withProfileableManifest, // 👈 Add the new manifest plugin here
      // ... your other plugins
    ]
  }
};&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Build variants panel&lt;/h3&gt;
&lt;p&gt;Make sure to enable the &lt;em&gt;Build variants&lt;/em&gt; panel (through the &lt;em&gt;View&lt;/em&gt; menu item) and set the &lt;code&gt;:app&lt;/code&gt; module’s active build variant to &lt;code&gt;release&lt;/code&gt; and Active ABI to &lt;code&gt;arm64-v8a&lt;/code&gt; (or whatever arch your Android device has). Then click &lt;code&gt;Profiler: Run &apos;app&apos; as profileable (low overhead)&lt;/code&gt; from the top bar play icon.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Useful resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/guides/tree-shaking/&quot;&gt;https://docs.expo.dev/guides/tree-shaking/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://reactnative.dev/docs/optimizing-javascript-loading&quot;&gt;https://reactnative.dev/docs/optimizing-javascript-loading&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.expo.dev/guides/analyzing-bundles/&quot;&gt;https://docs.expo.dev/guides/analyzing-bundles/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/improving-react-native-cold-start/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>React Native</category><category>Expo</category><category>Performance</category><category>Blockchain</category><enclosure url="https://www.nop33.com/_astro/cover.wGMYoqRR_1F2Ii8.webp" length="0" type="image/webp"/></item><item><title>Giving OpenClaw Safe Access to Your Obsidian Vault</title><link>https://www.nop33.com/blog/giving-openclaw-access-to-obsidian/</link><guid isPermaLink="true">https://www.nop33.com/blog/giving-openclaw-access-to-obsidian/</guid><description>How I gave my self-hosted AI assistant read access to my entire Obsidian vault and restricted write access to a single folder, without risking my notes.</description><pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I have an AI assistant (&lt;a href=&quot;https://github.com/openclaw&quot;&gt;OpenClaw&lt;/a&gt;) running on my home server. I also have an &lt;a href=&quot;https://www.nop33.com/blog/syncing-obsidian-with-syncthing&quot;&gt;Obsidian vault&lt;/a&gt; with all my notes. I wanted my &lt;a href=&quot;https://venice.ai/&quot;&gt;private AI&lt;/a&gt; to read my notes to understand me better, and to write daily journal entries on my behalf.&lt;/p&gt;
&lt;p&gt;But I didn’t want it to be able to delete or modify my existing notes if something went wrong.&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Giving an AI agent filesystem access is a trust problem. If the agent can read and write freely, a single bug or hallucination could corrupt or delete files. With a sync system like &lt;a href=&quot;https://syncthing.net/&quot;&gt;Syncthing&lt;/a&gt; propagating changes across devices, the damage would spread everywhere within seconds.&lt;/p&gt;
&lt;p&gt;I needed two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Read access&lt;/strong&gt; to the entire vault, so the AI can learn about me and reference my notes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write access&lt;/strong&gt; to a single folder (&lt;code&gt;Journal/&lt;/code&gt;) - so it can create daily notes, monthly reviews, and annual reviews&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And critically: the AI should have &lt;strong&gt;no way&lt;/strong&gt; to escalate its access, even if it tries.&lt;/p&gt;
&lt;h2&gt;The architecture&lt;/h2&gt;
&lt;p&gt;My setup runs on &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment/overview&quot;&gt;Proxmox VE&lt;/a&gt; with two VMs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;VM 101&lt;/strong&gt; (&lt;code&gt;production-docker&lt;/code&gt;) - stores the Obsidian vault on disk, runs Syncthing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VM 102&lt;/strong&gt; (&lt;code&gt;openclaw&lt;/code&gt;) - runs the AI assistant&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The vault lives at &lt;code&gt;/dionysus/obsidian/vault&lt;/code&gt; on VM 101. The key insight is to use &lt;strong&gt;two separate channels&lt;/strong&gt; for read and write, each with different permissions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;      OpenClaw VM (102)                      VM 101 (production-docker)
┌────────────────────────────┐             ┌───────────────────────────┐
│                            │             │                           │
│  NFS mount (read-only)    ──────────────&amp;gt;│  /dionysus/obsidian/vault │
│  /mnt/obsidian-vault       │             │                           │
│                            │             │                           │
│  SSH (forced command)     ──────────────&amp;gt;│  write script             │
│  (only writes to Journal/) │             │  (validates path + ext)   │
│                            │             │                           │
└────────────────────────────┘             └───────────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The read path: NFS&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Network_File_System&quot;&gt;NFS&lt;/a&gt; (Network File System) lets one machine share a directory over the network. On VM 101, I exported the vault as &lt;strong&gt;read-only&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;/dionysus/obsidian/vault 192.168.21.60(ro,sync,no_subtree_check)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the OpenClaw VM, it’s mounted at &lt;code&gt;/mnt/obsidian-vault&lt;/code&gt;. The AI can read every note, but the operating system enforces that it cannot write, delete, or modify anything through this mount. No application-level workaround can bypass this since it’s enforced by the NFS server.&lt;/p&gt;
&lt;h2&gt;The write path: SSH with a forced command&lt;/h2&gt;
&lt;p&gt;For writing journal entries, the AI uses SSH. The key is configured with a &lt;strong&gt;forced command&lt;/strong&gt;, which means it can only execute one specific script, no matter what command the AI tries to run.&lt;/p&gt;
&lt;p&gt;The SSH key in &lt;code&gt;authorized_keys&lt;/code&gt; on VM 101 looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;command=&quot;/usr/local/bin/obsidian-write-journal-for-openclaw&quot;,no-port-forwarding,no-agent-forwarding,no-pty,no-X11-forwarding ssh-ed25519 AAAA... openclaw-obsidian-write&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Even if the AI tries to &lt;code&gt;ssh user@server &quot;rm -rf /&quot;&lt;/code&gt;, the SSH server ignores the requested command and runs the forced script instead. The &lt;code&gt;no-pty&lt;/code&gt; flag prevents getting an interactive shell. The &lt;code&gt;no-port-forwarding&lt;/code&gt; and &lt;code&gt;no-agent-forwarding&lt;/code&gt; flags close other escape routes.&lt;/p&gt;
&lt;h2&gt;The write script&lt;/h2&gt;
&lt;p&gt;The script on VM 101 validates every write request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
VAULT=&quot;/dionysus/obsidian/vault&quot;
ALLOWED_DIR=&quot;Journal&quot;

# SSH passes the original command via this variable
if [[ -n &quot;$SSH_ORIGINAL_COMMAND&quot; ]]; then
  eval set -- $SSH_ORIGINAL_COMMAND
fi

MODE=&quot;write&quot;

while [[ &quot;$1&quot; == --* ]]; do
  case &quot;$1&quot; in
    --append) MODE=&quot;append&quot;; shift ;;
    *) echo &quot;ERROR: Unknown flag $1&quot; &amp;gt;&amp;amp;2; exit 1 ;;
  esac
done

FILENAME=&quot;$1&quot;

# Must provide a filename
if [[ -z &quot;$FILENAME&quot; ]]; then
  echo &quot;ERROR: No filename provided&quot; &amp;gt;&amp;amp;2
  exit 1
fi

# Must be inside Journal/
if [[ &quot;$FILENAME&quot; != &quot;$ALLOWED_DIR/&quot;* ]] || [[ &quot;$FILENAME&quot; == *&quot;..&quot;* ]]; then
  echo &quot;ERROR: Can only write to $ALLOWED_DIR/&quot; &amp;gt;&amp;amp;2
  exit 1
fi

# Must be a markdown file
if [[ &quot;$FILENAME&quot; != *.md ]]; then
  echo &quot;ERROR: Only .md files are allowed&quot; &amp;gt;&amp;amp;2
  exit 1
fi

mkdir -p &quot;$VAULT/$(dirname &quot;$FILENAME&quot;)&quot;

if [[ &quot;$MODE&quot; == &quot;append&quot; ]]; then
  cat &amp;gt;&amp;gt; &quot;$VAULT/$FILENAME&quot;
else
  cat &amp;gt; &quot;$VAULT/$FILENAME&quot;
fi&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three checks:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Path restriction&lt;/strong&gt; - filename must start with &lt;code&gt;Journal/&lt;/code&gt; and cannot contain &lt;code&gt;..&lt;/code&gt; (no path traversal)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extension restriction&lt;/strong&gt; - only &lt;code&gt;.md&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No delete capability&lt;/strong&gt; - the script can only create or update files, there’s no delete operation&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;code&gt;--append&lt;/code&gt; flag is important. If I’ve already written something in my daily note manually, the AI appends its summary below without replacing my content.&lt;/p&gt;
&lt;h2&gt;How the AI uses it&lt;/h2&gt;
&lt;p&gt;From the OpenClaw VM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Create a daily note
echo &quot;# 2026-04-16

## What I did today
- Set up Syncthing for Obsidian sync
- Configured NFS and SSH access for OpenClaw
&quot; | ssh -i ~/.ssh/obsidian_write ilias@192.168.21.52 &quot;Journal/2026-04-16.md&quot;

# Append to an existing note
echo &quot;
## Evening reflection
Productive day focused on infrastructure.
&quot; | ssh -i ~/.ssh/obsidian_write ilias@192.168.21.52 &quot;--append Journal/2026-04-16.md&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once written, &lt;a href=&quot;https://www.nop33.com/blog/syncing-obsidian-with-syncthing&quot;&gt;Syncthing&lt;/a&gt; propagates the changes to my MacBook and Android phone automatically.&lt;/p&gt;
&lt;h2&gt;What happens if the AI goes rogue?&lt;/h2&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Threat&lt;/th&gt;&lt;th&gt;Protection&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Delete all notes&lt;/td&gt;&lt;td&gt;Can’t - NFS mount is read-only, write script has no delete operation&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Overwrite notes outside Journal/&lt;/td&gt;&lt;td&gt;Can’t - script rejects any path not starting with &lt;code&gt;Journal/&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Modify the write script itself&lt;/td&gt;&lt;td&gt;Can’t - script lives on VM 101, outside the AI’s reach&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Get a shell on VM 101&lt;/td&gt;&lt;td&gt;Can’t - SSH key has forced command and &lt;code&gt;no-pty&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Bypass SSH and write directly&lt;/td&gt;&lt;td&gt;Can’t - NFS mount is read-only&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Corrupt a journal entry&lt;/td&gt;&lt;td&gt;Recoverable - Syncthing keeps old versions in &lt;code&gt;.stversions/&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The layered approach means there’s no single point of failure. The AI would need to compromise both the NFS protocol &lt;em&gt;and&lt;/em&gt; the SSH forced command mechanism to do real damage. Both are enforced at the OS/protocol level, not the application level.&lt;/p&gt;
&lt;h2&gt;The journal structure&lt;/h2&gt;
&lt;p&gt;All AI-written entries go into &lt;code&gt;Journal/&lt;/code&gt; with date-based naming:&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Type&lt;/th&gt;&lt;th&gt;Filename&lt;/th&gt;&lt;th&gt;Trigger&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Daily note&lt;/td&gt;&lt;td&gt;&lt;code&gt;2026-04-16.md&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Every evening - AI asks what I did, writes a summary&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Monthly review&lt;/td&gt;&lt;td&gt;&lt;code&gt;2026-04.md&lt;/code&gt;&lt;/td&gt;&lt;td&gt;End of month - AI reads that month’s daily notes and synthesizes&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Annual review&lt;/td&gt;&lt;td&gt;&lt;code&gt;2026.md&lt;/code&gt;&lt;/td&gt;&lt;td&gt;End of year - AI reads the 12 monthly reviews and reflects&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Daily notes follow the same format as my Obsidian Daily Notes plugin template, so they integrate seamlessly with the rest of my vault.&lt;/p&gt;
&lt;h2&gt;Why not just use an API?&lt;/h2&gt;
&lt;p&gt;A REST API with authentication would work too, but the SSH forced command approach has advantages for a home server:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No additional service to run&lt;/strong&gt; - SSH is already there&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No authentication code to write&lt;/strong&gt; - SSH keys handle it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Battle-tested security&lt;/strong&gt; - SSH forced commands have been used for decades (think &lt;code&gt;git&lt;/code&gt; over SSH, &lt;code&gt;rsync&lt;/code&gt; backup scripts)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simple to audit&lt;/strong&gt; - one script, one key, one &lt;code&gt;authorized_keys&lt;/code&gt; line&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;The core pattern is: &lt;strong&gt;separate your read and write channels, and enforce restrictions at the infrastructure level, not the application level.&lt;/strong&gt; An AI agent promising to only write to one folder is not the same as an AI agent that &lt;em&gt;can only&lt;/em&gt; write to one folder. The difference matters when things go wrong.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/giving-openclaw-access-to-obsidian/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>Obsidian</category><category>Self-hosting</category><category>AI</category><category>Security</category><category>Linux</category><enclosure url="https://www.nop33.com/_astro/cover.DDZMTdoc_1yU6G0.webp" length="0" type="image/webp"/></item><item><title>From Callbacks to Promises: Same Patterns, Less Pain (part 2)</title><link>https://www.nop33.com/blog/from-callbacks-to-promises-nodejs/</link><guid isPermaLink="true">https://www.nop33.com/blog/from-callbacks-to-promises-nodejs/</guid><description>Rebuilding the same sequential, parallel, and limited-concurrency exercises with promises and async/await, and watching the manual bookkeeping disappear.</description><pubDate>Sun, 12 Apr 2026 14:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In &lt;a href=&quot;https://www.nop33.com/blog/mastering-async-callbacks-nodejs&quot;&gt;Part 1&lt;/a&gt;, I built four exercises to internalize callback-based async control flow: basic callbacks, sequential iteration, unlimited parallel, and limited parallel. Each exercise taught a pattern but also exposed a category of bug that callbacks force you to handle manually.&lt;/p&gt;
&lt;p&gt;Here’s the table I ended with: five rules of callback discipline that you, the programmer, must implement correctly:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Rule&lt;/th&gt;&lt;th&gt;What goes wrong if you break it&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Both code paths must be async (no Zalgo)&lt;/td&gt;&lt;td&gt;Callers can’t predict whether their code runs before or after the callback&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;return&lt;/code&gt; after calling &lt;code&gt;finalCb(err)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The success path also fires, which leads to a double result&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;hasError&lt;/code&gt; flag at the top of every callback&lt;/td&gt;&lt;td&gt;&lt;code&gt;finalCb&lt;/code&gt; fires multiple times on concurrent errors&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;nextIndex&lt;/code&gt; (not &lt;code&gt;completed&lt;/code&gt;) for scheduling&lt;/td&gt;&lt;td&gt;Phantom tasks scheduled beyond the end of the array&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;finalCb&lt;/code&gt; called exactly once per invocation&lt;/td&gt;&lt;td&gt;Consumers see duplicate responses, corrupted state&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;In this post, I rebuild all four exercises with promises and async/await. Every rule in that table becomes a language-enforced guarantee. The code gets shorter. The bugs become impossible. And the patterns become almost trivially simple to express.&lt;/p&gt;
&lt;h2&gt;The starting point: promisify&lt;/h2&gt;
&lt;p&gt;Before I could use promises, I needed to bridge the callback-based &lt;code&gt;fetchUser&lt;/code&gt; into the promise world. There are two ways to do this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A - rewrite from scratch:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUserP = (id) =&amp;gt; {
  return new Promise((resolve, reject) =&amp;gt; {
    if (id &amp;lt; 0) return reject(new Error(&apos;Invalid id&apos;))
    setTimeout(
      () =&amp;gt; {
        resolve({ id, name: &apos;User &apos; + id })
      },
      100 + Math.random() * 300,
    )
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B - wrap the existing function:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const promisify = (fn) =&amp;gt; {
  return (...args) =&amp;gt; {
    return new Promise((resolve, reject) =&amp;gt; {
      fn(...args, (err, result) =&amp;gt; {
        if (err) return reject(err)
        resolve(result)
      })
    })
  }
}

const fetchUserP = promisify(fetchUser)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both produce the same observable behavior. But Option B is more useful in the real world. You’ll frequently inherit callback-based APIs from Node’s older stdlib, legacy npm packages, and C++ bindings. The &lt;code&gt;promisify&lt;/code&gt; wrapper is how you bridge them without rewriting. It’s also what Node’s built-in &lt;code&gt;util.promisify&lt;/code&gt; does under the hood. The pattern is literally: &lt;code&gt;if (err) reject(err); else resolve(result)&lt;/code&gt;. That’s the entire bridge between the two worlds.&lt;/p&gt;
&lt;h3&gt;The accidental discovery: promises are Zalgo-safe&lt;/h3&gt;
&lt;p&gt;Look at Option A’s error path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;if (id &amp;lt; 0) return reject(new Error(&apos;Invalid id&apos;))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I’m calling &lt;code&gt;reject&lt;/code&gt; &lt;strong&gt;synchronously&lt;/strong&gt;, inside the executor, on the same tick as &lt;code&gt;fetchUserP(-5)&lt;/code&gt; itself. In Part 1, this was exactly the “releasing Zalgo” sin - a function that sometimes calls its continuation synchronously and sometimes asynchronously. I had to wrap the error path in &lt;code&gt;process.nextTick&lt;/code&gt; to fix it.&lt;/p&gt;
&lt;p&gt;Here, I didn’t wrap it. And it works fine. Why?&lt;/p&gt;
&lt;p&gt;Because the ECMAScript spec guarantees that &lt;strong&gt;every &lt;code&gt;.then&lt;/code&gt;, &lt;code&gt;.catch&lt;/code&gt;, and &lt;code&gt;await&lt;/code&gt; resumption runs as a microtask&lt;/strong&gt;, regardless of whether the promise was settled synchronously or asynchronously. Even if you call &lt;code&gt;reject&lt;/code&gt; inside the executor before it returns, the &lt;code&gt;.catch&lt;/code&gt; handler doesn’t fire until after the current synchronous call stack empties.&lt;/p&gt;
&lt;p&gt;You can verify this yourself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;fetchUserP(-5).catch((err) =&amp;gt; console.error(&apos;catch:&apos;, err.message))
console.log(&apos;after sync code&apos;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;after sync code
catch: Invalid id&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; handler runs &lt;strong&gt;after&lt;/strong&gt; &lt;code&gt;console.log(&apos;after sync code&apos;)&lt;/code&gt;, even though the promise was already rejected when &lt;code&gt;.catch&lt;/code&gt; was attached. That’s not luck - it’s a language guarantee.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;You cannot release Zalgo from a promise, even if you try.&lt;/strong&gt; This is one of the most important reasons promises exist.&lt;/p&gt;
&lt;p&gt;Here’s how the five callback-discipline rules from Part 1 map to promise guarantees:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Callback discipline (manual)&lt;/th&gt;&lt;th&gt;Promise equivalent (automatic)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;process.nextTick&lt;/code&gt; to avoid sync/async inconsistency&lt;/td&gt;&lt;td&gt;Handlers always run as microtasks, even if the executor settled synchronously&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;return&lt;/code&gt; after &lt;code&gt;finalCb(err)&lt;/code&gt; to prevent double-fire&lt;/td&gt;&lt;td&gt;A promise settles exactly once - extra &lt;code&gt;resolve&lt;/code&gt;/&lt;code&gt;reject&lt;/code&gt; calls are silently ignored&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;hasError&lt;/code&gt; flag + guard at top of callback&lt;/td&gt;&lt;td&gt;&lt;code&gt;Promise.all&lt;/code&gt; / &lt;code&gt;Promise.race&lt;/code&gt; handle this internally&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;nextIndex&lt;/code&gt; bookkeeping for scheduling&lt;/td&gt;&lt;td&gt;Built into &lt;code&gt;Promise.all&lt;/code&gt;; manual only for limited concurrency&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;finalCb&lt;/code&gt; fires exactly once&lt;/td&gt;&lt;td&gt;A promise can only resolve or reject once, by specification&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Exercise 5: Sequential with async/await&lt;/h2&gt;
&lt;p&gt;The callback version of sequential iteration (Part 1, Exercise 2) required a recursive &lt;code&gt;iterate(index)&lt;/code&gt; helper - 20 lines of code managing a results array, a termination check, error propagation, and the recursive call.&lt;/p&gt;
&lt;p&gt;The async/await version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersSequentiallyAsync = async (ids) =&amp;gt; {
  const results = []
  for (const id of ids) results.push(await fetchUserP(id))
  return results
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three lines.&lt;/p&gt;
&lt;p&gt;That’s not a compressed version of something bigger. That &lt;strong&gt;is&lt;/strong&gt; the function. The &lt;code&gt;for...of&lt;/code&gt; loop replaces the recursive iterator. &lt;code&gt;results.push()&lt;/code&gt; replaces the closure variable and index tracking. &lt;code&gt;return results&lt;/code&gt; replaces &lt;code&gt;finalCb(null, results)&lt;/code&gt;. And error handling? There is none - if &lt;code&gt;await fetchUserP(id)&lt;/code&gt; rejects, the &lt;code&gt;await&lt;/code&gt; throws, the throw exits the loop, the throw exits the function, and the returned promise rejects. The caller catches it with &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.time(&apos;sequential&apos;)
try {
  const users = await fetchUsersSequentiallyAsync([1, 2, 3, 4, 5])
  console.timeEnd(&apos;sequential&apos;)
  for (const u of users) console.log(`Got user: ${u.id} - ${u.name}`)
} catch (err) {
  console.timeEnd(&apos;sequential&apos;)
  console.error(&apos;Caught:&apos;, err.message)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Got user: 1 - User 1
Got user: 2 - User 2
Got user: 3 - User 3
Got user: 4 - User 4
Got user: 5 - User 5
sequential: 1.466s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;1.466s&lt;/strong&gt; - identical to the callback version’s 1.472s. Async/await is not a speedup. It’s a clarity improvement. The underlying work happens at exactly the same pace.&lt;/p&gt;
&lt;h3&gt;Short-circuit on error - for free&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;[1, 2, -3, 4, 5]&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;  → start fetch 1
  ← done   fetch 1
  → start fetch 2
  ← done   fetch 2
  → start fetch -3
  ← error  fetch -3
sequential: 492ms
Caught: Invalid id&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fetches 4 and 5 are &lt;strong&gt;never attempted&lt;/strong&gt;. The moment &lt;code&gt;await fetchUserP(-3)&lt;/code&gt; rejects, the &lt;code&gt;await&lt;/code&gt; expression throws, the throw exits the &lt;code&gt;for...of&lt;/code&gt; loop (no more iterations), and the promise returned by the async function rejects. No flags, no counters, no &lt;code&gt;return finalCb(err)&lt;/code&gt;. Just a thrown exception unwinding the stack the way exceptions unwind stacks in synchronous code.&lt;/p&gt;
&lt;p&gt;Compare to the callback version: there, I needed &lt;code&gt;if (err) { finalCb(err); return }&lt;/code&gt; inside the inner callback, a &lt;code&gt;return&lt;/code&gt; after calling &lt;code&gt;finalCb&lt;/code&gt; to stop the iteration, careful attention that &lt;code&gt;finalCb&lt;/code&gt; was only called once, and &lt;code&gt;process.nextTick&lt;/code&gt; on the error path to avoid Zalgo. All of that is now done by the language.&lt;/p&gt;
&lt;h3&gt;The &lt;code&gt;.forEach&lt;/code&gt; trap&lt;/h3&gt;
&lt;p&gt;One common mistake: using &lt;code&gt;.forEach&lt;/code&gt; instead of &lt;code&gt;for...of&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// DOES NOT work - all fetches run in parallel
ids.forEach(async (id) =&amp;gt; {
  const user = await fetchUserP(id)
  results.push(user)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;async&lt;/code&gt; callback returns a promise that &lt;code&gt;.forEach&lt;/code&gt; ignores. The loop doesn’t wait. All five fetches get kicked off concurrently. Use &lt;code&gt;for...of&lt;/code&gt; or a plain &lt;code&gt;for&lt;/code&gt; loop if you want &lt;code&gt;await&lt;/code&gt; to sequence the iterations.&lt;/p&gt;
&lt;h2&gt;Exercise 6: Parallel with &lt;code&gt;Promise.all&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;The callback version of unlimited parallel (Part 1, Exercise 3) was 25 lines - &lt;code&gt;new Array(ids.length)&lt;/code&gt;, a &lt;code&gt;completed&lt;/code&gt; counter, a &lt;code&gt;hasError&lt;/code&gt; flag with a guard at the top of every callback, and &lt;code&gt;results[i] = result&lt;/code&gt; for order-preserving writes.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Promise.all&lt;/code&gt; version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersInParallelAsync = (ids) =&amp;gt; Promise.all(ids.map((id) =&amp;gt; fetchUserP(id)))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One line. &lt;code&gt;Promise.all&lt;/code&gt; takes an array of promises and returns a single promise that resolves with an array of results &lt;strong&gt;in the same order as the input&lt;/strong&gt; - regardless of which one settled first. On the first rejection, it rejects immediately.&lt;/p&gt;
&lt;p&gt;Everything I hand-coded in the callback version is built in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;new Array(ids.length)&lt;/code&gt; + &lt;code&gt;results[i] = result&lt;/code&gt; → order-preserving is built in&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed&lt;/code&gt; counter + &lt;code&gt;completed === ids.length&lt;/code&gt; → “all done” detection is built in&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hasError&lt;/code&gt; flag + guard at top of callback → first-rejection-wins is built in&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Output (success):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;parallel-success: 321ms
Got user: 1 - User 1
Got user: 2 - User 2
Got user: 3 - User 3
Got user: 4 - User 4
Got user: 5 - User 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output (error with &lt;code&gt;[1, 2, -3, 4, 5]&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;  → start fetch 1
  → start fetch 2
  → start fetch -3
  → start fetch 4
  → start fetch 5
  ← error  fetch -3
parallel-error: 0.439ms
Caught: Invalid id
  ← done   fetch 4
  ← done   fetch 2
  ← done   fetch 5
  ← done   fetch 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the four &lt;code&gt;← done&lt;/code&gt; lines &lt;strong&gt;after&lt;/strong&gt; the &lt;code&gt;Caught:&lt;/code&gt; line. Those &lt;code&gt;setTimeout&lt;/code&gt; callbacks still fired - the timers were not cancelled. &lt;code&gt;Promise.all&lt;/code&gt; short-circuits on the first rejection, but it &lt;strong&gt;cannot cancel in-flight work&lt;/strong&gt;. The other promises keep running, consuming resources, until they naturally complete. Their results are silently discarded.&lt;/p&gt;
&lt;p&gt;JavaScript promises have no built-in cancellation. In this toy example with 300ms timers, that’s harmless. In a real system where each in-flight operation holds a database connection or an HTTP socket, this is why limited concurrency matters even more with promises.&lt;/p&gt;
&lt;h3&gt;The &lt;code&gt;.map(fn)&lt;/code&gt; gotcha&lt;/h3&gt;
&lt;p&gt;My first version passed &lt;code&gt;fetchUserP&lt;/code&gt; directly to &lt;code&gt;.map&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;Promise.all(ids.map(fetchUserP)) // crashed!&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This blew up with &lt;code&gt;TypeError: cb is not a function&lt;/code&gt;. Why?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Array.prototype.map&lt;/code&gt; passes &lt;strong&gt;three&lt;/strong&gt; arguments to its callback: &lt;code&gt;(element, index, array)&lt;/code&gt;. So &lt;code&gt;ids.map(fetchUserP)&lt;/code&gt; is equivalent to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;ids.map((element, index, array) =&amp;gt; fetchUserP(element, index, array))&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fetchUserP&lt;/code&gt; is &lt;code&gt;promisify(fetchUser)&lt;/code&gt;, which spreads all arguments and appends the callback:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;fetchUser(1, 0, [1,2,3,4,5], (err, result) =&amp;gt; { ... })&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;fetchUser&lt;/code&gt; takes &lt;code&gt;(id, cb)&lt;/code&gt; - so &lt;code&gt;id = 1&lt;/code&gt;, &lt;code&gt;cb = 0&lt;/code&gt; (the index!). When the &lt;code&gt;setTimeout&lt;/code&gt; fires and tries to call &lt;code&gt;cb(null, user)&lt;/code&gt;, it calls &lt;code&gt;0(null, user)&lt;/code&gt;. A number, not a function. Crash.&lt;/p&gt;
&lt;p&gt;The fix: &lt;code&gt;ids.map(id =&amp;gt; fetchUserP(id))&lt;/code&gt; - wrap it so only the element is forwarded.&lt;/p&gt;
&lt;p&gt;This is the same family of bugs as the classic &lt;code&gt;[&apos;1&apos;, &apos;2&apos;, &apos;3&apos;].map(parseInt)&lt;/code&gt; returning &lt;code&gt;[1, NaN, NaN]&lt;/code&gt; - &lt;code&gt;parseInt&lt;/code&gt; receives the index as its radix parameter. The rule: &lt;strong&gt;never pass a multi-argument function directly to &lt;code&gt;.map&lt;/code&gt; unless you’re certain the extra arguments are harmless.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;Exercise 7: Limited concurrency with promises&lt;/h2&gt;
&lt;p&gt;This is the capstone exercise - the hardest pattern from chapter 4, rebuilt with promises. I implemented it two ways.&lt;/p&gt;
&lt;h3&gt;Approach 1: Port the callback version&lt;/h3&gt;
&lt;p&gt;The most direct translation: wrap the &lt;code&gt;tryNext&lt;/code&gt; / &lt;code&gt;while&lt;/code&gt; loop / counter logic from Part 1 inside a &lt;code&gt;new Promise&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersWithConcurrencyAsync = (ids, concurrency) =&amp;gt; {
  return new Promise((resolve, reject) =&amp;gt; {
    let running = 0
    let completed = 0
    let nextIndex = 0
    const results = new Array(ids.length)

    const tryNext = () =&amp;gt; {
      while (running &amp;lt; concurrency &amp;amp;&amp;amp; nextIndex &amp;lt; ids.length) {
        const i = nextIndex
        running++
        nextIndex++
        fetchUserP(ids[i])
          .then((result) =&amp;gt; {
            results[i] = result
            completed++
            running--
            if (completed === ids.length) resolve(results)
            else tryNext()
          })
          .catch(reject)
      }
    }

    tryNext()
  })
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works, but I hit an interesting bug on my first attempt. I put &lt;code&gt;running--&lt;/code&gt; in a &lt;code&gt;.finally()&lt;/code&gt; handler instead of inside &lt;code&gt;.then()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;fetchUserP(ids[i])
  .then((result) =&amp;gt; {
    results[i] = result
    if (++completed === ids.length) resolve(results)
    else tryNext() // tryNext runs HERE, with stale running count
  })
  .catch(reject)
  .finally(() =&amp;gt; running--) // decrement runs AFTER tryNext&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem: &lt;code&gt;.then()&lt;/code&gt;, &lt;code&gt;.catch()&lt;/code&gt;, and &lt;code&gt;.finally()&lt;/code&gt; handlers chain as sequential microtasks. &lt;code&gt;.then()&lt;/code&gt; runs first, and &lt;code&gt;.finally()&lt;/code&gt; runs &lt;strong&gt;after&lt;/strong&gt; it. So when &lt;code&gt;tryNext()&lt;/code&gt; runs inside &lt;code&gt;.then()&lt;/code&gt;, &lt;code&gt;running&lt;/code&gt; hasn’t been decremented yet - it’s still at the concurrency cap. The &lt;code&gt;while (running &amp;lt; concurrency)&lt;/code&gt; check fails, no new task starts, and the system effectively runs at &lt;strong&gt;concurrency - 1&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;My output confirmed it: with concurrency 3, the timing was 2.458s (matching concurrency 2’s theoretical &lt;code&gt;ceil(20/2) × 250ms ≈ 2.5s&lt;/code&gt;), not the expected ~1.7s. After moving &lt;code&gt;running--&lt;/code&gt; to the top of &lt;code&gt;.then()&lt;/code&gt; - before &lt;code&gt;tryNext()&lt;/code&gt; - the timing dropped to 1.765s.&lt;/p&gt;
&lt;p&gt;The lesson: &lt;strong&gt;in promise chains, the order of handlers matters.&lt;/strong&gt; State mutations that affect scheduling logic must happen before the scheduling call, not in a later handler in the chain.&lt;/p&gt;
&lt;h3&gt;Approach 2: Promise.race pool&lt;/h3&gt;
&lt;p&gt;A cleaner approach that leverages &lt;code&gt;Promise.race&lt;/code&gt; to naturally manage the concurrency window:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersWithConcurrencyAsync = async (ids, concurrency) =&amp;gt; {
  const results = new Array(ids.length)
  const pool = new Set()

  for (let i = 0; i &amp;lt; ids.length; i++) {
    if (pool.size === concurrency) {
      await Promise.race(pool)
    }
    const promise = fetchUserP(ids[i])
      .then((result) =&amp;gt; (results[i] = result))
      .finally(() =&amp;gt; pool.delete(promise))
    pool.add(promise)
  }

  await Promise.all(pool)
  return results
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The idea: maintain a &lt;code&gt;Set&lt;/code&gt; of currently-running promises. The &lt;code&gt;for&lt;/code&gt; loop spins through ids, adding promises to the pool. When the pool hits capacity, &lt;code&gt;await Promise.race(pool)&lt;/code&gt; suspends the loop until one promise settles, freeing a slot. After the loop, &lt;code&gt;await Promise.all(pool)&lt;/code&gt; drains any remaining in-flight work.&lt;/p&gt;
&lt;p&gt;Notice that &lt;code&gt;.finally()&lt;/code&gt; is used correctly here - for cleanup (removing a promise from the pool set), not for control-flow state. The scheduling isn’t driven by a counter; it’s driven by &lt;code&gt;await Promise.race&lt;/code&gt;, which naturally resumes the &lt;code&gt;for&lt;/code&gt; loop &lt;strong&gt;after&lt;/strong&gt; the &lt;code&gt;.then()&lt;/code&gt; and &lt;code&gt;.finally()&lt;/code&gt; microtasks have both run. By the time the loop continues, the settled promise has already been removed from the pool.&lt;/p&gt;
&lt;p&gt;Comparing the two approaches:&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Approach 1 (port)&lt;/th&gt;&lt;th&gt;Approach 2 (pool)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;State variables&lt;/td&gt;&lt;td&gt;&lt;code&gt;running&lt;/code&gt;, &lt;code&gt;completed&lt;/code&gt;, &lt;code&gt;nextIndex&lt;/code&gt;, &lt;code&gt;results&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;results&lt;/code&gt;, &lt;code&gt;pool&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Scheduling&lt;/td&gt;&lt;td&gt;Manual &lt;code&gt;tryNext()&lt;/code&gt; with &lt;code&gt;while&lt;/code&gt; loop&lt;/td&gt;&lt;td&gt;&lt;code&gt;await Promise.race(pool)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;”All done” detection&lt;/td&gt;&lt;td&gt;&lt;code&gt;completed === ids.length&lt;/code&gt; then &lt;code&gt;resolve(results)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;await Promise.all(pool)&lt;/code&gt; then &lt;code&gt;return results&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Error propagation&lt;/td&gt;&lt;td&gt;&lt;code&gt;.catch(reject)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Automatic - &lt;code&gt;Promise.race&lt;/code&gt; throws, &lt;code&gt;await&lt;/code&gt; propagates&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Lines of function body&lt;/td&gt;&lt;td&gt;~20&lt;/td&gt;&lt;td&gt;~10&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Both produce the same output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;  → start fetch 1
  → start fetch 2
  → start fetch 3
  ← done   fetch 2
  → start fetch 4
  ← done   fetch 1
  → start fetch 5
  ...
  ← done   fetch 20
limited-parallel: 1.587s
Fetched 20 users&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exactly 3 in flight at all times, results in input order, ~1.6s total.&lt;/p&gt;
&lt;h2&gt;The big picture&lt;/h2&gt;
&lt;p&gt;Here are all eight exercises across both posts, mapped side by side:&lt;/p&gt;




































































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;Pattern&lt;/th&gt;&lt;th&gt;Concurrency&lt;/th&gt;&lt;th&gt;Tools&lt;/th&gt;&lt;th&gt;What you handle manually&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;Basic callback&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;&lt;code&gt;fetchUser(id, cb)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Zalgo prevention, error-first convention&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;Sequential&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;Recursive &lt;code&gt;iterate()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Recursion, termination check, error short-circuit&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;Unlimited parallel&lt;/td&gt;&lt;td&gt;N&lt;/td&gt;&lt;td&gt;Counter + &lt;code&gt;hasError&lt;/code&gt; flag&lt;/td&gt;&lt;td&gt;Flag placement, order-preserving, exactly-once callback&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;Limited parallel&lt;/td&gt;&lt;td&gt;k&lt;/td&gt;&lt;td&gt;&lt;code&gt;tryNext()&lt;/code&gt; + &lt;code&gt;while&lt;/code&gt; loop&lt;/td&gt;&lt;td&gt;4 state variables, scheduling guard, phantom-task prevention&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;Promisify&lt;/td&gt;&lt;td&gt;-&lt;/td&gt;&lt;td&gt;&lt;code&gt;new Promise&lt;/code&gt; wrapper&lt;/td&gt;&lt;td&gt;Nothing - the bridge is mechanical&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;Sequential&lt;/td&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;for...of&lt;/code&gt; + &lt;code&gt;await&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Nothing - loop + await + throw handle everything&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;7&lt;/td&gt;&lt;td&gt;Unlimited parallel&lt;/td&gt;&lt;td&gt;N&lt;/td&gt;&lt;td&gt;&lt;code&gt;Promise.all&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Nothing - order, counting, and error handling are built in&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;Limited parallel&lt;/td&gt;&lt;td&gt;k&lt;/td&gt;&lt;td&gt;&lt;code&gt;Promise.race&lt;/code&gt; pool&lt;/td&gt;&lt;td&gt;Pool management (10 lines vs 30)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The arc is clear. As you move down the table, the “What you handle manually” column empties out. The patterns don’t change - sequential, parallel, limited parallel are the same problems in both halves. What changes is &lt;strong&gt;how much of the bookkeeping the language does for you&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Async/await doesn’t make your code faster. The timings are identical: ~1.5s sequential, ~300ms parallel, ~1.7s limited parallel, regardless of whether you use callbacks or promises. What it does is make the &lt;strong&gt;mental model tractable&lt;/strong&gt;. You stop having to reason about “which set of things are true at the exact moment each callback fires” and start writing code that reads like synchronous logic - with &lt;code&gt;for&lt;/code&gt;, &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;, and &lt;code&gt;return&lt;/code&gt; - where the only new concept is that &lt;code&gt;await&lt;/code&gt; suspends until a promise settles.&lt;/p&gt;
&lt;p&gt;That’s the whole arc of chapters 4 and 5 of the book. If you’ve worked through these exercises, you’ve internalized it. Happy coding.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/from-callbacks-to-promises-nodejs/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>Node.js</category><category>JavaScript</category><category>Async</category><category>Tutorial</category><enclosure url="https://www.nop33.com/_astro/cover.Ci_01dIO_Z1dQs7b.webp" length="0" type="image/webp"/></item><item><title>Mastering Async Callbacks in Node.js, Without the Complexity (part 1)</title><link>https://www.nop33.com/blog/mastering-async-callbacks-nodejs/</link><guid isPermaLink="true">https://www.nop33.com/blog/mastering-async-callbacks-nodejs/</guid><description>The web spider example from Node.js Design Patterns lost me. Here are the simple exercises I used to finally internalize callback-based async control flow.</description><pubDate>Sun, 12 Apr 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’ve been reading &lt;a href=&quot;https://www.nodejsdesignpatterns.com/&quot;&gt;Node.js Design Patterns&lt;/a&gt; (4th Edition) by Mario Casciaro and Luciano Mammino. It’s an excellent book. I’ve even read the 3rd Edition on my Kobo, but I realized I am having a tough time comprehending technical subjects on an e-book reader, so I decided to order the physical book.&lt;/p&gt;
&lt;p&gt;As I was reading in (in parallel with my favorite LLMs to test my comprehension), chapters 4 and 5 hit me like a wall. Both chapters teach async control flow patterns (callbacks in ch. 4, promises/async-await in ch. 5) through a progressively complex “web spider” example that crawls URLs, parses HTML, writes files to disk, and handles recursive link traversal.&lt;/p&gt;
&lt;p&gt;The patterns themselves aren’t that hard. But the spider mixes four concerns at once: HTTP requests, filesystem I/O, URL parsing, and the control-flow pattern you’re actually trying to learn. I kept re-reading the same code and feeling like I understood it, but when I tried to write something from scratch, nothing stuck.&lt;/p&gt;
&lt;p&gt;So I took a different approach: strip away everything except the control-flow pattern and build it myself, step by step, with Claude Code as my guide. Instead of a web spider, I used a trivially simple fake async function &lt;code&gt;fetchUser(id, cb)&lt;/code&gt; that simulates a database call with &lt;code&gt;setTimeout&lt;/code&gt;. No HTTP. No filesystem. No URL parsing. Just the pattern.&lt;/p&gt;
&lt;p&gt;It worked. The patterns finally clicked. In this post (Part 1 of 2), I’ll walk you through the four callback exercises I used to internalize chapter 4. In &lt;a href=&quot;https://www.nop33.com/blog/from-callbacks-to-promises-nodejs&quot;&gt;Part 2&lt;/a&gt;, I’ll rebuild the same exercises with promises and async/await and show how much simpler they become.&lt;/p&gt;
&lt;h2&gt;The setup: a fake async function&lt;/h2&gt;
&lt;p&gt;Every exercise in this series uses the same tiny function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUser = (id, cb) =&amp;gt; {
  if (id &amp;lt; 0) {
    const err = new Error(`Invalid id: ${id}`)
    return process.nextTick(() =&amp;gt; cb(err))
  }

  setTimeout(
    () =&amp;gt; {
      cb(null, { id, name: &apos;User &apos; + id })
    },
    100 + Math.random() * 300,
  )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It takes an &lt;code&gt;id&lt;/code&gt; and a callback, waits 100-400ms to simulate network latency, and returns a fake user object. If the id is negative, it errors. That’s it.&lt;/p&gt;
&lt;p&gt;But even this 10-line function has a subtlety worth understanding before we move on.&lt;/p&gt;
&lt;h3&gt;The Zalgo problem&lt;/h3&gt;
&lt;p&gt;Notice that the error branch uses &lt;code&gt;process.nextTick(() =&amp;gt; cb(err))&lt;/code&gt; instead of just &lt;code&gt;cb(err)&lt;/code&gt;. Why not call the callback immediately?&lt;/p&gt;
&lt;p&gt;Because the success branch calls the callback asynchronously (inside a &lt;code&gt;setTimeout&lt;/code&gt;). If the error branch called it synchronously, then &lt;code&gt;fetchUser&lt;/code&gt; would be &lt;strong&gt;sometimes sync, sometimes async&lt;/strong&gt; depending on its input. This is known as &lt;a href=&quot;https://blog.izs.me/2013/08/designing-apis-for-asynchrony/&quot;&gt;“releasing Zalgo”&lt;/a&gt;: a function that unpredictably switches between sync and async behavior is nightmarish to reason about.&lt;/p&gt;
&lt;p&gt;Here’s the problem it causes. Consider this caller:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.log(&apos;before&apos;)
fetchUser(-1, (err) =&amp;gt; console.log(&apos;callback:&apos;, err.message))
console.log(&apos;after&apos;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With a &lt;strong&gt;synchronous&lt;/strong&gt; error branch, the output would be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;before
callback: Invalid id
after&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The callback fires &lt;em&gt;before&lt;/em&gt; &lt;code&gt;console.log(&apos;after&apos;)&lt;/code&gt; runs. But with a positive id (async branch), the output would be:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;before
after
callback: User 1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same function, different ordering depending on the input. Code that works after the function call might run before &lt;em&gt;or&lt;/em&gt; after the callback, unpredictably. That’s Zalgo.&lt;/p&gt;
&lt;p&gt;The rule is simple: &lt;strong&gt;an async function must be async on every code path, always.&lt;/strong&gt; Wrapping the error callback in &lt;code&gt;process.nextTick&lt;/code&gt; guarantees that the callback is always deferred to the next iteration of the event loop, regardless of which branch executes.&lt;/p&gt;
&lt;h2&gt;Exercise 1: The error-first callback&lt;/h2&gt;
&lt;p&gt;Before tackling any control-flow patterns, I made sure I could write and consume an error-first callback correctly. The exercise: call &lt;code&gt;fetchUser&lt;/code&gt; three times. Once with a valid id, once with an invalid id, and once more with a valid id, and handle the results.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const cb = (err, result) =&amp;gt; {
  if (err) {
    console.error(err)
  } else {
    console.log(`Got user: ${result.id} - ${result.name}`)
  }
}

fetchUser(1, cb)
fetchUser(-5, cb)
fetchUser(2, cb)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Error: Invalid id
Got user: 1 - User 1
Got user: 2 - User 2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things to notice:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. The error-first convention.&lt;/strong&gt; When there’s no error, we pass &lt;code&gt;null&lt;/code&gt; as the first argument: &lt;code&gt;cb(null, user)&lt;/code&gt;. When there is an error, we pass only the error: &lt;code&gt;cb(err)&lt;/code&gt;. The callback always checks &lt;code&gt;err&lt;/code&gt; first. This convention is baked into Node.js and every library that uses callbacks. Ugly af.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. The output order.&lt;/strong&gt; The error prints first even though &lt;code&gt;fetchUser(-5)&lt;/code&gt; is the second call. Why? Because &lt;code&gt;process.nextTick&lt;/code&gt; callbacks run before &lt;code&gt;setTimeout&lt;/code&gt; callbacks in the event loop. The error (deferred via &lt;code&gt;nextTick&lt;/code&gt;) fires before either success callback (deferred via &lt;code&gt;setTimeout&lt;/code&gt;). The order isn’t “order of invocation” - it’s “order of event-loop scheduling.”&lt;/p&gt;
&lt;p&gt;Simple exercise, but the foundation for everything that follows. If this is solid, the rest builds on top.&lt;/p&gt;
&lt;h2&gt;Exercise 2: Sequential execution&lt;/h2&gt;
&lt;p&gt;Now the real work begins. The goal: fetch a list of users &lt;strong&gt;one at a time&lt;/strong&gt;, each call waiting for the previous one to finish. This is the “sequential iteration pattern” from chapter 4 of the book.&lt;/p&gt;
&lt;h3&gt;The challenge&lt;/h3&gt;
&lt;p&gt;Write a function &lt;code&gt;fetchUsersSequentially(ids, finalCb)&lt;/code&gt; that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Takes an array of ids (e.g., &lt;code&gt;[1, 2, 3, 4, 5]&lt;/code&gt;) and a final callback&lt;/li&gt;
&lt;li&gt;Calls &lt;code&gt;fetchUser&lt;/code&gt; on each id &lt;strong&gt;one after another&lt;/strong&gt;. Each call must wait for the previous one to finish.&lt;/li&gt;
&lt;li&gt;When all are done, calls &lt;code&gt;finalCb(null, results)&lt;/code&gt; with an array of user objects in the same order as &lt;code&gt;ids&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;If any call fails, stops immediately and calls &lt;code&gt;finalCb(err)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
Hints (try it yourself first)&lt;p&gt;The canonical way to do sequential iteration with callbacks is &lt;strong&gt;recursion via an iterator function&lt;/strong&gt;:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;Define an inner function &lt;code&gt;iterate(index)&lt;/code&gt; that checks if &lt;code&gt;index === ids.length&lt;/code&gt;. If yes, we’re done, so call &lt;code&gt;finalCb(null, results)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Otherwise, call &lt;code&gt;fetchUser(ids[index], (err, user) =&amp;gt; { ... })&lt;/code&gt;. In that inner callback: on error, call &lt;code&gt;finalCb(err)&lt;/code&gt; and return. Otherwise, push &lt;code&gt;user&lt;/code&gt; into &lt;code&gt;results&lt;/code&gt; and call &lt;code&gt;iterate(index + 1)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Kick it off with &lt;code&gt;iterate(0)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;Do &lt;strong&gt;not&lt;/strong&gt; use a &lt;code&gt;for&lt;/code&gt; loop! With callbacks, a &lt;code&gt;for&lt;/code&gt; loop would fire all the requests at once (parallel), not sequentially. That’s Exercise 3.&lt;/p&gt;
&lt;h3&gt;My solution&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersSequentially = (ids, finalCb) =&amp;gt; {
  const results = []

  const iterate = (index) =&amp;gt; {
    if (index === ids.length) {
      return finalCb(null, results)
    }

    fetchUser(ids[index], (err, result) =&amp;gt; {
      if (err) {
        return finalCb(err)
      }
      results.push(result)
      iterate(index + 1)
    })
  }

  iterate(0)
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The driver code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.time(&apos;sequential&apos;)
fetchUsersSequentially([1, 2, 3, 4, 5], (err, results) =&amp;gt; {
  console.timeEnd(&apos;sequential&apos;)
  if (err) return console.error(err)
  for (const user of results) console.log(`Got user: ${user.id} - ${user.name}`)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Got user: 1 - User 1
Got user: 2 - User 2
Got user: 3 - User 3
Got user: 4 - User 4
Got user: 5 - User 5
sequential: 1.472s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Five sequential fetches, each averaging ~250ms. Total: ~1.5s. Remember this number, we’ll compare it against parallel execution.&lt;/p&gt;
&lt;h3&gt;Style note: “done” check at the top&lt;/h3&gt;
&lt;p&gt;Putting the &lt;code&gt;if (index === ids.length)&lt;/code&gt; check at the &lt;strong&gt;top&lt;/strong&gt; of &lt;code&gt;iterate&lt;/code&gt; (rather than checking “is this the last one?” after fetching) handles the edge case of an empty array naturally. If &lt;code&gt;ids&lt;/code&gt; is &lt;code&gt;[]&lt;/code&gt;, &lt;code&gt;iterate(0)&lt;/code&gt; immediately sees &lt;code&gt;0 === 0&lt;/code&gt; and calls &lt;code&gt;finalCb(null, [])&lt;/code&gt;. With the check at the bottom, you’d call &lt;code&gt;fetchUser(undefined, ...)&lt;/code&gt; - a latent bug.&lt;/p&gt;
&lt;h2&gt;Exercise 3: Unlimited parallel execution&lt;/h2&gt;
&lt;p&gt;Now the opposite: fetch all users &lt;strong&gt;at the same time&lt;/strong&gt; and gather the results when everything’s done.&lt;/p&gt;
&lt;h3&gt;The challenge&lt;/h3&gt;
&lt;p&gt;Write &lt;code&gt;fetchUsersInParallel(ids, finalCb)&lt;/code&gt; with the same contract &lt;code&gt;finalCb(err, results)&lt;/code&gt;, results in input order, fires exactly once. But kick off all fetches immediately.&lt;/p&gt;
Hints&lt;ul&gt;
&lt;li&gt;Use a &lt;code&gt;for&lt;/code&gt; loop to kick off all fetches at once.&lt;/li&gt;
&lt;li&gt;Track completions with a &lt;code&gt;completed&lt;/code&gt; counter. When it equals &lt;code&gt;ids.length&lt;/code&gt;, call &lt;code&gt;finalCb(null, results)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;results[i] = user&lt;/code&gt; (not &lt;code&gt;.push()&lt;/code&gt;) to preserve input order despite out-of-order completions.&lt;/li&gt;
&lt;li&gt;Use a &lt;code&gt;hasError&lt;/code&gt; flag to ensure &lt;code&gt;finalCb&lt;/code&gt; is called at most once after an error.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;My solution&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersInParallel = (ids, finalCb) =&amp;gt; {
  const results = new Array(ids.length)
  let completed = 0
  let hasError = false

  for (let i = 0; i &amp;lt; ids.length; i++) {
    fetchUser(ids[i], (err, result) =&amp;gt; {
      if (hasError) return
      if (err) {
        hasError = true
        return finalCb(err)
      }
      results[i] = result
      if (++completed === ids.length) finalCb(null, results)
    })
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output (success case):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;parallel-success: 309ms
Got user: 1 - User 1
Got user: 2 - User 2
Got user: 3 - User 3
Got user: 4 - User 4
Got user: 5 - User 5&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;309ms&lt;/strong&gt; for five fetches, versus ~1.5s sequential. The speedup is almost 5x! All five ran concurrently, bottlenecked only by the slowest one. That’s the whole point of parallel.&lt;/p&gt;
&lt;h3&gt;What I learned: why &lt;code&gt;hasError&lt;/code&gt; must be checked at the top&lt;/h3&gt;
&lt;p&gt;My first attempt had the &lt;code&gt;hasError&lt;/code&gt; check in the wrong place. I only checked &lt;code&gt;!hasError&lt;/code&gt; on the success path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Buggy version
fetchUser(ids[i], (err, result) =&amp;gt; {
  if (err) {
    hasError = true
    return finalCb(err)
  }
  results[i] = result
  if (++completed === ids.length &amp;amp;&amp;amp; !hasError) finalCb(null, results)
  //                                ^^^^^^^^^ only checked here
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This seemed reasonable. If an error occurred, &lt;code&gt;completed&lt;/code&gt; would never reach &lt;code&gt;ids.length&lt;/code&gt; (error’d callbacks don’t increment it), so the success callback would never fire. And that’s true. But what about &lt;strong&gt;multiple errors&lt;/strong&gt;?&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;[1, -2, -3, 4]&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;fetchUser(-2)&lt;/code&gt; errors → &lt;code&gt;hasError = true&lt;/code&gt;, &lt;code&gt;finalCb(err)&lt;/code&gt;. &lt;strong&gt;First call.&lt;/strong&gt; Good.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetchUser(-3)&lt;/code&gt; errors → &lt;code&gt;hasError = true&lt;/code&gt; (already was), &lt;code&gt;finalCb(err)&lt;/code&gt;. &lt;strong&gt;Second call.&lt;/strong&gt; Bad!&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;finalCb&lt;/code&gt; fired twice. The flag didn’t protect the error path from other errors.&lt;/p&gt;
&lt;p&gt;The fix: check &lt;code&gt;hasError&lt;/code&gt; &lt;strong&gt;at the very top of the callback&lt;/strong&gt;, before anything else. This drops all late-arriving callbacks, no matter if they’re late errors or late successes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;fetchUser(ids[i], (err, result) =&amp;gt; {
  if (hasError) return // drop everything after first error
  if (err) {
    hasError = true
    return finalCb(err)
  }
  results[i] = result
  if (++completed === ids.length) finalCb(null, results)
})&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the kind of bug that only manifests with specific input patterns (multiple errors) and specific timing. In a real system, it would show up as intermittent double-responses, corrupted state, or, in the worst case, charging a credit card twice. The lesson: &lt;strong&gt;with callbacks, every single code path through the callback must be explicitly guarded. There’s no built-in safety net.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;Why &lt;code&gt;let&lt;/code&gt; matters in the for loop&lt;/h3&gt;
&lt;p&gt;A subtle but critical detail: the loop uses &lt;code&gt;let i&lt;/code&gt;, not &lt;code&gt;var i&lt;/code&gt;. With &lt;code&gt;let&lt;/code&gt;, each iteration gets its own binding of &lt;code&gt;i&lt;/code&gt; captured by the closure. With &lt;code&gt;var&lt;/code&gt;, every callback would close over the &lt;strong&gt;same&lt;/strong&gt; &lt;code&gt;i&lt;/code&gt; variable (which would be &lt;code&gt;5&lt;/code&gt; by the time any callback fires), and you’d write every result to &lt;code&gt;results[5]&lt;/code&gt;. Use &lt;code&gt;let&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Exercise 4: Limited parallel execution&lt;/h2&gt;
&lt;p&gt;This is the hardest exercise and the most important one. It’s where the &lt;code&gt;TaskQueue&lt;/code&gt; class in the book comes from, and it’s the pattern behind database connection pools, &lt;code&gt;http.Agent&lt;/code&gt; &lt;code&gt;maxSockets&lt;/code&gt;, worker pools, and every other concurrency limiter you’ll encounter in production.&lt;/p&gt;
&lt;h3&gt;Why concurrency limits exist&lt;/h3&gt;
&lt;p&gt;In Exercise 3, we ran 5 fetches in parallel. That’s fine. But what if you had 1000?&lt;/p&gt;
&lt;p&gt;Every concurrent operation holds real resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;File descriptors&lt;/strong&gt; - each open socket, file, or pipe is a file descriptor in Unix. Your process has a hard cap (&lt;code&gt;ulimit -n&lt;/code&gt;, commonly 1024, for my MacBook it’s 2048). Opening one past the limit crashes with &lt;code&gt;EMFILE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database connections&lt;/strong&gt; - each connection is a live TCP socket plus a backend process/thread on the DB server. Postgres defaults to &lt;code&gt;max_connections = 100&lt;/code&gt;. Hit that limit and your next connection attempt fails (and you’re not the only client).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Memory&lt;/strong&gt; - each in-flight operation has buffers, closures, and intermediate state sitting in RAM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remote rate limits&lt;/strong&gt; - the server you’re talking to has its own caps. Hammer it hard enough and you get 429s, 503s, or bans.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bandwidth&lt;/strong&gt; - 1000 concurrent downloads through a shared pipe each get 1/1000th of the bandwidth, so they all finish slowly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s also a less obvious reason: &lt;strong&gt;throughput is not a monotonic function of concurrency.&lt;/strong&gt; For most real workloads, throughput rises as you add concurrency up to some optimum (where your bottleneck resource is fully utilized), then &lt;em&gt;falls&lt;/em&gt; as contention overhead outpaces the gains. A hundred workers fighting over one lock get less done than ten workers doing the same job.&lt;/p&gt;
&lt;p&gt;The limited-parallel pattern isn’t a Node quirk. It’s the right shape for talking to any finite resource, in any language.&lt;/p&gt;
&lt;h3&gt;The challenge&lt;/h3&gt;
&lt;p&gt;Write &lt;code&gt;fetchUsersWithConcurrency(ids, concurrency, finalCb)&lt;/code&gt;, same contract as before, but at most &lt;code&gt;concurrency&lt;/code&gt; fetches may be in flight at any time.&lt;/p&gt;
Hints&lt;p&gt;You need four state variables:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;results&lt;/code&gt; - pre-allocated &lt;code&gt;new Array(ids.length)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;nextIndex&lt;/code&gt; - the index of the next id to fetch (starts at 0)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;running&lt;/code&gt; - how many fetches are currently in flight (starts at 0)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed&lt;/code&gt; - how many have finished successfully (starts at 0)&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;And one internal helper, &lt;code&gt;tryNext()&lt;/code&gt;, with a &lt;code&gt;while&lt;/code&gt; loop:&lt;/p&gt;&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;while (running &amp;lt; concurrency AND nextIndex &amp;lt; ids.length AND not hasError):
    capture nextIndex into a local variable
    increment nextIndex and running
    call fetchUser, and in the callback:
        decrement running
        handle errors (with hasError guard)
        write result to results[i]
        check if completed === ids.length
        call tryNext() to fill the freed slot&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;while&lt;/code&gt; loop handles both “first call launches a burst” and “subsequent calls top up one at a time.”&lt;/p&gt;
&lt;h3&gt;My solution&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const fetchUsersWithConcurrency = (ids, concurrency, finalCb) =&amp;gt; {
  const results = new Array(ids.length)
  let nextIndex = 0
  let running = 0
  let completed = 0
  let hasError = false

  const tryNext = () =&amp;gt; {
    while (running &amp;lt; concurrency &amp;amp;&amp;amp; nextIndex &amp;lt; ids.length &amp;amp;&amp;amp; !hasError) {
      const i = nextIndex
      nextIndex++
      running++

      fetchUser(ids[i], (err, result) =&amp;gt; {
        running--
        if (hasError) return
        if (err) {
          hasError = true
          return finalCb(err)
        }
        results[i] = result
        if (++completed === ids.length) return finalCb(null, results)
        tryNext()
      })
    }
  }

  tryNext()
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With logging added to &lt;code&gt;fetchUser&lt;/code&gt;, the output with 20 ids and concurrency 3:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;  → start fetch 1
  → start fetch 2
  → start fetch 3
  ← done   fetch 1
  → start fetch 4
  ← done   fetch 3
  → start fetch 5
  ← done   fetch 2
  → start fetch 6
  ...
  ← done   fetch 19
  ← done   fetch 20
limited-parallel: 1.765s
Fetched 20 users&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can see the rhythm: exactly 3 &lt;code&gt;→ start&lt;/code&gt; lines before any &lt;code&gt;← done&lt;/code&gt;, then each completion immediately triggers a new start. The pipeline stays full.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Timing&lt;/strong&gt;: 1.765s for 20 tasks with concurrency 3. Compare:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Fully parallel (concurrency = 20): ~400ms&lt;/li&gt;
&lt;li&gt;Fully sequential (concurrency = 1): ~5s&lt;/li&gt;
&lt;li&gt;Limited parallel (concurrency = 3): ~1.8s&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Limited parallel sits between the two - trading throughput for resource bounds. Real systems use this every day.&lt;/p&gt;
&lt;h3&gt;What I learned: &lt;code&gt;nextIndex&lt;/code&gt; vs &lt;code&gt;completed&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;My first version used &lt;code&gt;completed &amp;lt; ids.length&lt;/code&gt; as the scheduling guard instead of &lt;code&gt;nextIndex &amp;lt; ids.length&lt;/code&gt;. It seemed equivalent: “keep going until we’re done.” It wasn’t.&lt;/p&gt;
&lt;p&gt;The difference:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nextIndex&lt;/code&gt; tracks how many tasks have been &lt;strong&gt;kicked off&lt;/strong&gt; (scheduled)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed&lt;/code&gt; tracks how many tasks have &lt;strong&gt;finished&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you’ve scheduled all 20 tasks but only 17 have completed, &lt;code&gt;completed &amp;lt; ids.length&lt;/code&gt; is still true, so the &lt;code&gt;while&lt;/code&gt; loop enters and tries to schedule task #21, which is &lt;code&gt;ids[20]&lt;/code&gt;, which is &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here’s the trace from my buggy version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;  ← done   fetch 17
  → start fetch undefined    ← phantom task!
  ← done   fetch 19
  → start fetch undefined    ← another one!
  ← done   fetch undefined
limited-parallel: 1.791s
Fetched 21 users               ← wrong count!&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;→ start fetch undefined&lt;/code&gt; lines are phantom tasks because the scheduler tried to fetch beyond the end of the array. The &lt;code&gt;Fetched 21 users&lt;/code&gt; is because &lt;code&gt;results[20]&lt;/code&gt; was written by a phantom fetch, extending the array past its original length.&lt;/p&gt;
&lt;p&gt;The fix: use &lt;code&gt;nextIndex &amp;lt; ids.length&lt;/code&gt; in the scheduling guard. “Should I schedule more?” is strictly about the input queue, not about the state of the world.&lt;/p&gt;
&lt;p&gt;Every task flows through three states:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;       unscheduled         →    in flight    →    completed
(ids.length - nextIndex)        (running)        (completed)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;while&lt;/code&gt; loop moves tasks from unscheduled to in flight. Its guard must ask “is there anything left &lt;strong&gt;to schedule&lt;/strong&gt;?” (&lt;code&gt;nextIndex &amp;lt; ids.length&lt;/code&gt;), not “is everything &lt;strong&gt;done&lt;/strong&gt;?” (&lt;code&gt;completed &amp;lt; ids.length&lt;/code&gt;).&lt;/p&gt;
&lt;h2&gt;Wrapping up Part 1&lt;/h2&gt;
&lt;p&gt;Here’s what I had to manually juggle with callbacks across these four exercises:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Rule&lt;/th&gt;&lt;th&gt;What goes wrong if you break it&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Both code paths must be async (no Zalgo)&lt;/td&gt;&lt;td&gt;Callers can’t predict whether their code runs before or after the callback&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;return&lt;/code&gt; after calling &lt;code&gt;finalCb(err)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The success path also fires, leading to double result&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;hasError&lt;/code&gt; flag at the top of every callback&lt;/td&gt;&lt;td&gt;&lt;code&gt;finalCb&lt;/code&gt; fires multiple times on concurrent errors&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;nextIndex&lt;/code&gt; (not &lt;code&gt;completed&lt;/code&gt;) for scheduling&lt;/td&gt;&lt;td&gt;Phantom tasks scheduled beyond the end of the array&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;finalCb&lt;/code&gt; called exactly once per invocation&lt;/td&gt;&lt;td&gt;Consumers see duplicate responses, corrupted state&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;Every one of these is a &lt;strong&gt;rule you, the programmer, must remember and implement correctly&lt;/strong&gt;. The language doesn’t enforce any of them. A missing &lt;code&gt;return&lt;/code&gt;, a misplaced flag check, or the wrong counter in a guard, any one of these can cause a bug that only surfaces under specific input patterns and specific timing.&lt;/p&gt;
&lt;p&gt;In &lt;a href=&quot;https://www.nop33.com/blog/from-callbacks-to-promises-nodejs&quot;&gt;Part 2&lt;/a&gt;, I rebuild all four exercises with promises and async/await. Every rule in the table above becomes a language-enforced guarantee that you couldn’t violate if you tried. The code gets shorter, the bugs get impossible, and the same patterns become almost trivially simple to express.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/mastering-async-callbacks-nodejs/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>Node.js</category><category>JavaScript</category><category>Async</category><category>Tutorial</category><enclosure url="https://www.nop33.com/_astro/cover.CsFdPjgz_OdLnm.webp" length="0" type="image/webp"/></item><item><title>Syncing Obsidian Across Devices with Syncthing and a Home Server</title><link>https://www.nop33.com/blog/syncing-obsidian-with-syncthing/</link><guid isPermaLink="true">https://www.nop33.com/blog/syncing-obsidian-with-syncthing/</guid><description>A step-by-step guide to setting up Syncthing on a Proxmox home server, macOS, and Android to sync an Obsidian vault - no cloud services required.</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I use &lt;a href=&quot;https://obsidian.md/&quot;&gt;Obsidian&lt;/a&gt; for all my notes: projects, recipes, finances, travel plans, everything. OK, that’s a lie. I use Notion as well. I’ve been trying to migrate over to Obsidian for years. The main reason why I haven’t yet done it is a seamless sync between my MacBook and my Android phone without relying on a cloud service. Obsidian Sync exists, but I already have a home server running 24/7. So, why not use it?&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://syncthing.net/&quot;&gt;Syncthing&lt;/a&gt; is an open-source, peer-to-peer file synchronization tool. No central server, no account, end-to-end encrypted. Exactly what I needed.&lt;/p&gt;
&lt;h2&gt;My setup&lt;/h2&gt;
&lt;p&gt;My home server is an HP EliteDesk running &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment/overview&quot;&gt;Proxmox VE&lt;/a&gt;. Inside it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LXC 100&lt;/strong&gt; - &lt;a href=&quot;https://caddyserver.com/&quot;&gt;Caddy&lt;/a&gt; reverse proxy (&lt;code&gt;192.168.21.51&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VM 101&lt;/strong&gt; - &lt;code&gt;production-docker&lt;/code&gt; (&lt;code&gt;192.168.21.52&lt;/code&gt;) running Docker with a UFW firewall&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I already use this setup to host &lt;a href=&quot;https://github.com/janeczku/calibre-web&quot;&gt;Calibre Web&lt;/a&gt; for my ebook library and &lt;a href=&quot;https://umami.is/&quot;&gt;Umami&lt;/a&gt; for website analytics. A 40 GB virtual drive called “Dionysus” (thanks Yann), mounted at &lt;code&gt;/dionysus&lt;/code&gt;, stores all persistent data.&lt;/p&gt;
&lt;p&gt;The plan: run Syncthing as a Docker container on VM 101, store the vault on Dionysus alongside my Calibre library, and sync to my MacBook and phone. The server acts as an always-on hub. Even if my laptop is asleep when I edit a note on my phone, the changes flow through the server and sync to the MacBook when it wakes up.&lt;/p&gt;
&lt;h2&gt;Step 1: Syncthing on the server&lt;/h2&gt;
&lt;p&gt;I created directories for the Syncthing config and the vault data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p /home/ilias/docker/syncthing/config
mkdir -p /dionysus/obsidian/vault&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The config lives alongside my other Docker services (&lt;code&gt;/home/ilias/docker/syncthing/&lt;/code&gt;), while the actual vault data goes on the Dionysus drive.&lt;/p&gt;
&lt;p&gt;Here’s the &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  syncthing:
    image: syncthing/syncthing:latest
    container_name: syncthing
    hostname: hp-elitedesk-syncthing
    environment:
      - PUID=1000
      - PGID=1000
    volumes:
      - ./config:/var/syncthing/config
      - /dionysus/obsidian/vault:/var/syncthing/data
    network_mode: host
    restart: unless-stopped
    healthcheck:
      test: curl -fkLsS -m 2 127.0.0.1:8384/rest/noauth/health | grep -o --color=never OK || exit 1
      interval: 1m
      timeout: 10s
      retries: 3&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;network_mode: host&lt;/code&gt; is important: Docker’s default bridge networking breaks Syncthing’s local device discovery. Without it, devices on the same LAN won’t find each other automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /home/ilias/docker/syncthing
docker compose up -d&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 2: Firewall rules&lt;/h2&gt;
&lt;p&gt;Syncthing needs a few ports open on the VM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo ufw allow 22000/tcp comment &apos;Syncthing sync&apos;
sudo ufw allow 22000/udp comment &apos;Syncthing QUIC sync&apos;
sudo ufw allow 21027/udp comment &apos;Syncthing local discovery&apos;
sudo ufw allow from 192.168.21.0/24 to any port 8384 proto tcp comment &apos;Syncthing GUI (LAN only)&apos;&lt;/code&gt;&lt;/pre&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Port&lt;/th&gt;&lt;th&gt;Protocol&lt;/th&gt;&lt;th&gt;Purpose&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;22000&lt;/td&gt;&lt;td&gt;TCP+UDP&lt;/td&gt;&lt;td&gt;Sync protocol + QUIC&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;21027&lt;/td&gt;&lt;td&gt;UDP&lt;/td&gt;&lt;td&gt;Local discovery&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;8384&lt;/td&gt;&lt;td&gt;TCP&lt;/td&gt;&lt;td&gt;Web GUI (LAN only)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The GUI is restricted to the local network. For remote access, I use Caddy (more on that later).&lt;/p&gt;
&lt;h2&gt;Step 3: Secure the GUI&lt;/h2&gt;
&lt;p&gt;The Syncthing web GUI is available at &lt;code&gt;http://192.168.21.52:8384&lt;/code&gt;. First thing: set a username and password under &lt;strong&gt;Actions&lt;/strong&gt; &amp;gt; &lt;strong&gt;Settings&lt;/strong&gt; &amp;gt; &lt;strong&gt;GUI&lt;/strong&gt;. Then note the &lt;strong&gt;Device ID&lt;/strong&gt;. You’ll need it to connect other devices.&lt;/p&gt;
&lt;h2&gt;Step 4: macOS setup&lt;/h2&gt;
&lt;p&gt;On my MacBook:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;brew install syncthing
brew services start syncthing&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The GUI opens at &lt;code&gt;http://127.0.0.1:8384&lt;/code&gt;. I added the server’s Device ID via &lt;strong&gt;+ Add Remote Device&lt;/strong&gt;, then accepted the connection on the server side.&lt;/p&gt;
&lt;h2&gt;Step 5: Share the vault&lt;/h2&gt;
&lt;p&gt;On the server GUI, I created a shared folder:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Folder Label&lt;/strong&gt;: &lt;code&gt;Obsidian Vault&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Folder ID&lt;/strong&gt;: &lt;code&gt;obsidian-vault&lt;/code&gt; (must match exactly on all devices)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Folder Path&lt;/strong&gt;: &lt;code&gt;/var/syncthing/data&lt;/code&gt; (maps to &lt;code&gt;/dionysus/obsidian/vault&lt;/code&gt; on the host)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Folder Type&lt;/strong&gt;: Send &amp;amp; Receive&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;File Versioning&lt;/strong&gt;: Staggered (keeps recent versions frequently, older versions at increasing intervals. This is a safety net against accidental deletions propagating everywhere)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the MacBook I accepted the folder and pointed it to &lt;code&gt;~/ObsidianVault&lt;/code&gt;. Since the vault already existed on my MacBook, Syncthing detected the matching files and skipped re-transferring them.&lt;/p&gt;
&lt;h2&gt;Step 6: Android setup&lt;/h2&gt;
&lt;p&gt;The original Syncthing Android app is deprecated. Install &lt;strong&gt;&lt;a href=&quot;https://github.com/catfriend1/syncthing-android&quot;&gt;Syncthing-Fork&lt;/a&gt;&lt;/strong&gt; (by catfriend1) from &lt;a href=&quot;https://f-droid.org/&quot;&gt;F-Droid&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Critical Android settings:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Disable battery optimization&lt;/strong&gt;: Android Settings &amp;gt; Apps &amp;gt; Syncthing-Fork &amp;gt; Battery &amp;gt; Unrestricted. Without this, Android kills the app in the background and sync stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grant All Files Access&lt;/strong&gt;: needed on Android 11+ to sync files outside the app’s own directory.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then add the server’s Device ID, accept on the server side, and accept the shared folder pointing to &lt;code&gt;/storage/emulated/0/ObsidianVault&lt;/code&gt;. Install Obsidian from the Play Store and open the vault from that path.&lt;/p&gt;
&lt;h2&gt;Step 7: The .stignore file&lt;/h2&gt;
&lt;p&gt;Syncthing’s &lt;code&gt;.stignore&lt;/code&gt; file works like &lt;code&gt;.gitignore&lt;/code&gt;. It tells Syncthing which files to skip. This file is &lt;strong&gt;not synced&lt;/strong&gt; between devices, so you need to create it on each one separately, in the root of the vault.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;// Obsidian workspace state (device-specific, changes constantly)
.obsidian/workspace.json
.obsidian/workspace-mobile.json

// OS junk
.DS_Store
Thumbs.db
._*

// Temp/lock files
*.tmp
~*&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;workspace.json&lt;/code&gt; ignore is the most important one. This file changes every time you click anything in Obsidian. Without ignoring it, you’d get constant sync activity and frequent conflicts.&lt;/p&gt;
&lt;h2&gt;Step 8: Remote access&lt;/h2&gt;
&lt;h3&gt;Direct sync from anywhere&lt;/h3&gt;
&lt;p&gt;On my local network, devices connect directly. But away from home, Syncthing falls back to relay servers. They are encrypted, but slower. To enable direct connections from anywhere, I added a port forwarding rule on my Unifi UDR7 router:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Port&lt;/strong&gt;: 22000 (TCP + UDP)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forward to&lt;/strong&gt;: &lt;code&gt;192.168.21.52:22000&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Web GUI via Caddy&lt;/h3&gt;
&lt;p&gt;I already have Caddy reverse-proxying other self-hosted services. Adding the Syncthing GUI was one more block in the Caddyfile:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;syncthing.example.com {
        header {
                X-Content-Type-Options nosniff
                X-Frame-Options DENY
                X-XSS-Protection &quot;1; mode=block&quot;
        }
        reverse_proxy 192.168.21.52:8384 {
                header_up Host {upstream_hostport}
                header_up X-Scheme https
        }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After adding a DNS record in Cloudflare and reloading Caddy, the GUI was accessible from anywhere.&lt;/p&gt;
&lt;h2&gt;The result&lt;/h2&gt;
&lt;p&gt;I now have my Obsidian vault syncing across three devices with no cloud dependency:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Home server&lt;/strong&gt; - always-on hub, stores the vault on a dedicated virtual drive&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MacBook&lt;/strong&gt; - full bidirectional sync&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android phone&lt;/strong&gt; - full bidirectional sync&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Editing a note on my phone while on the go, then picking up right where I left off on my laptop at home. It just works! And if anything goes wrong, staggered file versioning keeps old versions around for recovery.&lt;/p&gt;
&lt;p&gt;Total cost: a bit of time setting it up, and zero monthly fees.&lt;/p&gt;
&lt;p&gt;I wonder, however, how much battery the Syncthing-Fork app will consume on my phone. To be continued…&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/syncing-obsidian-with-syncthing/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>Syncthing</category><category>Obsidian</category><category>Self-hosting</category><category>Proxmox</category><category>Docker</category><enclosure url="https://www.nop33.com/_astro/cover.CRqByt8O_ZlMHca.webp" length="0" type="image/webp"/></item><item><title>How I Shrunk a Blockchain SDK by 88%: Modernizing @alephium/web3</title><link>https://www.nop33.com/blog/shrinking-alephium-web3-sdk/</link><guid isPermaLink="true">https://www.nop33.com/blog/shrinking-alephium-web3-sdk/</guid><description>A deep dive into modernizing the @alephium/web3 TypeScript SDK - replacing polyfills, consolidating crypto libraries, fixing the build system, and cutting bundle size from 742 kB to 207 kB.</description><pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://github.com/alephium/alephium-web3&quot;&gt;&lt;code&gt;@alephium/web3&lt;/code&gt; TypeScript SDK&lt;/a&gt; is how developers connect to and interact with the Alephium blockchain. It’s similar to what &lt;a href=&quot;https://viem.sh/&quot;&gt;viem&lt;/a&gt; is for Ethereum. It provides utilities like address validation, transaction signing, smart contract interaction, and API communication.&lt;/p&gt;
&lt;p&gt;But it had a problem. A big, 742 kB problem.&lt;/p&gt;
&lt;h2&gt;The starting point&lt;/h2&gt;
&lt;p&gt;A developer who imported a single function from our SDK, say, &lt;code&gt;isValidAddress&lt;/code&gt;, paid for the &lt;strong&gt;entire library&lt;/strong&gt;. A plain website with one text input and one SDK call produced a &lt;strong&gt;742 kB JavaScript bundle&lt;/strong&gt;. A React app? &lt;strong&gt;928 kB&lt;/strong&gt;. Getting it to work in React Native required &lt;strong&gt;12 workarounds&lt;/strong&gt; including custom Metro resolvers, native crypto modules, and a dev build (Expo Go wouldn’t work).&lt;/p&gt;
&lt;p&gt;The root causes were typical of TypeScript libraries built a few years ago:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No ESM output&lt;/strong&gt; - strictly CommonJS, defeating tree-shaking&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monolithic UMD browser bundle&lt;/strong&gt; - webpack squashed everything into one opaque file&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heavy Node.js polyfills&lt;/strong&gt; - &lt;code&gt;crypto-browserify&lt;/code&gt;, &lt;code&gt;stream-browserify&lt;/code&gt;, &lt;code&gt;buffer&lt;/code&gt; shipped as dependencies&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redundant crypto libraries&lt;/strong&gt; - both &lt;code&gt;elliptic&lt;/code&gt; and &lt;code&gt;@noble/secp256k1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A global side effect&lt;/strong&gt; - &lt;code&gt;BigInt.prototype.toJSON&lt;/code&gt; was monkey-patched on import, making &lt;code&gt;&quot;sideEffects&quot;: false&lt;/code&gt; impossible&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Before I touched any code: benchmarking&lt;/h2&gt;
&lt;p&gt;Before changing anything, I built four benchmark apps. The idea was to have the same code running in Node.js, a Vite website, a Vite + React webapp, and an Expo (React Native) app. Each one imported &lt;code&gt;isValidAddress&lt;/code&gt;, validated an address, and fetched a balance. This gave me concrete “before” numbers to measure against.&lt;/p&gt;
&lt;p&gt;The Expo app was the most revealing. Getting it to work required: a custom entry point, a Buffer polyfill, &lt;code&gt;react-native-quick-crypto&lt;/code&gt; (because &lt;code&gt;crypto-browserify&lt;/code&gt; crashed at module evaluation time), &lt;code&gt;readable-stream&lt;/code&gt;, &lt;code&gt;path-browserify&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;, &lt;code&gt;process&lt;/code&gt;, an empty &lt;code&gt;fs&lt;/code&gt; shim, a custom Metro resolver to bypass the UMD bundle (which referenced &lt;code&gt;self&lt;/code&gt;, undefined in React Native), &lt;code&gt;extraNodeModules&lt;/code&gt; for 6 builtins, &lt;code&gt;node-linker=hoisted&lt;/code&gt; for pnpm, and &lt;code&gt;expo-dev-client&lt;/code&gt; because Expo Go couldn’t handle the native crypto module.&lt;/p&gt;
&lt;p&gt;That’s 12 workarounds just to call one function.&lt;/p&gt;
&lt;h2&gt;Phase 1: The diet&lt;/h2&gt;
&lt;p&gt;The first phase was about removing what we no longer need, without changing the build system.&lt;/p&gt;
&lt;h3&gt;Bumping Node.js to &amp;gt;= 20&lt;/h3&gt;
&lt;p&gt;Node 14 was the minimum. Node 14 is EOL. Bumping to 20 unlocked native &lt;code&gt;fetch&lt;/code&gt; (goodbye &lt;code&gt;cross-fetch&lt;/code&gt;) and &lt;code&gt;globalThis.crypto.subtle&lt;/code&gt; (goodbye Node &lt;code&gt;crypto&lt;/code&gt; imports). I originally tried Node &amp;gt;= 18 but discovered that &lt;code&gt;crypto.subtle&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; on Node 18 in non-secure contexts. But since Node 18 is also EOL, Node 20 was a better choice.&lt;/p&gt;
&lt;h3&gt;Consolidating on @noble&lt;/h3&gt;
&lt;p&gt;The SDK used both &lt;code&gt;elliptic&lt;/code&gt; (legacy, huge, pulls in &lt;code&gt;bn.js&lt;/code&gt;) and &lt;code&gt;@noble/secp256k1&lt;/code&gt; (modern, audited, zero-dependency). I replaced &lt;code&gt;elliptic&lt;/code&gt; with &lt;code&gt;@noble/secp256k1&lt;/code&gt; for secp256k1, added &lt;code&gt;@noble/curves&lt;/code&gt; for P-256 and Ed25519, and replaced &lt;code&gt;blakejs&lt;/code&gt; with &lt;code&gt;@noble/hashes/blake2b&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For the wallet package, I replaced &lt;code&gt;bip39&lt;/code&gt; with &lt;code&gt;@scure/bip39&lt;/code&gt; and &lt;code&gt;bip32&lt;/code&gt; with &lt;code&gt;@scure/bip32&lt;/code&gt;, both from the same &lt;code&gt;@noble&lt;/code&gt;/&lt;code&gt;@scure&lt;/code&gt; ecosystem that viem uses. This eliminated the entire &lt;code&gt;noble-wrapper.ts&lt;/code&gt; adapter file that existed solely to bridge &lt;code&gt;bip32&lt;/code&gt;’s API with &lt;code&gt;@noble&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Removing the BigInt monkey-patch&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;BigInt.prototype.toJSON&lt;/code&gt; mutation was a global side effect. Any consumer importing anything from the SDK got this prototype modification. It made declaring &lt;code&gt;&quot;sideEffects&quot;: false&lt;/code&gt; impossible, which in turn made tree-shaking impossible.&lt;/p&gt;
&lt;p&gt;I replaced it with a &lt;code&gt;stringify&lt;/code&gt; utility (inspired by viem’s approach), which is a drop-in &lt;code&gt;JSON.stringify&lt;/code&gt; replacement that converts BigInt to strings via a replacer function. The swagger-typescript-api template was updated to use &lt;code&gt;stringify&lt;/code&gt; automatically in the generated TypeScript definition API files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependencies went from 11 to 6&lt;/strong&gt; for &lt;code&gt;@alephium/web3&lt;/code&gt;, and from 8 to 4 for &lt;code&gt;@alephium/web3-wallet&lt;/code&gt;. Huge win already 💪&lt;/p&gt;
&lt;h2&gt;Phase 2: The build system journey&lt;/h2&gt;
&lt;p&gt;This is where things got interesting and where I learnt the most.&lt;/p&gt;
&lt;h3&gt;Attempt 1: tsup&lt;/h3&gt;
&lt;p&gt;My first idea was to use tsup. tsup uses esbuild under the hood and promises dead-simple dual CJS/ESM output. I got it working, but hit problems:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;bundle: true&lt;/code&gt;&lt;/strong&gt; inlined all dependencies into a single file, which is the opposite of what I wanted for tree-shaking&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;bundle: false&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;.cjs&lt;/code&gt;/&lt;code&gt;.mjs&lt;/code&gt; extensions broke Node’s CJS directory resolution (&lt;code&gt;require(&apos;./api&apos;)&lt;/code&gt; doesn’t check &lt;code&gt;.cjs&lt;/code&gt; files)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;bundle: false&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;.js&lt;/code&gt;/&lt;code&gt;.mjs&lt;/code&gt; extensions broke Vite’s dev server because bare import paths like &lt;code&gt;&quot;./api&quot;&lt;/code&gt; in &lt;code&gt;.mjs&lt;/code&gt; files resolved to &lt;code&gt;.js&lt;/code&gt; (CJS) instead of &lt;code&gt;.mjs&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Attempt 2: tsup with workarounds&lt;/h3&gt;
&lt;p&gt;I tried various combinations: explicit file paths in barrel exports, &lt;code&gt;resolve.conditions&lt;/code&gt; in Vite config, &lt;code&gt;optimizeDeps.include&lt;/code&gt;, bundled ESM with externalized deps. Each fix introduced a new problem.&lt;/p&gt;
&lt;h3&gt;The solution: tsc dual-build (like viem)&lt;/h3&gt;
&lt;p&gt;I looked at how viem does it. They don’t use tsup or any build tool wrapper. They simply use plain &lt;code&gt;tsc&lt;/code&gt; twice:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;CJS pass&lt;/strong&gt;: &lt;code&gt;tsc --module commonjs --outDir dist/_cjs&lt;/code&gt; + &lt;code&gt;{&quot;type&quot;:&quot;commonjs&quot;}&lt;/code&gt; in &lt;code&gt;dist/_cjs/package.json&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ESM pass&lt;/strong&gt;: &lt;code&gt;tsc --outDir dist/_esm&lt;/code&gt; + &lt;code&gt;{&quot;type&quot;:&quot;module&quot;,&quot;sideEffects&quot;:false}&lt;/code&gt; in &lt;code&gt;dist/_esm/package.json&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s quite a smart a clean approach 🧠&lt;/p&gt;
&lt;p&gt;All files use &lt;code&gt;.js&lt;/code&gt; extension. The nested &lt;code&gt;package.json&lt;/code&gt; determines interpretation. No extension confusion, no resolver hacks.&lt;/p&gt;
&lt;p&gt;But I hit one more issue: strict ESM resolvers (Vitest, Node.js with &lt;code&gt;&quot;type&quot;: &quot;module&quot;&lt;/code&gt;) require explicit &lt;code&gt;.js&lt;/code&gt; extensions in import paths. tsc doesn’t rewrite import specifiers - &lt;code&gt;from &apos;./api&apos;&lt;/code&gt; stays as &lt;code&gt;from &apos;./api&apos;&lt;/code&gt; in the output, which fails in strict ESM because there’s no &lt;code&gt;./api.js&lt;/code&gt; file (it’s &lt;code&gt;./api/index.js&lt;/code&gt;). The solution to this woudl require a massive refactoring of all import statements around the code base. It didn’t feel right to me to have a TS codebase full of .js imports (even though that’s what viem does).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My solution: I decided to use &lt;code&gt;tsc-alias&lt;/code&gt;&lt;/strong&gt; as a post-build tool that rewrites the ESM output to add &lt;code&gt;.js&lt;/code&gt; extensions (&lt;code&gt;./api&lt;/code&gt; → &lt;code&gt;./api/index.js&lt;/code&gt;). Source TS files stay clean. CJS output doesn’t need it. One line added to the build script.&lt;/p&gt;
&lt;h2&gt;Phase 3: Package configuration&lt;/h2&gt;
&lt;p&gt;Getting the &lt;code&gt;package.json&lt;/code&gt; right is surprisingly nuanced:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;type&quot;: &quot;commonjs&quot;,
  &quot;sideEffects&quot;: false,
  &quot;main&quot;: &quot;dist/_cjs/index.js&quot;,
  &quot;module&quot;: &quot;dist/_esm/index.js&quot;,
  &quot;types&quot;: &quot;dist/_cjs/index.d.ts&quot;,
  &quot;exports&quot;: {
    &quot;.&quot;: {
      &quot;import&quot;: {
        &quot;default&quot;: &quot;./dist/_esm/index.js&quot;,
        &quot;types&quot;: &quot;./dist/_esm/index.d.ts&quot;
      },
      &quot;require&quot;: {
        &quot;default&quot;: &quot;./dist/_cjs/index.js&quot;,
        &quot;types&quot;: &quot;./dist/_cjs/index.d.ts&quot;
      }
    },
    &quot;./api/explorer&quot;: { &quot;...&quot; },
    &quot;./api/node&quot;: { &quot;...&quot; }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each field serves a different consumer: &lt;code&gt;main&lt;/code&gt; for legacy CJS, &lt;code&gt;module&lt;/code&gt; for legacy bundlers, &lt;code&gt;types&lt;/code&gt; for TypeScript with &lt;code&gt;moduleResolution: &quot;node&quot;&lt;/code&gt;, and &lt;code&gt;exports&lt;/code&gt; for everything modern. The &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;require&lt;/code&gt; conditions each have their own &lt;code&gt;types&lt;/code&gt; entry: CJS declarations in &lt;code&gt;_cjs/&lt;/code&gt;, ESM declarations in &lt;code&gt;_esm/&lt;/code&gt;. This avoids the &lt;a href=&quot;https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md&quot;&gt;FalseCJS&lt;/a&gt; type issue.&lt;/p&gt;
&lt;p&gt;I added &lt;code&gt;publint&lt;/code&gt; and &lt;code&gt;@arethetypeswrong/cli&lt;/code&gt; to validate the configuration. Not even viem passes &lt;code&gt;attw&lt;/code&gt; for strict &lt;code&gt;node16&lt;/code&gt; ESM resolution. It’s a known limitation of dual CJS/ESM packages when tsc doesn’t rewrite import paths in &lt;code&gt;.d.ts&lt;/code&gt; files. I decided to ignore &lt;code&gt;node16&lt;/code&gt; and move forward.&lt;/p&gt;
&lt;h2&gt;TypeScript 5.9&lt;/h2&gt;
&lt;p&gt;Upgrading from TypeScript 4.9 to 5.9 across the monorepo was straightforward but gave me &lt;code&gt;moduleResolution: &quot;bundler&quot;&lt;/code&gt; which properly resolves &lt;code&gt;exports&lt;/code&gt; fields in &lt;code&gt;package.json&lt;/code&gt;. I also migrated from Jest to Vitest, which handles ESM natively. No more &lt;code&gt;transformIgnorePatterns&lt;/code&gt; hacks for &lt;code&gt;@noble&lt;/code&gt; and &lt;code&gt;@scure&lt;/code&gt; packages. However, upgrading &lt;code&gt;@noble/hashes&lt;/code&gt; and &lt;code&gt;@noble/curves&lt;/code&gt; to v2 (ESM-only) is still blocked by the dual CJS/ESM build: the CJS build uses &lt;code&gt;--moduleResolution node&lt;/code&gt; which can’t resolve subpath exports from ESM-only packages. That upgrade waits for either dropping CJS or bumping to Node 22+ with &lt;code&gt;--module nodenext&lt;/code&gt;. Node 20 reaches EOL in April 2026 (current month as of writing). So, soon I can upgrade those libraries to v2.&lt;/p&gt;
&lt;h2&gt;The results&lt;/h2&gt;
&lt;p&gt;I tested with the actual benchmark apps, using packages published to a local Verdaccio registry (exactly as consumers experience from npm):&lt;/p&gt;























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;App&lt;/th&gt;&lt;th&gt;v2.0.10&lt;/th&gt;&lt;th&gt;v3.0.0&lt;/th&gt;&lt;th&gt;Reduction&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Website (vanilla JS)&lt;/td&gt;&lt;td&gt;1,697 kB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;207 kB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;-88%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Webapp (React)&lt;/td&gt;&lt;td&gt;1,904 kB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;407 kB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;-79%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;And in the real production apps from the &lt;a href=&quot;https://github.com/alephium/alephium-frontend&quot;&gt;alephium-frontend&lt;/a&gt; monorepo:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;App&lt;/th&gt;&lt;th&gt;Before&lt;/th&gt;&lt;th&gt;After&lt;/th&gt;&lt;th&gt;Reduction&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Explorer: &lt;code&gt;index.js&lt;/code&gt; chunk&lt;/td&gt;&lt;td&gt;2,480 kB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;730 kB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;-71%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Desktop wallet&lt;/td&gt;&lt;td&gt;7,740 kB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;4,944 kB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;-36%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Mobile wallet (iOS)&lt;/td&gt;&lt;td&gt;22.5 MB&lt;/td&gt;&lt;td&gt;&lt;strong&gt;21.3 MB&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;-5.3%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The explorer also removed &lt;code&gt;rollup-plugin-node-polyfills&lt;/code&gt; since it’s no longer needed. The mobile wallet’s improvement is more modest because the SDK is a smaller fraction of the total React Native bundle.&lt;/p&gt;
&lt;p&gt;For Expo/React Native, the setup went from &lt;strong&gt;12 workarounds to 2&lt;/strong&gt;: &lt;code&gt;react-native-get-random-values&lt;/code&gt; (for &lt;code&gt;@noble/secp256k1&lt;/code&gt;) and an empty &lt;code&gt;fs&lt;/code&gt; shim (Metro resolves dynamic imports statically). &lt;strong&gt;Expo Go works&lt;/strong&gt; and no dev build required.&lt;/p&gt;
&lt;p&gt;A colleague rightfully questioned the need for an empty &lt;code&gt;fs&lt;/code&gt; shim. The reason it is needed is that the SDK uses the Node.js builtin &lt;code&gt;fs&lt;/code&gt; module for 2 functions. As long as these 2 functions are not consumed, the Vite project or the vanilla JS website project do not complain. The Metro bundler of the Expo project, however, imports everything at ones before doing the static analysis, and it throws an exception, since &lt;code&gt;fs&lt;/code&gt; is not available. Creating an empty shim solved this problem. After some more investigation, I decided to follow the isomorphic-git example, by simply providing the required method of the &lt;code&gt;fs&lt;/code&gt; module (in my case that was &lt;code&gt;readFile&lt;/code&gt;) as a parameter to these 2 functions that required the &lt;code&gt;fs&lt;/code&gt; module. That way, any consumer of those 2 functions will simply have to pass &lt;code&gt;fs.readFile&lt;/code&gt; as an argument to them. This completely eliviated the need for a shim, leading to smoother devX when integrating the SDK.&lt;/p&gt;
&lt;h2&gt;What I’d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Start with &lt;code&gt;tsc&lt;/code&gt;, not &lt;code&gt;tsup&lt;/code&gt;.&lt;/strong&gt; I spent significant time debugging tsup’s ESM resolution quirks before realizing that viem’s plain-tsc approach avoids them entirely. Build tool wrappers add convenience but also abstraction. When something breaks, you’re debugging the wrapper’s behavior, not your code.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Benchmark with published packages from day one.&lt;/strong&gt; I initially tested with &lt;code&gt;pnpm link&lt;/code&gt; and tarball installs, which behave differently from real npm installs. Setting up Verdaccio early would have caught issues sooner. Frontend development with multiple shared local packages is still pain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don’t underestimate &lt;code&gt;package.json&lt;/code&gt; complexity.&lt;/strong&gt; Getting &lt;code&gt;exports&lt;/code&gt;, &lt;code&gt;types&lt;/code&gt;, &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;module&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, and &lt;code&gt;sideEffects&lt;/code&gt; right for dual CJS/ESM is genuinely hard. Tools like &lt;code&gt;publint&lt;/code&gt; and &lt;code&gt;attw&lt;/code&gt; help, but even the reference libraries (viem) don’t pass every check.&lt;/p&gt;
&lt;h2&gt;What’s next&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drop CJS or bump to Node 22+&lt;/strong&gt;. This will unlock &lt;code&gt;@noble&lt;/code&gt; v2 / &lt;code&gt;@scure&lt;/code&gt; v2 (ESM-only packages), currently blocked by the CJS build’s &lt;code&gt;--moduleResolution node&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sub-path exports&lt;/strong&gt; - &lt;code&gt;@alephium/web3/codec&lt;/code&gt;, &lt;code&gt;@alephium/web3/address&lt;/code&gt;, etc. for even more granular tree-shaking&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full roadmap, benchmark data, and reference apps are available in the &lt;a href=&quot;https://github.com/nop33/web3-rewrite&quot;&gt;web3-rewrite repo&lt;/a&gt;. Lastly, I wrote a &lt;a href=&quot;https://github.com/alephium/alephium-web3/blob/94400aae1542255233f253629bc76873f5e81876/packages/web3/MIGRATION.md&quot;&gt;migration guide&lt;/a&gt; that explains how to go from v2 to v3.&lt;/p&gt;&lt;hr/&gt;&lt;p&gt;&lt;em&gt;Originally published at &lt;a href=&quot;https://www.nop33.com/blog/shrinking-alephium-web3-sdk/&quot;&gt;nop33.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;</content:encoded><dc:creator>Ilias Trichopoulos</dc:creator><category>TypeScript</category><category>SDK</category><category>Blockchain</category><category>Performance</category><category>Tree-shaking</category><enclosure url="https://www.nop33.com/_astro/cover.vVo-hGrC_Z2gUEcs.webp" length="0" type="image/webp"/></item></channel></rss>