惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
WordPress大学
WordPress大学
T
Tailwind CSS Blog
V
Visual Studio Blog
月光博客
月光博客
Hugging Face - Blog
Hugging Face - Blog
小众软件
小众软件
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
博客园 - Franky
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
阮一峰的网络日志
阮一峰的网络日志
量子位
有赞技术团队
有赞技术团队
酷 壳 – CoolShell
酷 壳 – CoolShell
Apple Machine Learning Research
Apple Machine Learning Research
博客园_首页
Jina AI
Jina AI
雷峰网
雷峰网
博客园 - 【当耐特】
博客园 - 叶小钗
美团技术团队
宝玉的分享
宝玉的分享
IT之家
IT之家

Base Engineering Blog

B20: The Standard Behind Tokenized Stocks on Base Making AWS Nitro Validation feasible onchain Native Account Abstraction Postmortem: June 25th Block Production Outage Introducing Base Beryl Multiproofs on Base Introducing Base Azul A new, unified stack for Base Chain Builder Codes & ERC-8021: Fixing Onchain Attribution Anti-Sybil Infrastructure for the Base Ecosystem Bridging Assets to Base: Playbook Engineering the Base-Solana Bridge How Base Pay Delivers 1-Step Crypto Payments L1 Upgrades: The Glamsterdam proposals we’re most excited about Scaling Base safely with end-to-end benchmarking Scaling Base: Doubling capacity in 30 days OnchainTestKit: A Framework for End-to-End Testing of Onchain Applications Flashblocks Deep Dive: How we made Base 10x faster Benchmarking ZKP Systems for Passkey ECDSA Verification L2 Primary Names are coming to Basenames: ENSIP-19 Introducing free security reviews for Base builders Introducing the Base Account SDK Scaling Base in 2025: Sustaining 10x growth while maintaining sub-cent transactions Engineering the Commerce Payments Protocol Powering Shopify
Building The New Base App: Prefetching at Scale
Base Engineering Team · 2026-01-22 · via Base Engineering Blog

A year ago, we made a bold decision: evolve Coinbase Wallet into the Base App, an onchain everything app that brings social, trading, payments, app discovery, and earning into one place. Our goal was to build a consumer-grade experience that rivals the best social apps, while gracefully handling onchain challenges like transaction confirmations and real-time blockchain data syncing. But we also knew it was critical to balance security and quality with speed and scale. To do this, we carefully considered how to handle loading states.

We started with loading skeletons (or shimmer UIs). They serve a single purpose: to keep users engaged while data is being fetched. Instead of staring at a blank screen or a spinning loader, users see a visual placeholder that mimics the final layout of the content.

But loading skeletons are ultimately an elegant band-aid, and we set out to do better. They help mask slow APIs or heavy computations by giving users something to look at while they wait. While they improve the overall user experience, they don't address the root cause of slow performance.

As the content is being fetched, the skeleton itself must be rendered first, followed by the actual content once it’s loaded. This double render cycle can affect performance, particularly on less powerful devices. The result? An additional delay that might ironically make the app feel slower, especially in low network conditions or on resource-constrained devices.

One could also argue the repeated, jumpy flash of gray shimmers isn’t as elegant as we thought. See our current Profile loading timeline:

post image
Profile Loading States

This pattern addresses a backend bottleneck : Loading data from multiple sources, all at once can be slow. It introduces a waterfall of layout loading state while various parts of the screen are being fetched.

However, this came at the tradeoff of making the user's experience substantially worse.

We wanted to do better. So we built a behavioral prefetching system in the new Base App to anticipate user behavior and deliver content instantly.

We have data on the navigation habits between different links and screens. E.g: Users on the Feed are more inclined to navigate to a Cast than a Profile.

We also know when links are in the viewport. We can use this to design a simple prefetching algorithm:

score = conversionRate * visibility * manual priority

And start prefetching screens data accordingly:

post image

A simple idea in principle, with many caveats.

There can be hundreds of links on any given screen — so how can we reduce scope and only prefetch visible items? Unfortunately react-native doesn’t have yet an Intersection Observer API so we came up with a few alternatives below. 

Note: These snippets are meant to be educational / inspirational, copy-pasting as-is won’t work.

Measure & onLayout

With react’s New architecture, we can leverage the useLayoutEffect & measure method

function PrefetchOnVisible({ children, prefetchQuery }) {
  const ref = useRef<View>(null);
  const prefetchManager = PrefetchManager.getInstance();

  // Synchronous, first-frame measure
  useLayoutEffect(() => {
    ref.current.measure((_x, _y, _width, _height, _pageX, pageY) => {

      // Within Viewport?
      const isComponentVisible = pageY <= viewPortHeight && pageY + 100 >= 0;
      if (!isComponentVisible) return;

      prefetchManager.process(prefetchQuery);
    });
  }, [prefetchQuery]);

  return <View ref={ref}>{children}</View>;
}

For legacy Architecture, we can fallback to onLayout callback:

function PrefetchOnVisible({ children, prefetchQuery }) {
  const ref = useRef<View>(null);
  const prefetchManager = PrefetchManager.getInstance();

  // Asynchronous, next frame measure
  const onLayout = useCallback(() => {
    ref.current.measure((_x, _y, _width, _height, _pageX, pageY) => {

      // Within Viewport?
      const isComponentVisible = pageY <= viewPortHeight && pageY + 100 >= 0;
      if (!isComponentVisible) return;

      prefetchManager.process(prefetchQuery);
    });
  }, [prefetchQuery]);

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  );
}

Then wrap our links accordingly:

function ProfileLink({ children, profileId }) {
  const navigateToProfile = useCallback(() => {
    navigate('Profile', { profileId });
  }, [profileId]);

  const profileQuery = useProfileScreenContentQuery(profileId);

  return (
    <PrefetchOnVisible prefetchQuery={profileQuery}>
      <Pressable onPress={navigateToProfile}>
        {children}
      </Pressable>
    </PrefetchOnVisible>
  );

If ProfileLink is within the viewport when the screen renders, ProfileQuery will be fetched & cached. Navigation to the profile will become instant.

However, this only works for static, non-scrollable screens: useLayoutEffect and useLayout only fire when the layout changes.

Virtualized list: renderItem & onViewableItemsChanged

Most if not all virtualized lists (FlatList, FlashList & LegendList) have two methods we’re interested in:

We can wrap these methods to keep track of when an item is visible on the screen:

Let’s start by wrapping renderItem function with a provider to share the item’s index:

function usWrappedRenderItem({ renderItem, listId }) {
  
  // Wrap list items with a provider surfacing the index of the item
  return useCallback((args) => (
    <ListItemVisibilityProvider listItemVisibilityIndex={args.index} listId={listId}>
      {renderItem(args)}
    </ListItemVisibilityProvider>
  ), [renderItem]);
}

All children within this function can now access their index within the list & the listId:

const { index, listId } = useListItemVisibility()

Next let’s wrap the onViewableItemsChanged callback:

 function useVisibilityTracker({ onViewableItemsChanged, listId }) {

  // 1. Keep track of mounted list
  useEffect(
    function trackListVisibility() {
      visibilityTracker.registerList(listId);

      return visibilityTracker.removeList(listId);
    },
    [listId],
  );

  // 2 Update visible items when array of visible items changes
  const updateVisibleItems = useCallback((visibleIndexes: Set<number>) => {
    visibilityTracker.setVisibleItems(listId, visibleIndexes);
  }, [listId]);

  // 3. Debounce to avoid rapid-fire updates while scrolling
  const debouncedUpdateVisibleItems = useDebouncedCallback(updateVisibleItems, 150);

  // 4. Return the wrapped onViewableItemsChanged callback
  return useCallback((event: { viewableItems: ViewToken[]; changed: ViewToken[] }) => {

    // Call the original callback first
    onViewableItemsChanged?.(event);

    // Get visible indexes
    const { viewableItems } = event;
    const visibleIndexes = new Set(
      viewableItems.filter(({ isViewable }) => !!isViewable).map(({ index }) => index),
    );

    debouncedUpdateVisibleItems(visibleIndexes);
  }, [debouncedUpdateVisibleItems, onViewableItemsChanged]);
}

And finally, use this in our lists:

const wrappedRenderItem = useWrappedRenderItem({
    renderItem,
    listId: 'MyList',
});

 const wrappedOnViewableItemsChanged = useVisibilityTracker({
    onViewableItemsChanged
    listId: 'MyList',
});

return (
  <FlashList 
    renderItem={wrappedRenderItem}
    onViewableItemsChanged={wrappedOnViewableItemsChanged}
    ...

With this,  we effectively gave our list items full visibility context, so we can use this to prefetch our data like so:

function PrefetchListItemOnVisible({
  children,
  prefetchQuery,
}) {
  const visibilityTracker = ListVisibilityTracker.getInstance();
  
  // from wrappedRenderItem
  const { index, listId } = useListItemVisibility();

  // Subscribe on mount
  useEffect(() => {
    visibilityTracker.subscribe('listId', (visibleIndexes) => {

      // Item index is not in the viewport, return
      if(!visibleIndexes.has(index)) return;

      // Item is in the viewport, prefetch !
      prefetchManager.process(prefetchQuery);
    });

    return () => visibilityTracker.unsubscribe();
  }, []);

  return children;
}; 

The hard part is done, and what’s next is we have  scroll-aware viewport context and can prefetch data accordingly.

Even when fetching only visible data, this can still mean hundreds of requests per user, multiplied across hundreds of thousands of users.

An on-device queue gives us flexibility and safety, we can add delay, max concurrent requests & even a set maximum request per minutes:

post image

We also can set these queue options based on device class: A last-gen iPhone Pro can prefetch hundreds of queries in seconds, but a low-end Android phone will start struggling almost immediately.

Even the most moderate queue settings and conservative visibility tracking can’t avoid overfetching entirely: We added fine-grained killswitches by defining “Triggers,” e.g. Link to a screen & “Target” e.g. The screen.

If our profile backend sees an abnormal strain from prefetching, we can disable specific prefetching flow like “search results → profile”, or disable profile prefetching entirely.

post image

Now that frontend won’t overfetch, and we have multiple mitigations in place for our backend. Time for the elephant in the room: DevX

We have 20+ classes, components, context, helpers, hooks. How can we make the process of implementing prefetching easy?

Solution: a single API for prefetching target & triggers:

function createPrefetchableComponent( { query, prefetchTarget, options }, Component) {

  // Wraps "target" component
  const PrefetchableComponent = memo(function PrefetchableComponent({ variables }) {

    // Fetching & refresh methods
    const queryRef = useLazyLoadQuery(query, variables, options);
    const { refresh, isRefreshing } = useRefreshQuery(query, variables);

    const props = { queryRef, refresh, isRefreshing, variables };

    return <Component {...props} />
  });

  // Attach the Trigger component with the query & prefetchTarget
  PrefetchableComponent.TriggerComponent = memo(function TriggerComponent({ children }) {
    return <PrefetchObserver prefetchQuery={query} >{children}</PrefetchObserver>;
  });


  return PrefetchableComponent;
}

Note: This is a heavily simplified snippet, in practice it’s a typescript fiesta.

Then use this to wrap our prefetchable components:

const PrefetchableProfile = createPrefetchableComponent({ 
  query: profileQuery,
  prefetchTarget: 'profile'
}, function Profile({data, refresh}) {
   
   // ...

})

And finally around our triggers:

 function ProfileLink({ children, profileId }) {
  const navigateToProfile = useCallback(() => {
    navigate('Profile', { profileId });
  }, [profileId]);

  return (
    <PrefetchableProfile.TriggerComponent variables={{profileId}}>
      <Pressable onPress={navigateToProfile}>
        {children}
      </Pressable>
    </PrefetchableProfile.TriggerComponent>
  );

Add a couple AI rules and context on top, and implementing prefetching becomes one-shotable.

Previously, navigating between tabs led to a shimmer fest:

Play Video

With prefetching enabled, navigation & rendering is instant:

Play Video

We added debugging tools to let us “see” the prefetching in action

Play Video
post image

We measure our app’s performance via a unified scoring system introduced at Coinbase:

  • NTBT: Navigation Total Blocking Time

  • ART: Above the fold Rendering Time

  • TRT: Total Rendering Time

Together, these metrics capture how quickly users can interact, see initial content, and view the fully loaded screen.

See performance comparison results on High End devices:

NTBT: -80-100%

The data is available in cache, no suspense is triggered and navigation is instant

| Screen        | Before | After | Diff (%) |
|---------------|--------|-------|----------|
| Search        | 44     | 0     | -100%    |
| Transact      | 183    | 25    | -86.3%   |
| Notifications | 48     | 0     | -100%    |
| Wallet        | 126    | 38    | -69.8%   |
| Profile       | 229    | 0     | -100%    |

TRT & ART: -70-80%

We skip the loading steps entirely, the screen renders near-instantly in its final state.

| Screen        | Before | After | Diff (%) |
|---------------|--------|-------|----------|
| Search        | 283    | 33    | -88.3%   |
| Transact      | 765    | 141   | -81.6%   |
| Notifications | 447    | 127   | -71.6%   |
| Wallet        | 849    | 128   | -84.9%   |
| Profile       | 511    | 94    | -81.6%   |

We saw similar results on low end devices

NTBT: -60%

The data is available in cache, but de-serialization is still expensive on low end devices.

TRT & ART: -40%

Rendering is faster but still expensive on low end devices.


Download the Base app and experience instant, seamless navigation for yourself—no shimmers, no waiting, just the onchain everything app the way it's meant to feel.


Appendix: Rendering, Tech Stack & RN New Architecture

Curiously, a few screens remained stubbornly slow even with prefetching enabled. What's going on?

All our investigations pointed to the same conclusion: Our stack was aging.

Running React Native 0.77.3 in legacy mode meant missing out on bug fixes & new features.

The New Architecture, its flashy new Fabric UI rendering engine and bridgeless native call should also streamline rendering.

But more importantly, it blocked us from upgrading third‑party libraries like FlashList V2, Reanimated V4, and many others that have already moved to the

New Architecture

.

In theory, it’s just a matter of switching a flag somewhere in the app config:

-newArchEnabled=true
+newArchEnabled=false

Sounds easy enough right?

Subscribe to the Base Engineering Blog & stay tuned for more on the New Architecture.