Skip to main content

Cutting ~7 MB off a React Native JS bundle — five techniques and one negative result

· 42 min read

Over a few weeks I shipped a series of small PRs that took about 7.3 MB off the production Android JavaScript bundle of a large React Native app I work on. Roughly −20% of what we ship, all measured against the same main branch each PR was opened from.

The point of writing this up is partly to share the techniques (none of them are clever, all of them are mostly mechanical), and partly to talk about the one I was sure would land a big number and instead saved 16 KB. That negative result is the most useful one I came out of the workstream with.

Five working techniques in this post, plus the negative result at the end:

TechniqueΔ rawΔ raw %
Strip debug surfaces (Storybook + a few smaller)~2.6 MB~7.2%
Dedupe transitive packages with Yarn resolutions~2.34 MB~6.88%
WebView track (theme-dedupe + extract to asset)~1.20 MB~3.7%
date-fns locale subpaths instead of the barrel880 KB2.75%
Fix the heavy import in an upstream package281 KB0.88%
Total (working techniques)~7.3 MB~20%
Delete a 118-export icon barrel (negative result)16 KB raw / +10 KB gz

If you only read one section, make it the last one. It's a useful corrective to the conventional wisdom about barrels.

How I measured

Same recipe across every PR. Produce a production Android bundle with Metro, then drop the JS bundle plus its source map into source-map-explorer:

NODE_ENV=production \
yarn react-native bundle --entry-file index.js --platform android \
--dev false --minify true \
--bundle-output tmp/android.bundle \
--sourcemap-output tmp/android.bundle.map
npx source-map-explorer tmp/android.bundle --no-border-checks --html tmp/treemap.html

source-map-explorer produces an HTML treemap of every reachable module in the bundle, grouped by node_modules package (with one big bucket for app code). That treemap is the single source of truth for "where are my bytes going" on Hermes-shaped React Native apps. I keep a before/after screenshot for every PR on an assets/*-reports orphan branch in the repo, so reviewers can reproduce the comparison without rebuilding.

Two things worth being deliberate about:

  • Raw vs gzipped. Raw matters because Hermes parses uncompressed bytes on cold start. Gzipped matters because that's what users actually download over the wire (and what code-push patches diff against). They usually move together, but not always, as we'll see in the negative result.
  • A threshold for "worth it". I picked 100 KB raw on Android as the floor for a real workstream entry. Below that, the call-site churn typically outweighs the win and the track gets closed.

That's the whole methodology. Everything below is "what showed up in the treemap, and what I did about it."

1. Stripping Storybook (and other debug surfaces)

The biggest cut on this app came from stripping Storybook out of production builds. About −2.33 MB, roughly −6.4% of the JS bundle, in one PR. Once the pattern was in place, I reused it for a QA-tools nav group (−116 KB), an on-device QA overlay tree (−171 KB), and a couple of smaller surfaces. Same recipe every time. Marginal cost of stripping the next surface is around twenty lines.

Before · prod bundle (no strip)app codenode_modulesDebug surfacesStorybook · QA tools · overlays~2.6 MB · ~7.2%After · prod-prod bundleapp codenode_modulesdebug aliased to no-op stubs
Conceptual sketch. The Babel module-resolver alias swaps the debug surfaces for one-line stubs in prod-prod builds, so Metro never reaches the Storybook subtree.

The __DEV__ trap

Most React Native apps end up with a few surfaces that shouldn't ship to end users but absolutely have to ship to internal release-channel builds: Storybook for designers, a QA-tools tab for testers, debug overlays. The usual advice is to gate them on __DEV__. That works for the dev/release split. It doesn't work for the prod-prod versus release-with-QA-tools split, which is the case I have to support.

Typical code:

// RootStack.tsx
{(__DEV__ || IS_QA_TOOLS_ENABLED) && (
<Stack.Screen name="Storybook" component={Storybook} />
)}

Two values, two completely different behaviours:

  • __DEV__ is a Babel-time constant. Metro replaces it with true or false during transform, so the branch really does drop out of release bundles.
  • IS_QA_TOOLS_ENABLED comes from react-native-config, which reads .env at runtime through the native side. Metro can't statically resolve it at bundle time, so the conditional looks dynamic to the bundler, the branch stays reachable, and the import { Storybook } at the top of RootStack.tsx is a live edge in the module graph.

That live edge is enough to keep Storybook and everything it transitively pulls in (@storybook/react-native, the Storybook runtime, every *.stories.*) in the production bundle. About 1.8 MB of compressed JS behind a flag that's never going to be true in prod.

The fix: a Babel alias plus dotenv

What I actually want is a gate that resolves at bundle time, flipped per build type from CI. __DEV__ can't be it, so I add a separate, build-time-only env var read in babel.config.js:

// babel.config.js
require('dotenv').config({ quiet: true });

module.exports = api => {
const babelEnv = api.env();
const isDev = babelEnv === 'development';
const isTest = babelEnv === 'test';

const isQAToolsEnabled =
isDev || isTest || process.env.QA_TOOLS_ENABLED === 'true';

return {
presets: ['module:@react-native/babel-preset'],
overrides: [
{
exclude: /node_modules/,
plugins: [
[
'module-resolver',
{
root: ['./src'],
alias: {
...(isQAToolsEnabled
? {}
: {
'~/pages/Storybook/Storybook':
'./src/pages/Storybook/Storybook.stub.tsx',
'~/routing/groups/QAToolsGroup/QAToolsGroup':
'./src/routing/groups/QAToolsGroup/QAToolsGroup.stub.tsx',
'~/components/debug/DebugOverlay/DebugOverlay':
'./src/components/debug/DebugOverlay/DebugOverlay.stub.tsx',
}),
'~': './src',
},
},
],
],
},
],
};
};

And one tiny stub per surface:

// src/pages/Storybook/Storybook.stub.tsx
import { FC } from 'react';
export const Storybook: FC = () => null;

A few things that took some iteration to land right:

  • dotenv at the top of babel.config.js. react-native-config only reads .env on the native side. Metro and Babel never see those values unless something puts them into process.env first. The two-line require('dotenv').config() does that. CI flips .env files (yarn envs:prod vs yarn envs:prod:qa), so the build type is fully determined by which .env is in place.
  • First-match wins in babel-plugin-module-resolver. Specific aliases (~/pages/Storybook/Storybook) have to come before the prefix alias (~), or the prefix wins and the stub is never picked up. The conditional spread puts them in the right position.
  • Match the export shape exactly. Named exports must match. Default exports must default-export. Nothing changes at the call site, only the resolution target.

knip (or any dead-code tool) will flag the stub as unreachable, because nothing imports it by path. Add the stub paths to .knip.json's ignore list.

Verify both flag states

React.lazy and similar "defer it" patterns can give you a similar startup-time win without removing anything from the shipped bundle. The whole point of this technique is to ship fewer bytes, so the measurement has to confirm that. I always build both flag states and run them through source-map-explorer:

QA_TOOLS_ENABLED=false NODE_ENV=production yarn react-native bundle ... # prod-prod
QA_TOOLS_ENABLED=true NODE_ENV=production yarn react-native bundle ... # prod-with-qa

In the first treemap, Storybook is gone. In the second, it's back. If you only build one of them, you haven't actually verified that flipping the flag restores the original behaviour, and it's easy to mis-spell the alias key and silently strip nothing while the bundle gets smaller for some unrelated reason.

Numbers

Surface strippedΔ rawΔ raw %
Storybook−2.33 MB−6.4%
QA-tools nav group−116 KB−0.31%
QA-tools overlay tree−171 KB−0.52%
Total~2.6 MB~7.2%

2. Deduping packages with Yarn resolutions

The second-largest cut on this app didn't come from removing anything I wrote. It came from telling the package manager to stop installing the same library twice. About −2.34 MB off the JS bundle, −6.88%, with a six-line change in package.json.

Before · two co-resolved versions@some-sdk/*v2.17.x≈ same code@some-sdk/*v2.21.x≈ same codeAfter · one version, pinned@some-sdk/*v2.23.9 (resolutions)−2.34 MB · −6.88%
Conceptual sketch. The resolutions field forces every transitive importer to use one version of each package, so Metro packages one copy and Hermes parses it once.

The smell: a treemap with too many of the same thing

On the treemap, one external SDK family was the obvious shape: a handful of scoped sub-packages (@some-sdk/core, @some-sdk/client, @some-sdk/utils, plus a couple more) all showed up twice each, at two slightly different version numbers, with near-identical sizes. A second SDK family (@another-sdk/runtime) had the same pattern. A yarn why confirmed it:

$ yarn why @some-sdk/utils
# → @some-sdk/utils@npm:2.21.x (hoisted, used directly)
# → @some-sdk/utils@npm:2.17.x (pulled in by another upstream SDK)

Two different transitive consumers were each pinning a slightly different minor of the same family of packages. Yarn played it safe and co-resolved both versions. Metro followed the import edges and packaged both copies of every shared file. Hermes parsed both copies on cold start. None of the runtime ever simultaneously needs both — they're the same code, just at different patch versions.

This is the most underrated pattern in Node-shaped bundlers. The bundle isn't bloated because anything is big. It's bloated because the same big thing is shipped twice (or three times, or four).

The fix: a resolutions block

Yarn's resolutions field forces every transitive consumer in the workspace to use a single version, regardless of what their peerDependencies say. For this app it ended up looking roughly like this:

// package.json
"resolutions": {
"@another-sdk/runtime": "1.98.4",
"@some-sdk/client": "2.23.9",
"@some-sdk/core": "2.23.9",
"@some-sdk/runtime": "2.23.9",
"@some-sdk/types": "2.23.9",
"@some-sdk/utils": "2.23.9"
}

After yarn install, every transitive import of those packages resolves to exactly one copy. Metro sees one. Hermes parses one. The treemap collapses each family from a wide bar to a single block.

yarn dedupe --check is the canonical follow-up. It walks the lockfile and flags any package that still has more than one version installed across the graph. I run it in CI alongside the bundle size check, so any new transitive that drags in a duplicate fails the PR until it's pinned or upgraded out.

What this technique is not

resolutions is a bundle-time knob. It's the right tool when:

  • Two consumers want different patch or minor versions of the same package.
  • The package is large and lives in the JS bundle (not in native code).
  • The versions are compatible enough that pinning one of them is safe.

It's not a substitute for upgrading. If two consumers genuinely depend on incompatible major versions, forcing them onto one will break one of them at runtime. The only way to know is to smoke-test every pinned package through the real code path that uses it.

That smoke test isn't optional. I picked the most-trafficked screen each pinned family actually powers and ran the full happy path through QA on both platforms before merging. Pin alone, no smoke, no merge.

Trade-offs

Every line in the resolutions block is a tiny technical debt:

  • It pins a transitive version your direct dependencies didn't ask for, so it bypasses their own peer-dependency drift signal. If they bump a peer, you won't see the warning until you remove the pin.
  • It's invisible to most newcomers reading package.json. I keep an inline comment above each pin explaining why it exists and the link to the PR that introduced it.
  • It needs to be retired when the upstream consumers catch up. The same yarn why that motivated the pin is the test for whether the pin is still needed: if every transitive now wants the same version anyway, the pin is dead weight.

For this app, the trade-offs were obviously worth it: 2.34 MB of bundle, kept off the cold-start parse for every user, against six lines and one inline comment per line.

3. Locale subpaths instead of a barrel

The third-largest cut was almost embarrassing in how small the actual change was. A two-line edit dropped 880 KB off the bundle, around −2.75%.

Before · ~97 locales reachableAfter · 6 locales reachabledeen-GBesfritnl−880 KB · −2.75%
Conceptual sketch. The barrel statically re-exports every locale subfolder, so any single named import keeps the whole atlas in the graph. Importing each locale by subpath keeps only the six the app uses.

The fix:

// Before: one import, ~97 locales packaged
import { enGB, es, fr, it, de, nl } from 'date-fns/locale';

// After: six imports, six locales packaged
import { de } from 'date-fns/locale/de';
import { enGB } from 'date-fns/locale/en-GB';
import { es } from 'date-fns/locale/es';
import { fr } from 'date-fns/locale/fr';
import { it } from 'date-fns/locale/it';
import { nl } from 'date-fns/locale/nl';

Same six locales. Same call sites. Almost a megabyte stopped shipping to production.

Why the barrel was so expensive

date-fns/locale is a barrel. Its only job is to re-export everything from its sibling folder:

// date-fns/locale/index.js (paraphrased)
export { af } from "./af/index.js";
export { ar } from "./ar/index.js";
export { de } from "./de/index.js";
// ... ~90 more locales ...
export { zhTW } from "./zh-TW/index.js";

In a perfect tree-shaking world, import { enGB, es, fr, it, de, nl } from that barrel would compile down to those six locale folders. In the Metro + Hermes pipeline that ships React Native apps today, it doesn't. Metro evaluates the barrel to satisfy any single named import from it, and the barrel statically references every locale subfolder via top-level export { ... } from statements. So every re-export becomes a reachable module in the dependency graph. Metro packages them, Hermes parses them, and your release bundle quietly carries a full atlas of date formatting rules for languages your app doesn't even ship strings for.

The fix is to import each locale from its subpath instead. Each subpath file only exports its one locale and depends on nothing else under date-fns/locale/, so Metro packages only what those six lines name. The rest of the locale folder falls out of the graph.

Lock it in with a lint rule

Once you've done the rewrite, the only thing standing between you and the same regression next quarter is one well-intentioned auto-import. So I added a no-restricted-imports rule that bans the barrel outright:

// oxlint.config.mjs (or .eslintrc.* — same shape)
{
rules: {
'no-restricted-imports': ['error', {
paths: [
{
name: 'date-fns/locale',
message:
'Import specific locales via date-fns/locale/<code> ' +
'(e.g. date-fns/locale/en-GB). The barrel pulls in ~97 locales ' +
'and bloats prod bundles.',
},
],
}],
},
}

This is the cheapest part of the fix and easily the most important one. Without it, six months from now a different teammate types enGB, their editor auto-imports from the barrel, lint passes, tests pass, and 880 KB quietly walk back in.

Where else the same shape hides

Once you have a treemap in front of you, this pattern jumps out everywhere. Any time your app does:

import { something } from "big-library-with-many-things";

and the library exposes its things through a barrel that statically export { ... } froms every sibling, you're probably shipping every sibling. Common offenders, all with the same fix (import x from 'lib/x' instead of import { x } from 'lib'):

  • Icon sets. lucide-react-native, react-native-vector-icons/<set>, @expo/vector-icons. Barrels typically map to hundreds or thousands of components.
  • Locale or i18n data. Anything with /locale/, /locales/, /data/<lang>/ under it.
  • Utility libraries. Classic shape: import { debounce } from 'lodash' ships all of lodash; import debounce from 'lodash/debounce' ships one file.
  • Charting and viz libs. Many ship one entry per chart type.

4. Two passes over the WebView HTML

The fourth technique came from a slightly weird direction: WebView HTML. Two screens in this app render a charting library inside a <WebView />, and the cost of how we were doing that ended up being the third-biggest workstream entry — but it took two passes over the same code path to get most of the way there.

Combined across the two passes: about −1.2 MB, around −3.7% of the bundle.

Before · HTML embedded in JS bundleJS bundleapp code, node_modules, etc.WebView HTML #1dark + light variants · ~640 KBWebView HTML #2~344 KB · embedded stringAfter · HTML lives next to the app, not in itJS bundleapp code, etc.just URI stringsfor the WebViewsNative assets.generated.html~408 KB.generated.html~330 KB
Conceptual sketch. Step 1 collapses the dark/light theme variants into one HTML per WebView. Step 2 moves the (now single) HTML out of the JS bundle and into the platform's native asset registry, so Hermes never parses it.

Background: how react-native-react-bridge ships HTML

The standard react-native-react-bridge setup for one of these screens looks roughly like this:

// SomeChartWebView.tsx
import { webViewCreateRoot } from 'react-native-react-bridge/lib/web';

export default webViewCreateRoot(<SomeChartRoot />);
// Presenter.tsx
import html from './SomeChartWebView';

<WebView source={{ html }} />;

What webViewCreateRoot actually does at compile time is bundle the web-side React tree with esbuild, inline every asset (CSS, fonts, SVGs, WASM) as base64 into the JS, and replace the default export with one giant HTML string literal.

That string ends up living in two places:

  1. Inside the JS bundle. Metro embeds it in index.android.bundle. Hermes parses and byte-compiles it on cold start before any of your code runs.
  2. Across the bridge. Every time the screen mounts, the same string is serialised and copied into the native WebView via source={{ html }}.

That's the cost surface both passes attack, from different angles.

Step 1: Collapse the dark and light theme variants into one (−427 KB)

The first pass came from staring at the treemap and noticing something obvious in hindsight: the chart screen wasn't shipping one HTML blob, it was shipping two, named something like ChartWebView.dark and ChartWebView.light. Two webViewCreateRoot() calls, two near-identical compiled HTML strings, around 213 KB each. Both shipped in every prod bundle. The runtime picked one based on the user's current theme.

The two React trees were structurally identical. The only thing that differed was a theme variable threaded through the styles. So step 1 was: render one component, pass the active theme in instead.

react-native-react-bridge already has the right hook for this. The native side can send the WebView a LOAD_CONFIG message right after the WebView's "ready" signal, and the web tree reads that config before mounting its real UI. So the dark/light bit just became one more field in LOAD_CONFIG:

// presenter (simplified before)
const html = mode === 'dark' ? darkChartWebViewHtml : lightChartWebViewHtml;
return <WebView source={{ html }} />;

// presenter (after)
return (
<WebView
key={`chart-webview-${mode}`}
source={{ html: chartWebViewHtml }}
onMessage={onWebViewReady}
/>
);

// onWebViewReady (after)
webviewRef.current?.postMessage(
JSON.stringify({ type: 'LOAD_CONFIG', config: { mode } })
);

One subtle thing that bit me before it stopped biting me: the WebView needs to fully remount when the theme changes, not just receive a new LOAD_CONFIG. Otherwise the chart library renders some styles before LOAD_CONFIG arrives, and you end up with a half-themed first paint. Giving the WebView a key that includes the current theme mode is the cheapest way to force the remount. React unmounts the old WebView, the new one starts fresh, and the very first paint reads the right theme.

After deleting the second webViewCreateRoot() call and removing the dark/light branch in the presenter, the JS bundle dropped by −427 KB in a single PR. No new pipeline, no native changes — just one of the two HTML strings stopped being generated.

This is the move I'd pull first on any RN app that uses react-native-react-bridge: count the webViewCreateRoot() call sites for each screen. If you have more than one for what's morally the same screen, you're paying for it twice.

Step 2: Move the (now single) HTML out of the JS bundle (−770 KB across two screens)

With the theme variants collapsed, each of the two chart screens still shipped one ~400 KB HTML string inside the JS bundle. That's the cost surface the second pass attacks. Hermes still parses those strings on cold start before any of your code runs, and the bridge still copies them on every mount, and nothing else in the JS ever looks at them.

What I wanted instead

The same HTML, but somewhere Metro treats as an asset and Hermes never touches:

  • iOS: ship it through Metro's asset registry, the same way a PNG would go. Image.resolveAssetSource(require('./...generated.html')) returns a file://...App.app/... URL the WebView can load.
  • Android: copy it into android/app/src/main/assets/ and load it via file:///android_asset/<name>.generated.html. That's the one documented file-URL scheme Chromium WebView still accepts for in-APK files. (Image.resolveAssetSource for non-image assets on Android returns a bare resource ID with no scheme, which the WebView can't load, so the iOS approach doesn't transfer.)
  • JS bundle: knows about a tiny URI string and nothing else.

The pre-build pipeline

I keep one manifest file that lists which web entries to bundle:

// src/scripts/pre-build/webview-manifest.ts
export const ANDROID_WEBVIEW_ASSETS_DIR = 'android/app/src/main/assets';

export const WEBVIEW_MANIFEST = [
{
entry: 'modules/charts/SomeChartWebView.web-entry.tsx',
outDir: 'modules/charts',
generatedFileName: 'SomeChartWebView.generated.html',
},
] as const;

Each entry points at a *.web-entry.tsx file that calls webViewCreateRoot(...) on the React tree the WebView used to render. The pre-build script runs esbuild over every entry, wraps the output in the same HTML shell react-native-react-bridge would have produced at transform time, and writes the result to two places: colocated next to the source (iOS uses this via require()), and in android/app/src/main/assets/ (Android uses this directly).

const bundled = await build({
entryPoints: [entryFile],
bundle: true,
minify: true,
write: false,
jsx: 'automatic',
plugins: [rnrbAssetLoaders],
});
return wrapWithWebViewHTML(`(function(){${bundled.outputFiles[0].text}})()`);

rnrbAssetLoaders re-implements the same asset loaders the runtime transformer in react-native-react-bridge provides (text, CSS, base64 images, WASM). Keeping that list in sync with the upstream plugin is what makes the output byte-identical to the inlined version.

Metro, and the platform resolvers

One line in metro.config.js makes require('./...generated.html') resolve as a native asset instead of a JS source file:

module.exports = {
resolver: {
...resolver,
assetExts: [...resolver.assetExts, 'html'],
},
};

And one sibling file per platform tells the presenter where the asset lives:

// SomeChartWebView.source.ios.ts
import { Image } from 'react-native';

export const SomeChartWebViewSourceUri: string =
Image.resolveAssetSource(require('./SomeChartWebView.generated.html'))?.uri ?? '';
// SomeChartWebView.source.android.ts
export const SomeChartWebViewSourceUri =
'file:///android_asset/SomeChartWebView.generated.html';

The Android file deliberately doesn't require() the colocated HTML. If it did, Metro would re-bundle the same HTML into res/raw/ on Android, where nothing consumes it, and the APK would carry the file twice.

The presenter then swaps source={{ html: InlineHtml }} for source={{ uri: SomeChartWebViewSourceUri }}. On Android you also need to flip allowFileAccess and allowFileAccessFromFileURLs to true so the file:///android_asset/... URL actually loads.

CI check: don't ship stale HTML

The "developer edits the source, remembers to commit the regenerated HTML" arrangement breaks the moment one person forgets. So there's a sibling script that rebuilds every entry in memory and byte-diffs against both committed copies. If anyone touches a *.web-entry.tsx without committing the regenerated HTML, CI fails fast with a clean message. That's the whole verifier.

Numbers

PRΔ rawΔ raw %
Step 1: collapse dark/light theme variants−427 KB−1.32%
Step 2: first WebView to native asset−424 KB−1.38%
Step 2: second WebView to native asset−344 KB−1.16%
Combined~−1.2 MB~−3.7%

The *.generated.html files (~408 KB and ~330 KB) now live in the APK's native assets folder. aapt compresses those, so the APK delta is roughly neutral. The win is the JS bundle, plus the bridge work that no longer happens per mount, plus one fewer HTML blob to maintain.

5. Sometimes the fix is upstream

Most of the cuts so far happen inside the app. This one didn't — the actual fix landed in a separate package my team also maintains, that the app depends on transitively. Worth its own section because the shape of the problem and the only-valid-fix is genuinely different from "edit the app."

Before · upstream imports the barrelupstreampackagerequire("effect")effect~100 submodulesSchema · Either · OptionGraph · ArbitrarySTM · STMRef · STMQueueTest · TestClock · TestRandomStream · Sink · Channel…and ~90 moreAfter · per-submodule imports upstreamupstreampackageeffect/Schemaeffect/Eithereffect/Option… a few more−281 KB · app PR is just a version bump
Conceptual sketch. The offending barrel require lives in the upstream package's compiled output, so a Babel alias or resolutions pin in the app can't reach it. The actual fix is per-submodule imports in the upstream, released as a patch version.

The smell, again

Same starting point as every other section: a treemap with something heavier than it should be. In this case, the effect library had a much larger footprint than the app's actual usage of it suggested. The app only uses a handful of effect submodules — Schema, Either, Option, a couple more. The treemap showed roughly a hundred effect/* submodules in the bundle: Graph, Arbitrary, the STM* family, the Test* family, all the rest. None of them used at runtime, all of them parsed by Hermes on cold start.

A quick yarn why effect traced it: nothing in the app code imported effect directly. Every reachable edge went through an internal package that does request-routing/state-shaping for the app, also maintained by my team.

Opening that package's compiled output (the thing actually consumed in node_modules), the cause was one line:

// upstream package, compiled output (paraphrased)
const Effect = require("effect");
// ... later uses of Effect.Schema, Effect.Either, etc.

require("effect") pulls the package's main barrel. That barrel statically export { ... } froms every submodule. So the moment one consumer touched the package's compiled JS, the entire effect library became reachable in the Metro graph, and Hermes parsed all of it.

Why I couldn't fix this from the app

This is the part that makes the section worth writing. The same offending line, if it had been written in the app's own source, would have been fixable in two minutes with techniques from earlier in the post:

  • A babel-plugin-module-resolver alias mapping effect to a thinner re-export.
  • A resolutions pin on effect to whichever version exposed a leaner barrel.
  • A direct rewrite of the import to per-submodule paths.

None of those actually work when the bad import lives in compiled output shipped by another package, for two reasons:

  1. Babel only sees app source. It doesn't transform node_modules output. The alias would silently no-op.
  2. resolutions pins versions, not import shapes. The barrel exists in every version of effect. Pinning a different effect would change what the barrel resolved to, not whether the upstream package required the barrel.

You could patch the upstream's compiled file with yarn patch or patch-package, but you'd be carrying that patch indefinitely, and it would silently rot the next time the upstream package was released. Patching dependencies you also own is a smell — you're saying "the fix exists, but I don't trust myself to land it in the place it should live."

The actual fix: bump the package

The one-line change went into the upstream package, not the app:

// upstream package, source (after)
import * as Schema from "effect/Schema";
import * as Either from "effect/Either";
import * as Option from "effect/Option";
// ...

Per-submodule imports, no barrel. The upstream package's compiled output now only references the effect submodules it actually uses. Tagged a patch version. Bumped the app.

That bump was a two-character diff in package.json. The PR description in the app was three lines of body and a link to the upstream release notes. CI on the upstream package validated the change in isolation, so the app's CI didn't have to re-verify anything beyond the bundle size delta.

Result on the app: −281 KB off the JS bundle, about −0.88%.

Trade-offs

Cross-repo coordination isn't free. The fix takes:

  • A second PR on a second repo, with that repo's own review and release cadence.
  • A version bump in the app, and somebody to remember to do it.
  • Awareness that the right place to fix the problem is sometimes upstream of where it shows up.

For this app, the trade-off was obvious because my team owns both repos. The first time you hit this pattern in a package owned by another team, the playbook stays the same: open a PR on the upstream with the smallest possible fix, link it from the bundle-size investigation, and bump the dependency once it merges. Worst case, you find out the upstream isn't accepting that change and you fall back to patch-package with eyes open about the cost.

The generalisable lesson is the same one I should have had on a bigger sign over my desk: when you own the upstream, fix it there. The app PR becomes a one-line version bump that documents itself, the fix is visible to every other consumer of the package, and you stop carrying app-side workarounds that hide the actual smell.

6. And one that didn't work: deleting an icon barrel

This is the most useful section of the post, even though the win is roughly zero.

Before · barrel in place (118 re-exports)app code (270 files import icons via barrel)node_modulesicons (118 components, all reachable)After · 270 call sites rewrittenapp code (270 files, longer import paths)node_modulesicons (same 118 components)Δ: −16 KB raw, +10 KB gzipped
Conceptual sketch. Every icon is reachable from at least one call site either way, so removing the barrel only dropped the barrel file itself (~133 bytes of glue). The 270 rewritten call sites have longer import specifiers, which Hermes stores as strings in the bundle.

If you've read anything about bundle size, you've probably been told that barrel files kill tree-shaking and that you should delete every index.ts that does export *. There's a popular ESLint plugin built around the idea, blog posts, conference talks, an evergreen library-of-the-week.

So I went and tested it. I picked the heaviest single barrel in the codebase: src/components/icons/index.ts, 118 re-exports, 270 importing files. I deleted it, rewrote every call site to a direct import (a small codemod did most of the work), and measured. A couple of days of work, in fairness.

Here's the result, measured the same way as every other PR in this workstream:

PlatformBaselineAfterΔ rawΔ gzip
Android raw34,424,776 B34,408,912 B−15,864 B (−0.046%)
Android gzip8,269,128 B8,279,086 B+9,958 B (+0.12%)
iOS raw31,688,006 B31,643,450 B−44,556 B (−0.14%)
iOS gzip7,628,467 B7,635,819 B+7,352 B (+0.10%)

About 16 KB raw on Android, and +10 KB gzipped. Measurable nothing, in exchange for hundreds of touched files. Below the 100 KB floor I'd set for this workstream. Track closed.

Why the gzipped number got worse

The shape of the numbers tells the story exactly.

  • Module count in the Android graph: 16,391 → 16,390. Exactly one module dropped, which is the barrel itself.
  • module_wrapper_glue: −133 B (the barrel's own glue, gone).
  • module_dep_id_array: +732 B.

The dep-id array grew because the import specifiers in the 270 call sites are now longer (~/components/icons/BellIcon versus ~/components/icons). Hermes byte-compiles every specifier as a string in the bundle. Longer strings, more bytes. The rewritten file contents also compress slightly worse than the version that had a uniform-shaped barrel import at the top of every file, which is where the +10 KB gzipped delta comes from.

What the wisdom is actually claiming

The defensible version of "barrels kill tree-shaking" is more careful than the meme:

Barrels can prevent tree-shaking when the consumer only wants a small subset of the barrel's re-exports, and the unused re-exports point at modules with non-trivial bodies.

Both halves matter. A barrel hurts you when it makes the bundler keep otherwise-dead modules in the dependency graph. If all the re-exports point at modules that are used somewhere in the app, even if no single consumer uses all of them, then deleting the barrel changes nothing for the bundler. Every file is still reachable through some edge. The barrel was just an extra hop on the way there.

The icons case is exactly the second shape. Every one of the 118 re-exported icons was imported, directly or indirectly, by at least one screen in the app. The icons set was sized to the app's actual UI surface, not over-built. So the barrel was masking nothing, and deleting it just removed one extra module (the barrel itself) and slightly rearranged the rest.

date-fns/locale from the previous section is the other shape: package-level barrel, statically re-exporting from third-party modules with self-contained bodies, where the app only uses a small fraction. There, deleting (or rather, bypassing) the barrel saved 880 KB. Same word in the title, completely different consequence.

A small mental model

The actual question is never "is this a barrel?". The actual question is:

If I deleted the barrel, would any source files in the dependency graph become unreachable?

If yes (there are sibling modules under the barrel that no other file imports), then the barrel is masking dead code and removing it will save real bytes. Each unreachable module's body falls out of the graph.

If no (every sibling is reachable through some other call site), then the barrel is masking nothing. At best you save the size of the barrel itself, a few hundred bytes for an export { ... } from index, a few KB at most for a heavier one. The call-site rewrite costs much more than that.

A treemap plus a couple of well-targeted greps answer this question in an hour. Two hours of that would have saved me several days of call-site rewriting on the icons PR, and the result you see in the table.

What I'd do again, and what I'd do differently

Cumulative across the five working techniques in this post:

TechniqueΔ rawΔ raw %
Strip debug surfaces~2.6 MB~7.2%
Dedupe via Yarn resolutions~2.34 MB~6.88%
WebView track (theme dedupe + asset)~1.20 MB~3.7%
date-fns locale subpaths880 KB2.75%
Fix the upstream package281 KB0.88%
Total (working techniques)~7.3 MB~20%
Delete icon barrel16 KB raw / +10 KB gz

A few things I'd take to the next workstream:

  • Treemap first, intuition second. Every technique that moved the bundle was scoped from the treemap up, with the expected delta sized before I wrote any code. The one that didn't (the icons barrel) was scoped from the lore down. The treemap is never wrong about what's in the bundle; the lore often is.
  • Pick a floor and stick to it. 100 KB raw was the threshold for continuing a track. Anything below that, call-site churn outweighs the win and the track gets closed. Closing tracks early is more valuable than landing them eventually.
  • Lock wins in with lint rules. An 880 KB saving with no no-restricted-imports rule is an 880 KB saving until the next auto-import.
  • Verify both states of any toggle. For any technique that adds a build-time flag (the Babel alias strip, for example), build with the flag on and off and look at both treemaps. It's the cheapest way to confirm you didn't quietly strip nothing.
  • Sometimes the right fix is in the package, not the app. If a heavy import lives in compiled output from a package you also own, fix it there and bump. The app PR becomes a one-line version bump that documents itself.
  • Don't trust the meme. Barrels can cost you a lot or absolutely nothing depending on what's behind them. Measure the barrel, not the meme.

None of these techniques required any clever runtime work. No React.lazy, no Hermes flags, no code-splitting tricks. They're all variations on "make Metro stop packaging the bytes you don't need." Which, on a mature React Native app, is usually where the bytes you don't need have been hiding.

Animated Balloons With React Native

· 3 min read

Animated balloons are a pretty attractive way of engaging with users. They can be used to attract user’s attention by congratulating them inside your app or by simply celebrating something, for instance. In this article, we will explore how to create animated balloons with React Native.

In the following sections, we will see how to use react-native-fiesta to add some animated balloons into your application with some few lines of code.

react-native-fiesta is a library that provides a set of celebration animations built with @shopify/react-native-skia. The nice thing about this is that Skia offers high-performance 2d graphics for React Native.

Installing Dependencies

The first step is installing react-native-fiesta package which provides us with all the necessary animations for our project. To do that, we need to install it by running the following command:

yarn add react-native-fiesta

react-native-fiesta fully depends on @shopify/react-native-skia so you have to package that one as well.c You can do it with the following command:

yarn add @shopify/react-native-skia && npx pod-install

After installation has been completed, we can use the library to add the animated balloons to our app.

Adding Animation Using Fiesta

To do that, we need to import the library in one of our components, we will use the App component as a base:

import React, from 'react'
import { Balloons } from 'react-native-fiesta'

After importing the Balloons from Fiesta, we can proceed to use them:

function App() {
return <Balloons />;
}

And that’s it, You have some animated balloons in your application. You can use them to celebrate a user’s birthday, a first purchase, a new goal achieved inside the app, or any other thing you might want to celebrate. The options are endless.

Balloons

Customising Fiesta Balloons

You can also customise the theming of the balloons. Let’s say you want to have a Colombian theme. You can do that by doing the following:

<Balloons theme={["#FCD116", "#003893", "#CE1126"]} />

That’s it!

Colombian Balloons

Fiesta also provides some other themes. You can use them just by importing them:

import { Balloons, FiestaThemes } from 'react-native-fiesta'



<Balloons theme={FiestaThemes.Neon} />

And then you will have some animated balloons with some neon colours.

Neon Balloons

Conclusion

In this post, we have taken a look at how add some animated balloons in React Native using react-native-fiesta. Now you can celebrate with your users and engage with them as never before.

You might be familiar on how Twitter celebrates a birthday in their platform. These balloons are inspired by that.

Travelling & Coding

· 7 min read

In the last couple of years I have been able to travel to multiple countries thanks to programming and also thanks to some companies that have allowed me to work fully remote.

However, not always that I code I do it for companies, I do also enjoy doing some personal projects as well as open sourcing and contributing. That's why I have some fun while doing it, because of my side projects. Without them, there is no really much motivation to just work on your daily things.

Offline Work

Working offline can be benefitial if you are in places with really poor connection or also in case you are in a flight, a train without internet or in a ship in the middle of the ocean and you don't want to pay for internet there.

There are some projects that allow you to work fully offline. I'm not talking about projects company wise but about code projects.

If you have a project which you can fully install locally A.K.A a full-stack, db, server and front-end then you have gold there. Why? You would think this is not really nice, but whilst you are killing time somehow you gain some extra focus (perhaps that's only my case, but there is a change that it is for you too).

Being able to work offline has the advantage of being very productive whilst that's the only thing you can do. In my case because I mostly travel solo that works pretty well. Now, if you are with friends, couple or family, please try to enjoy with them too and don't go to travel just to code. Do it when they are resting or something, respect your company.

Disadvantages; not always is that nice if you are stuck with a bug and you need to search something. In that case being offline sucks because the first thing you want to do is checking Stack Overflow but because you are offline, it's most likely that you will get stuck. In that case, try to switch to something else if you can, or if you can't switch to something else, just go to another project either from your company or personal, open source, e.t.c...

Killing Time

Sometimes you are in places where you have everything you need to code and unfortunately you have to be there just waiting. A.K.A airports (most of the times).

If you are reading this you're quite lucky, coding at an airport is fun because you are still in mainland and you can get internet either from there or from your own, you can also charge your laptop, phones and whatever you need but most importantly, you can get some coffee and food, that's right, please grab something to eat before coding and get fully focused :)

Sometimes it is unfortunate and your flight can get delays. Lucky you, you had something to do so who cares, as long as the delay doesn't affect your final plans everything should be good, you just gained some productivity hours there.

Extra Focus

A clear example of extra focus is this, I wrote this in about an hour whilst travelling from Rhodes to Rotterdam in a 4h flight. Normally I can't write that fast and accurately because there are too many distractions.

I have found myself getting a lot of focus whilst I am killing time or offline, I can tell I am around %400 more productive compared to when I work from my home, and around %2000 compared to when I work on-site (I know right, working on-site sucks, I just go to say hi and hang out with colleagues a bit).

Maybe it is because you don't have distractions or maybe it is because you don't have anything else to do that you gain focus. Internet itself can distract you, you want to check social media, you want to check a song you remembered or you want to check how to be productive while travelling and coding, you see, so many distractions. I think one of the main focus reasons is not having internet.

Next to the not internet thing, when you are working online with your co-workers of course it can be very useful if you have to align tasks, coordinate things, help and unblock people but also becomes super distracting. Every time you get a message you lost some small focus, whilst if you are working offline you don't have that problem.

Photo of my biggest distraction when working from home:

Distraction

Bad Internet Connection

Sometimes or maybe most of the times you need internet to accomplish your tasks, have a call with your co-workers or just copy and paste from Stack Overflow. But it turns out that the hotel you booked has an internet connection shared for 300 guests lol. That's why if you want to get a bit of a better connection, just check the ratings of the hotel before hand regarding to that topic, or even better just get your own hostpot.

In case the above is not possible, you can also go to co-working spaces. Not something I've done so much myself because I don't like to get that feeling of working on-site while working remotely but it is a good option.

Poor Calls

Sometimes because of the bad internet connection, your calls will suck. You are in the middle of a standup and then it's your turn to give your update and you see all the faces frozen in the screen or they just say, "yo Mateo, we can't hear you (you poor, get a better hotel, we don't pay you enough or what)".

For this, you just have to be prepared before hand, if your team doesn't care that much about it then fine, but because normally they do and maybe you have multiple calls within the day, just try to get a better connection using the options mentioned in the bad internet connection section.

It is not nice to have poor calls, it can also be a bit unrespectful depending on which kind of call you have, so get that fixed.

Bad Desks

Oh yeah the most important one. Before you book a hotel, if you are going to be working from there for a couple of days please check that at least if has a desk and a normal chair. Once in Istanbul I was working in half a desk and sitting on the bed because there was no chair and I was there for 4 days. I managed but when I went back to the Netherlands I had a massive pain in my back which you should avoid at all costs if you are a developer.

Conclusion

Working while travelling is awesome, you get to know a lot of places you wouldn't go to if you were only using your holidays but at the same time you can also have the advantage of the extra focus as I described, which is I think beautiful when you want to accomplish something short-term.

Because all experiences are different, I would love to see my fellow traveller programmers commenting about this. Maybe you don't get that extra focus and maybe you hate to do it. In any case, thanks for reading my experience.

Below a photo working in one of my trips:

Working

TypeScript Full Stack Technologies

· 9 min read

Recently I did some research and started working on a prototype for a personal idea I wanted to develop. I chose some tools accordingly for my needs and I would like to share my experience and what I like about this combo for a TypeScript developer.

Background

I wanted to build a cross-platform application, which includes a mobile application for iOS & Android, as well as a web application to reflect some of the core functionalities of the mobile app but in web.

Besides this, I also wanted to have an API which I could use for all my apps. And later on if the project starts getting some traction, I wanted this to be scalable.

Banner

Choosing the technologies: Starting with front-end

React Native

In this case, I am the developer. And as someone that is more focus on front-end, specially for mobile applications and working with React Native already for a couple of years, I know the advantages of this technology when it comes about creating something for iOS & Android in a faster and good way

Based on that, I choose React Native as my front-end framework.

React Native Web

Because I had seen react-native-web around for a while but had never used it before, I also decided to integrate it in my React Native project after doing some research. I saw a lot of reviews and specifically for the things I wanted to build (which was nothing too crazy) and it was a really good option.

When it comes about sharing code between web and mobile, I can say that about the 80% of my code is shared. The other 20% is adjusting the screens to be more web friendly, the navigation which is not that difficult to adjust and some small functionalities that I couldn’t make work that easily using React Native Web, e.g: Maps & Photo Uploader.

React Navigation

It is a very known library for mobile navigation for React Native, and also supports some things for React Native Web. For my application it worked really nice, again I remark that my application is fairly small and it is nothing too crazy. Maybe if it was too big, not sure how scalable would be sharing the routing across mobile and web.

Styled Components

Because the styles are written in a CSS way, it makes the styling easily shareable between mobile and web. And also the tool supports theming and other cool stuff which makes the development experience quite friendly.

For my case I am building right away dark and light mode so it was a must for me to have a theming since the beginning.

React Native Paper

Because I wanted to build a quick prototype, it didn’t matter that much if the styles weren’t that custom. React Native Paper provides a bunch of components that look nice, support accessibility and they are also responsive.

Funny enough, the theming is very similar to the React Navigation one so I designed a theming that could be easily shared between the app, navigation and paper.

Most of the components are also supported for React Native Web, which makes the development way faster because everything is already shared cross-platform.

Apollo Client | GraphQL

To connect the API with my front-end application I opted to use Apollo Client, it is also a very known tool and I also decided to work with this one because the back-end is also GraphQL.

Apollo Client helps the front-end to connect with GraphQL endpoints but also offers cache and other nice functionalities that I personally like. (global state management for example) You can read more about it here.

Next to this, I integrated a GraphQL code generator, which helps me autogenerating all the types based on the back-end schema. Together with GQLg, I also autogenerate mutations, queries and subscriptions based on the schema. Meaning that I don’t write anything in the front-end or the connection with the API since everything is autogenerated. This is my opinion accelerates my development a lot since I don’t have to worry about writing queries or mutations manually and I just consume the autogenerated resources.

i18n for internalisation

For the first version of the project I wanted it to have Spanish and English are those are the languages I can easily translate (and have a decent translation) and put them into the app.

i18n is a very nice framework for this.

https://www.i18next.com/

Back-end Technologies

As I haven’t done back-end for a while, I wanted to use something that is still more or less familiar for the things I do everyday, just because of simplicity and agility.

I got some inspiration from a friend that uses a similar stack and from a Ben’s Awad full-stack tutorial.

NestJS

I just discovered it this year and why did I choose it? Because it has a big community, it is as a docs says “extensive, versatile & progressive”, but also it has support for all the technologies I will list down below.

https://nestjs.com/

TypeORM

Provides an excellent support to connect applications with any kind of database and it is very nice to use it when you like TypeScript.

https://typeorm.io/

Apollo Server | GraphQL

As I wanted to use GraphQL for both back-end and front-end, I had to use Apollo Server in order to provide the endpoints. It connects really well if you also use Apollo Client in the front-end, and as I’m working in both sides I am really familiar on how this works.

You can read about it here. It provides self-documentation and also you can have a playground which is really useful to test the API.

PostgreSQL

As PostgreSQL supports both SQL (relational) and JSON (non-relational) querying. I wanted to have this flexibility. Initially my models are relational but I might be experimenting with non-relational as well.

Also, it is a well known database and with NestJS works nicely.

Others

  • Fastlane: CI/CD for mobile applications.
  • Google Analytics: Statistics and analytical.
  • Sentry: Error tracking and monitoring.
  • Cloudinary: Cloud-based image and video management services.
  • AWS: I will be using the free tier initially to deploy my back-end and database.

Summary

Front-end:

React Native, React Native Web, React Navigation, Styled Components, React Native Paper, Apollo Client, GraphQL & i18n.

Back-end:

NestJS, TypeORM, Apollo Server, GraphQL & PostgreSQL.

Others:

Fastlane, Google Analytics & Sentry.

Learnings

My experience using this combination has been very nice. In case the project has success and it gains users, it will be very easy to scale since it uses good technologies and the architecture for the project is well organised. I will be writing about the architecture used for this project in another article.

I’m currently working full-time as a Mobile Developer with React Native but I also have some full-stack experience with Php & Node.js. It’s been easy to learn NestJS, as it is a Node.js framework and somehow it reminds me of Angular. As a non full-time back-end, I don’t have a broad knowledge about how to build a very scalable back-end but I feel that NestJS provides already a very organised architecture that makes the job easier as I don’t have to think much about folder/files structure. Because it also supports a lot of things out of the box, it makes it super easy to create a quick prototype and you don’t have to worry about so many things.

Because of the technologies, in case the project goes good I can also plan on getting investment moving forward, and because everything is scalable, it is also attractive technology wise for investors. And not only that, in case you want to have more people working with you when the project grows, it will be very easy to have more people working on it.

What’s next?

The first plan is to release the API to a container which I can access remotely and then I will be able to connect my application to the cloud.

After that, finish the first version of the app. I will be focused first on delivering to the store the iOS app because of the map functionality, it’s been easier than the Android one since I don’t have to do much configuration using Apple Maps.

Later on I will finish Android, finish the map which is going to be different to the iOS one (will use Google Maps) and also I will make sure some other functionalities there work smooth and the performance is good.

And then last but not least, I will fix the remaining styling differences, maps and photo upload functionality for the web. And then I will plan the first release of the web as well.

At that point of time I will have everything fully released, I will be doing some self-promotion on Facebook groups and other platforms. I will be probably focused on adding more analytics and more error tracking, in order to deliver a good experience and learn from the user’s behaviour.

...

Notice that I did not mention what the app is about here as it didn’t really matter for the purpose of this article, I just wanted to show the cool tools that are around in 2022 if you want to build your own prototype as a solo developer.

However, in a couple of months I would like to share my full experience about going live using these technologies, and I will be sharing what went well and what did not go that well after releasing the first version. And of course, I will share the published app.

Thanks for reading, let me know in the comments if you have another nice stack you have experimented with lately.

Persistent state management using React Query for React Native

· 5 min read

Banner

If you are here, like me, you were searching for simple but nice solutions for state management for a small React Native application.

You may have considered using Redux or maybe Context. Well, those options work too but depending on your needs, it can get too robust or it won’t handle all the cases you need.

That’s why I experimented using Async Storage & React Query. It gives me persistence since I can close my app and the state will remain in the Async Storage, and also it will work as offline storage if that is something you need too.

Let’s start with the basics. What is Async Storage?

“AsyncStorage is an unencrypted, asynchronous, persistent, key-value storage system that is global to the app. It should be used instead of LocalStorage.” (https://reactnative.dev/docs/asyncstorage)

What is React Query?

“Fetch, cache and update data in your React and React Native applications all without touching any “global state” (https://react-query.tanstack.com/)

React Query allows us to cache our data in a very simple way.

Why this combination?

I wanted to keep the state of my applications persistent across different screens but I didn’t want to pass the state of the screens with props.

Also, I wanted to keep the information I had even after the user closed the app. So when the user opens it again, the information is there.

Other benefits?

Offline support. With this solution, you can also cache data and use it offline

Implementation

The application I built for this example, searches for books using OpenLibrary API, and then we have the ability to save these books in a wishlist or a reading group.

I won’t focus on the full implementation since you can find that in my repository. I want to explain here the logic for React Query + Async Storage.

Firstly, I created a service file called list.service.ts where I will put the get and the update logic.

We have a function, useGetList which will retrieve the items of the list saved on the local storage. At the same time, it will use the listKey to store this information in the cache using React Query. We use the same key for Async Storage and React Query to have the same reference.

import AsyncStorage from '@react-native-async-storage/async-storage'
import { useMutation, useQuery, useQueryClient } from 'react-query'

export enum List {
Wishlist = '@wishlist',
ReadingGroups = '@readingGroups'
}

export const useGetList = (listKey: List) => {
return useQuery<string[] | null, Error>(
listKey,
async () => {
const result: string | null = await AsyncStorage.getItem(listKey)

return result ? JSON.parse(result) : []
}
)
}

After this, we create a function called useUpdateList which will be the one in charge of updating the list in both local storage and cache.

Again, we use the listKey to make reference to the list.

/**
* Article: https://mateoguzmana.medium.com/persistent-state-management-using-async-storage-react-query-for-simple-react-native-apps-9206db073f4a
*/
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { getUpdatedList } from '../utils/list.util'

export enum List {
Wishlist = '@wishlist',
ReadingGroups = '@readingGroups'
}

export const useGetList = (listKey: List) => {
return useQuery<string[] | null, Error>(
listKey,
async () => {
const result: string | null = await AsyncStorage.getItem(listKey)

return result ? JSON.parse(result) : []
}
)
}

export const useUpdatelist = (listKey: List) => {
const queryClient = useQueryClient()

return useMutation(
listKey,
async (itemId: string) => {
const result: string | null = await AsyncStorage.getItem(listKey)

const currentList = result ? JSON.parse(result) : []
const newList = getUpdatedList(currentList, itemId)

await AsyncStorage.setItem(listKey, JSON.stringify(newList))

return newList
},
{
onSuccess: () => queryClient.invalidateQueries(listKey),
}
)
}

It is important to notice that in line 37, on onSuccess we call the queryClient and then we invalidate the queries. What it does is invalidate the queries in all the places where you are using the queries for the specific list. That’s why it’s important to keep a consistent reference with the listKey.

Example of this:

Let’s say we have Screen A and Screen B. On screen B we press a button to save a book to the wishlist. We want Screen A to reflect these changes too.

Getting initial and updated list

On screen A, we can simply do this:

const { data: getWishlistData } = useGetList(List.Wishlist);

This is basically getting the wishlist, using the useGetList query. Once we invalidate this query, it will automatically fetch again this information, keeping an updated state of this list. Then the changes are reflected in both Screen A and B (because the query is used in both).

Updating lists

We can use useUpdateList to update a list from a component by simply doing:

const updateWishlist = useUpdatelist(List.Wishlist);
const onPressWishlistButton = () => updateWishlist.mutate(itemId);

This will save the data in both local storage and cache and then the queries listening to this listKey will be fetched again automatically without any extra effort.

...

The explanation of the full example is a bit difficult, I might have missed some things during the writing so that is why I suggest you go to the repository and read the full code and also look at the examples, it will make it easier to understand.

Please also notice this was experimentation and in my opinion, this works very nicely for small projects. If you are looking into complex state management I’d suggest another option like Redux.

Please check out the example app.

Optimizing Bitrise Build Times

· 5 min read

The application I currently work on takes a lot of time to build in Bitrise. Sometimes you have to wait almost 90 min for it to respond. If you are lucky it will build in about 1 hour. It's still a lot, I know.

We needed to do this optimization because of the concurrency-based Plan Deprecation update so we started preparing for it.

I managed to reduce the waiting time by a bit less than half, getting from builds taking about 1 hour to 25 min on average. Without any difficult steps, and without a deep knowledge about pipeline optimizations this was done in around 3 working days including investigation and testing.

Here I'll explain how.

Logo

Initial times vs times after improvements

Samples were taken only from the two most used flows. This is some internal documentation I did for my company. Basically, I reduced the time for the pull requests workflow, leaving the workflow for the production release as it is to see how it behaves in the upcoming time. After we find it is stable we'll switch that one too.

Improvements

Improvements done:

  • Enabling caching
  • Using compressed caching
  • Cache updater workflow
  • Splitting Android and iOS workflows.

Enabling caching

Enabling caching for Node modules, Gradle, and Cocoa pods. Although it doesn't seem to improve that much from the previous 53 min, it does a more stable timing since we are not relying on the other servers to download the packages. We have noticed that some days those servers have downtimes and it increases the build times significantly sometimes by more than 30 min. Having them cached ensures a more linear time. To enable caching in the workflows, it is as simple as adding Cache Pull & Cache Push steps in your workflow. More documentation about it here.

Enabling Cache

Compressing cache:

Compressing cache when pushing it to the bucket in order to make the pulling of the cache faster. It made a difference of about 4 min faster than without compression.

To do this is as simple as setting the flag as true in the Cache Push step:

Compressing Cache

Cache updater workflow:

Creating a scheduled flow that will run weekly, this flow will update the cache on a weekly basis to avoid having this process in our "pr" workflow, making it faster since the pr flow will just pull cache, not pushing it which takes about 7 min to 8 min in our case.

In other words:

  • "pr" workflow always pulls cache.
  • This is updated with a scheduled workflow weekly.

How to do this?

You can simply create a flow that builds together Android and iOS and add the Cache Push step at the very end of this flow.

And then you can schedule your workflow to run every certain amount of time, in my case I decided to do it weekly.

Cache updater

Parallel builds, triggering iOS and Android builds in parallel.

Before the process was unified where the builds for Android and iOS were running in the same workflow one after the other. That forces you to wait until one build finishes before you can proceed with the next one.

Having them in two separate workflows has advantages and disadvantages.

Disadvantages

There are some things that run twice as the basic yarn commands. (Linter, ts tests, generating secrets for the bundle & translations). I was trying to re-use this in a general step and then run the single iOS and Android processes but is it not something that I found possible with the current Bitrise capabilities.

Advantages

  • The time was reduced by half.
  • Testers won't have to wait until both builds are complete to start testing.
  • Faster delivery.
  • Gives you a nice feeling of it being pretty fast. (or at least way more than before)

To be able to run parallel workflows you just need to use the step "Start Build"

The way how I did it was by creating a separate workflow that only contains this step and then in this step I'm calling both iOS and Android flows.

Parallel builds

And that's all. That reduced the times by half.

Reduced times

Future improvements I might want to look into:

  • If the capability for parallel steps is improved, ideally the re-use of steps for the independent builds would be great. That would save from 6 min to 7 min in the whole process making it even faster.
  • Look at the specifics for the Android build and the iOS archive process. For example, which optimizations we could do for the archive to be faster? Disable/enabled bitcode?
  • More stuff to put in the cache. Only 3 main things were put in the cache but there are more things that could be put in there.

Conclusions

As you can see, these improvements were not difficult and do not require a deep knowledge about pipeline optimizations. It is important to improve these processes we use every day once in a while, sometimes we don't care about the time it takes because we don't mind waiting but on a bigger scale this makes a huge difference.


Bitrise also posted it in their social media :)