home blog pics

Shipping a Lynx app on Android — what the docs don't tell you

08/02/2026

Notes from building Bestå with Lynx (ReactLynx + a Kotlin shell) — embedding LynxView, writing native modules, and surviving Android's 16 KB page-size check with an ELF patch.

Shipping a Lynx app on Android — what the docs don’t tell you

I recently shipped Bestå, a Norwegian exam-prep app, built with Lynx — the cross-platform UI framework ByteDance open-sourced. Plenty of people have written “hello world in Lynx” posts. Almost nobody has written about taking a Lynx app all the way to a signed Android build in 2026. These are the notes I wish I’d had.

The shape of a Lynx Android app

Lynx doesn’t own your app the way Flutter or a default React Native template does. You write a plain Android app in Kotlin, and Lynx renders into a LynxView you embed wherever you want. The JavaScript side is a separate build artifact:

  • JS side: ReactLynx (@lynx-js/react) built with rspeedy, which outputs a single main.lynx.bundle
  • Native side: a normal Gradle project depending on org.lynxsdk.lynx:lynx:4.0.0, with a MainActivity that instantiates a LynxView and points it at the bundle

The integration is refreshingly boring. A Gradle copy task moves the bundle from the JS workspace into assets/ before every build, so ./gradlew installDebug always ships whatever npm run build last produced.

The parts that stay native — and this is the part I’d emphasize to anyone evaluating Lynx — are exactly the parts that should be native. Bestå’s Kotlin shell owns text-to-speech, speech recognition, exact-alarm reminders, haptics, and storage. Each is a small Lynx “native module”: a Kotlin class whose annotated methods become callable from JS. The JS side stays a pure UI + logic layer, and every platform capability lives behind an interface you can unit-test.

Native modules are the good part

A module is registered once on the native side and called from JS like any async API. The friction is low enough that decomposing your app along this seam becomes the natural architecture: my reminder engine is ~200 lines of Kotlin around AlarmManager, and the JS side just says “schedule a reminder at 19:30”.

Two lessons there:

  1. Don’t reach for WorkManager for user-facing reminders. It batches and defers; a study reminder that fires forty minutes late is a broken feature. AlarmManager.setExactAndAllowWhileIdle (plus a boot receiver to re-register) is what you actually want.
  2. Design module APIs as data in, data out. Anything stateful you put on the bridge will eventually bite you during a hot reload.

The 16 KB page-size ambush

Here’s the one that cost me an evening. Modern Android (16+) supports 16 KB memory pages, and the OS now audits every native library in your APK. If any packaged .so has a PT_GNU_RELRO segment whose end isn’t 16 KB-aligned, your app gets flagged (PAGE_SIZE_APP_COMPAT_FLAG_RELRO_NOT_ALIGNED) and the system shows a compatibility warning dialog on every launch. Not a crash — worse: a scary dialog your users see each time they open the app.

The Lynx 4.0.0 prebuilts (and the Fresco 2.3.0 ones it pulls in) were linked with 4 KB pages, so their RELRO segments end mid-page. You can’t rebuild someone else’s prebuilt, and waiting on upstream wasn’t an option. But you can patch the ELF program header: shrink p_memsz/p_filesz of the PT_GNU_RELRO segment so it ends on the previous 16 KB boundary.

Is that safe? RELRO is a hardening feature — the dynamic linker mprotects that range read-only after relocation. Ending it earlier leaves at most 12 KB of the GOT writable, which is exactly what a 16 KB-page device would round it to anyway. The patch is ~60 lines of Python with struct; the interesting part is wiring it into the build so it runs on the merged native libs before they’re stripped and packaged:

tasks.configureEach {
    if (name.matches(Regex("merge\w*NativeLibs"))) {
        doLast {
            val script = projectDir.parentFile.resolve("scripts/fix_relro_alignment.py")
            outputs.files.files.filter { it.isDirectory }.forEach { dir ->
                val proc = ProcessBuilder("python3", script.absolutePath, dir.absolutePath)
                    .redirectErrorStream(true)
                    .start()
                proc.inputStream.bufferedReader().forEachLine { println(it) }
                if (proc.waitFor() != 0) {
                    throw GradleException("fix_relro_alignment.py failed for $dir")
                }
            }
        }
    }
}

Hooking merge*NativeLibs by name pattern means it covers debug, release, and any future variants without listing them. If the script fails, the build fails — silently shipping unpatched libraries would put the dialog right back in front of users.

Related decision: the APK is arm64-v8a only (abiFilters += "arm64-v8a"). Every 16 KB-page device is arm64, patching one ABI’s worth of libraries is easier to verify, and dropping 32-bit halved the APK.

Smaller things worth knowing

  • Hardware back button: Lynx won’t handle it for you. I ended up with a tiny JS-side nav stack and a native module that tells the activity whether JS consumed the back press.
  • Edge-to-edge: you’re embedding a view, so insets are your problem. Handle them once in MainActivity and pass safe-area values down to JS.
  • Fonts: Lynx’s font handling has sharp edges — variable-weight TTFs and icon subsets behaved unpredictably for me; full static TTFs worked. Budget time for this if your design system depends on a specific type stack.

Would I pick Lynx again?

For this app — yes. The dual-thread model gives you main-thread scrolling and taps that feel native in a way I’ve struggled to get from React Native without heavy tuning, and the “plain Android app that hosts a view” architecture means nothing about the platform is hidden from you. The cost is a younger ecosystem: fewer libraries, fewer Stack Overflow answers, and occasionally an evening spent patching ELF headers because you’re earlier on the adoption curve than the SDK’s CI is.

If you’re doing the same dance, the RELRO patch script is the thing to steal: check your APK with zipalign-era tooling replaced by llvm-readelf -l on each .so, look for GNU_RELRO end addresses, and align them down. Your users never need to know what a page size is.