Laying the Rails Beyond WebView: Why Karrot Chose Lynx
2026년 8월 12일
원문에서 보기 ↗
Hi, I'm Tony, the lead of Karrot's Frontend Core Team. Our team builds the development environment and deployment infrastructure used by frontend engineers at Karrot. This post is a record of how we evaluated Lynx --- a technology with very few real-world references at the time --- and brought it into our production app. If you've ever had to decide whether to adopt a new technology, I hope our decision-making process gives you something useful to take away.
WebView and the Performance Problem
Many screens in the Karrot app are built with WebView. When a WebView screen opens, it needs to fetch web documents and data over the network before executing them, and this initial loading cost has been a long-standing challenge for us. SSR can reduce performance bottlenecks from JavaScript evaluation and DOM manipulation by rendering the initial web document on the server. But the path that fetches the web document and initial data over the network still remains.
There are other approaches as well. We can display a native screen that closely resembles the actual web screen before the WebView finishes loading. We can also cache the client application with a Service Worker so the UI can render before making a network request. But these optimizations increase product complexity. Optimizing within the constraints we already had was important, but what we really wanted was an execution environment that could render the UI we intended from the very first frame.
The problem we truly wanted to solve was not shaving a few more milliseconds off loading time. We wanted users to stop perceiving loading altogether.
What We Needed From a New Runtime
- The first frame users see needed to be the actual product UI, not a separate temporary screen.
- We needed to be able to adopt it screen by screen while keeping the existing native app intact.
- Frontend bundles needed to be deployable independently of the app binary.
- We needed to be able to start without a large dedicated organization --- with just one frontend engineer, one iOS engineer, and one Android engineer.
- If something went wrong, we needed a path back to the existing WebView implementation.
Replacing the entire Karrot app with a new cross-platform framework was never our goal. Core product surfaces --- such as the Home feed, where users discover content, and Chat, where actual transactions take place --- needed to remain on our fast and stable native stack. What we were looking for was a rendering layer that could replace some of the screens previously handled by WebView, while giving us more room for performance optimization.
In March 2025, one of my colleagues shared a post about Lynx in Slack. As I started reading through it, I kept finding design decisions that matched what I had been looking for.
Lynx?
Lynx is a cross-platform technology that lets you build iOS and Android UIs with a development model close to React and CSS. You can build an entire mobile UI with Lynx, or embed it as a UI block inside an existing native application.
When evaluating Lynx, the first things I looked at were not its React APIs or CSS syntax. What caught my attention first were the JavaScript engine it chose and the way it rendered the first screen.
1. A Small JavaScript Engine That Starts Fast
When evaluating a JavaScript engine, we usually start by comparing execution performance. Processing speed for complex computations and benchmark scores are certainly important, but for mobile UI, the cost of preparing the runtime also directly affects the user's loading experience. After all, what the user experiences is the moment a screen opens.
Lynx's main-thread runtime uses PrimJS (QuickJS). PrimJS is designed to be small and easy to embed in applications, with low runtime initialization cost as one of its key characteristics. For the problem we were trying to solve, startup cost mattered more than maximum throughput. It was a good fit for the sequence of preparing a runtime for a screen, executing the necessary JavaScript, and displaying the first UI.
A small, embeddable runtime also gives you more control over the execution environment itself. You can limit which APIs the host application exposes, place shared polyfills and commonly used code closer to the runtime, and evolve toward an architecture where resources are prepared before a user enters a screen.
We felt this made Lynx a strong foundation for building an execution environment we could control.
2. A Dual-Thread Architecture Built Around the First Frame
ReactLynx code plays different roles on the main thread and the background thread.
https://lynxjs.org/react/lifecycle
The main thread renders the first screen and applies subsequent UI updates. The background thread runs the full React runtime and handles component lifecycles and side effects. In other words, the first screen does not wait for background work to finish before rendering.
This architecture enables Lynx to provide IFR --- Instant First-Frame Rendering. If the bundle and initial data are already available, the main thread can render the actual product UI immediately. Instead of passing through a blank screen or temporary state, the application can display the intended content in its very first frame.
Instant First-Frame Rendering (IFR) - Lynx
Of course, IFR comes with clear conditions. If the bundle must be downloaded asynchronously, you still have to wait for that download. If the data required for the first screen only becomes available after a network request, you have to wait for that data as well. The official Lynx documentation also explains that IFR cannot be achieved when the bundle and critical content cannot be prepared synchronously.
Interestingly, these constraints actually gave us a way to design the first screen at both the product and platform levels. We could decide whether to preload bundles, inject initial data from native, and determine which parts of the UI should appear in the first frame. The optimization surface was fundamentally different from simply trying to reduce loading time inside a WebView.
3. A Familiar Toolchain and Incremental Integration
Lynx applications can be built with a dedicated bundler called Rspeedy. Rspeedy is based on Rspack and Rsbuild and supports HMR. Rspack, which sits underneath it, is compatible with the Webpack ecosystem familiar to frontend engineers. That meant we could reuse many of the tools and concepts we were already accustomed to in frontend development.
Bundles can also be operated separately from the app binary. At Karrot, we serve Lynx bundles through a CDN and have the native app fetch them by URL. This means we do not need to release a new version of the app every time frontend code changes. (Example: https://lynxjs.org/lynx-examples/hello-world/dist/main.lynx.bundle)
You can also choose how deeply Lynx integrates with an existing application. A LynxView can fill an entire screen inside the native View Hierarchy, or occupy only part of a screen between native Views. This approach --- incrementally introducing Lynx where needed without rewriting the existing app --- is known as Brownfield adoption.
Embedding LynxView into Native View - Lynx
CSS support can be checked through Lynx's compatibility data. Lynx publishes platform and version support for APIs, built-in elements, and individual CSS properties. For us, clearly documenting where Lynx differs from the web was just as important as providing web-like syntax.
The Alternatives
The cross-platform ecosystem already has mature technologies such as React Native and Flutter. Both have large ecosystems and extensive production track records. So why not choose one of them? I wasn't interested in deciding which technology was objectively better. I wanted to find the one that best matched the problem Karrot was trying to solve.
What we needed was not a new application framework responsible for the entire app. We needed a rendering layer that could replace individual screens.
vs "React Native"
React Native can also be introduced incrementally into an existing native app at the screen or View level. Its React ecosystem and rich collection of native modules are major advantages for product development. But what we were trying to build was not an entirely new app development environment. We wanted a rendering layer with a deliberately limited role inside our existing native application.
Under this model, allowing a single native module does not end with installing a package. You need to own its compatibility across iOS and Android, its interaction with the app lifecycle and permissions, its security implications, and its performance over the long term. If frontend code is deployed independently from the app binary, you also need to ensure that the native module version expected by a remote bundle matches the version actually installed in the app. Gradual rollout and rollback strategies become part of your responsibility as well. And the larger the ecosystem, the greater the expectations around the breadth of integrations the platform should support. If we ended up continuously restricting which libraries developers could use because of these operational costs, we risked creating a platform with a rich ecosystem on paper but where developers felt they "couldn't actually use what they needed."
Instead of maximizing the size of the ecosystem, we wanted an execution environment where we could explicitly choose which capabilities the host application would provide --- and which operational responsibilities we were willing to own. At the time, Lynx's limited surface area felt less like a limitation and more like a boundary we could control.
vs "Flutter"
Flutter also supports incremental integration into an existing native app through Add-to-app, at either the screen or View level. Its own rendering model is particularly strong at producing consistent UI and predictable performance across platforms, and it also provides accessibility support through Semantics.
However, Karrot wanted to preserve the OS behavior and accessibility standards of the existing native application while replacing some of the screens previously handled by WebView with a React-and-CSS development model and an independently deployable bundle workflow. Because Flutter renders UI through its own rendering model rather than through the OS, we would need to separately verify whether all the behavior accumulated in our existing native product would be preserved. Adopting Flutter would also mean operating and validating a new Dart- and Widget-based development stack, a Flutter-specific design system, the lifecycle of the engine and plugins, and accessibility in screens where Flutter and native Views are mixed.
In addition, under Flutter's standard mobile deployment model, compiled Dart application code and assets are included in the deployment lifecycle of the app binary. That differed from the operating model we wanted, where frontend bundles could be served from a CDN and deployed independently from the app. It wasn't that Flutter lacked capability. We simply concluded that it was further away from the limited, controllable rendering layer Karrot was trying to build at the time.
Ultimately, we were not comparing the number of things each technology could do, but the scope of new responsibilities we would have to own in order to reach the operating model we wanted.
There are clear limitations to this evaluation. We did not build equivalent PoCs with all three technologies, nor was this an attempt to rank their performance. This was a decision made under a specific set of constraints: keep the existing native app, start by replacing only some WebView screens, and begin the adoption with just three engineers.
How We Evaluated a Technology With Few References
The biggest uncertainty during adoption was the lack of real-world references. When introducing a new runtime and rendering engine into a production app, a list of features in the documentation is not enough. We needed to understand when runtimes were created and destroyed, how far errors were isolated, when native resources were released, and how implementations differed across platforms.
There were no blog posts or Q&As that answered these questions. So how could we verify them? If the answers didn't exist, we decided to read the code ourselves. We cloned the Lynx codebase locally, ran AI agents against it, searched for the implementation of specific APIs, compared iOS and Android code paths, traced runtime lifecycles, and checked whether the code matched the documentation.
# Clone the Lynx codebase locally
$ git clone https://github.com/lynx-family/lynx
# Open an AI agent and ask what you want to know
$ claude
$ codex
Code is the closest source of truth to how a system actually behaves today. AI agents reduced the cost of reaching the truth I wanted to find inside a large codebase. That is why we cared more about how much we could verify ourselves than about the number of references available.
Keeping Uncertainty From Becoming Adoption Risk
Technologies with few references inevitably come with uncertainty. Unexpected problems may surface. But uncertainty does not automatically mean risk. Risk depends on the damage caused when something fails, and on your ability to detect that failure and recover from it.
We started by applying Lynx to screens that already had equivalent WebView implementations. If something went wrong, we could switch back to the existing WebView. We did not migrate the entire application at once. We limited the blast radius of failure to individual screens and built the recovery path first.
We also separated questions that could be answered through architecture from those that needed to be verified through a PoC. By narrowing the scope of what we needed to prove with the PoC, even if one of our assumptions turned out to be wrong, the impact would remain contained within that scope. We did not understand everything about the technology before starting. We couldn't have. Instead, we started only after establishing a way to investigate what we didn't know --- and a way back if we failed.
A Production PoC With Two Screens
We did not start by building an entire platform. Instead, we ran a PoC with two screens that had very different characteristics.
The first was the All Services page. Because its content changes relatively infrequently, we used it to validate static layouts and initial rendering.
The second was the Marketplace category feed. This screen loads real data, continuously appends items, and can be scrolled for long periods of time. It allowed us to validate data fetching and infinite scrolling behavior that would be difficult to understand from a static screen alone.
(Left) All Services / (Right) Marketplace Category Feed
On the frontend side, Gina, who was an intern at the time, implemented the Lynx views. Ray handled iOS, and Luke handled Android. Our success criteria were:
- Do not degrade existing business metrics.
- Improve the time until the first content becomes visible compared with the existing WebView.
- Operate reliably in production on both iOS and Android.
- Be able to fall back to the existing WebView if something goes wrong.
Results
We ran an experiment with a total of 1.9 million users, exposing 50% to the WebView and 50% to the Lynx view. We measured the time from the user's tap on the previous screen until the first screen was rendered, using p75.
- Android: Lynx was 18% faster. (735ms → 610ms)
- iOS: Lynx was 37% faster. (479ms → 302ms)
To be honest, looking at the numbers alone, I was a little disappointed that the difference wasn't larger. But the WebView implementation we were comparing against had gone through years of optimization, while this was our first Lynx implementation and we had very little experience with the framework, so it was far from optimized.
We only realized just how under-optimized it had been as we gained more experience later. We did not yet have basic policies around bundle optimization, chunk splitting, or Cache-Control, nor had we implemented more advanced optimizations such as bundle preloading or passing initial data directly into the first frame. Even in that state, however, Lynx performed better than a WebView we had spent years refining. That gave us a sense of how much room there was left to improve.
And the PoC screens actually ran in production. This was our first attempt at integrating a new JavaScript runtime and rendering engine into the app, and during the PoC period, we did not observe any crashes attributable to Lynx. That was enough for us to feel we could move on to the next stage.
A New Alternative We Could Operate Together
The benefits and costs of WebView were being felt by different parts of the organization. From a frontend perspective, WebView was an important tool that enabled fast development and independent deployment. From a mobile perspective, it was an execution environment whose memory usage and impact on app performance had to be continuously managed. Neither perspective was wrong. We were simply building the same product while directly bearing different costs.
We couldn't solve this by simply saying, "Let's use fewer WebViews," or, "Let's optimize them more." There was no realistic alternative that could replace the development speed and deployment workflow WebView provided. But simply deciding to keep using WebView left the costs borne by mobile engineers unresolved. We needed an alternative that could accommodate both perspectives.
Lynx showed the potential to become that alternative. Frontend engineers could continue using React and CSS while preserving a bundle deployment workflow similar to what they were already used to. Mobile engineers, meanwhile, gained an option based on a lightweight runtime rather than WebView.
But this was not a technology that either frontend or mobile could complete alone. On the frontend side, we needed to create a development experience comparable to --- or better than --- WebView and establish migration paths for existing production screens. On the mobile side, we needed to design the lifecycle of runtimes and Views, resource loading, caching, native modules, and error isolation. And once deployment, interfaces, and the design system entered the picture, we had to own the same problem together.
Lynx could not remain solely a frontend technology or solely a mobile technology. We had to connect both sides of the implementation before it could actually be used in production. And when something went wrong, we needed to investigate the cause together.
I think this cultural change was every bit as important as the technical validation. Meeting more often can certainly improve collaboration, but the way teams work changes much more fundamentally when they encounter a shared problem that neither side can solve without the other. Lynx was that kind of problem. Rather than simply adopting a framework, we were laying a new set of rails that frontend and mobile could build and operate together.
Turning It Into a Platform
At first glance, Lynx and WebView may not look very different: both can fetch a remote bundle and render a screen. The real difference lies in the optimization opportunities that become available afterward. Once we had rendered our first screens, we began building an environment that would allow multiple service teams to develop with Lynx conveniently and reliably.
1. Building the Bundle Deployment Flow on Top of Warp
We did not start by building an entirely new deployment platform for Lynx bundles. Instead, we reused Warp, the static web hosting platform built by our Frontend Core Team. Built Lynx bundles are served through a CloudFront CDN. The native application receives a URL, fetches the Lynx bundle from it, and renders the screen.
The Lynx engine can fetch bundles through a resource loader injected by the host application. This allowed us to build the loading layer around the download and web caching policies already used by the app. It became the foundation for building native UI while preserving the frontend development and deployment workflow.
2. Balancing Bundle Freshness and Startup Cost
We apply a caching strategy that combines ETag and max-age to Lynx bundles. While a bundle is within the lifetime defined by max-age, we use the local copy without network revalidation. When revalidation is required, if the ETag still matches, we can verify freshness without downloading the entire file again. Combined with the native loader's policy, this can also support limited offline scenarios.
We can also consider policies such as stale-while-revalidate. With this approach, the cached screen is shown first while the application checks for a newer bundle in the background. Depending on the characteristics of the product, we can choose where to balance freshness against the ability to open the first screen quickly.
3. Reusing Shared Runtime Assets
There is no need to include the same common polyfills in every screen bundle. If multiple Lynx screens reference the same version of immutable JavaScript assets, those assets only need to be downloaded once. By versioning URLs so they change only when their contents change, and applying long-lived caching policies, we can also increase cache hit rates. This architecture allows each screen's product code to be deployed independently while sharing common runtime assets.
4. Defining Native Interfaces With Our Own DSL
Lynx screens need access to functionality provided by the native app. They need to read contextual information from the app, navigate to other screens, select images for composing posts, emit logging and analytics events, and call functionality that requires native permissions.
If bridge code is implemented manually on each platform, the interfaces can easily drift apart. iOS, Android, and frontend engineers might all interpret the same capability differently. To solve this, we created Lydl, our own Domain-Specific Language built with Langium, along with a Codegen pipeline that integrates those declarations into the codebase.
A single DSL definition acts as the source of truth. From it, we automatically generate the interface code needed by frontend, iOS, and Android, as well as Lynx bundles that can be used to test the behavior. By managing types and calling conventions in one place, we reduce duplicated implementation and errors caused by manual work.
module MyModule {
version "1.0.0";
error UnknownError = UNKNOWN_ERROR({});
fn greet(req: { name: string }): string throws UnknownError;
}
docs {
greet({ name: "Tony" }); // Hello, Tony!
}
5. Bringing SEED Design to Lynx
Karrot's design system, SEED Design, has also begun supporting Lynx. Lynx projects can use SEED's design tokens, styles, and components. Packages such as @seed-design/lynx-react and @seed-design/lynx-css bring Karrot's product language into Lynx screens.
Design system support is about more than matching colors and typography. It is about sharing product standards --- including accessibility and interaction behavior --- across multiple execution environments. It also provides a foundation that prevents Lynx screens from feeling like a separate experimental UI.
As deployment, caching, shared assets, native interfaces, and the design system came together, Lynx began evolving from a single View into a platform that multiple teams could use.
Choosing Which Screens to Expand Through Brownfield Adoption
Karrot's goal is not to rebuild the app in Lynx. We do not plan to migrate every secondary screen in bulk, nor do we intend to set a migration target and require every team to adopt it.
We evaluate candidates based on the characteristics of each screen.
- Screens with high PV, where improvements to initial loading and memory usage can have a meaningful impact
- Screens where sharing an implementation between iOS and Android provides significant value
- Screens where fast development and independent bundle deployment are important
- Screens where we can safely fall back to the existing implementation if something goes wrong
Some screens will therefore remain native, while for others WebView may continue to be the most economical option. What matters is not standardizing every screen on a single technology, but increasing the number of options available and reducing the cost of each choice.
There are also more optimizations left to explore. We still need to evaluate the effects and trade-offs of bundle preloading, runtime preparation --- or prewarming --- and native initial-data injection. Before expanding Lynx to more screens, we also need to continue improving observability, error isolation, and compatibility policies between bundle and engine versions.
One More Option
Back in March 2025, I decided to push forward with Lynx after seeing a small JavaScript runtime, a Dual-Thread Architecture centered around the first frame, a toolchain familiar to frontend engineers, and an architecture that could be introduced into an existing app only where needed.
But choosing a technology and building a development platform that multiple organizations can use reliably and comfortably are two very different things. Gina implemented the first screens. Ray and Luke integrated the runtime into the production iOS and Android apps, respectively. We built a bundle deployment flow on top of Warp, designed caching and native interfaces, and SEED Design began supporting Lynx as well.
I don't think Lynx is the one right answer that should replace every screen. It is closer to an additional option --- one that lets us preserve the native foundation of the app while building some of the screens previously handled by WebView in a different way.
Karrot no longer has to choose only between native and WebView. We are beginning to have a new execution environment that can be selected based on the characteristics of each screen, along with the rails required to operate that environment. Those rails were not built by a single technology. They are the result of frontend and mobile engineers owning the same problem and solving it together.
Where We Use Lynx Today
Karrot currently uses Lynx on the screens below, and several teams are planning to introduce Lynx to additional key product surfaces. I'll continue updating this post as things change. (Updated August 12, 2026)
- [Korea] Marketplace Detail → Share Bottom Sheet (Appears when you tap the Share icon in the upper-right corner.)
- [Korea] My Karrot → Recently Viewed
- [Korea] My Karrot → Favorites (Appears when you pull down on the My Karrot screen.)
(Left) Share Bottom Sheet / (Center) Recently Viewed / (Right) Favorites
Laying the Rails Beyond WebView: Why Karrot Chose Lynx was originally published in 당근 기술 블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.