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 singlemain.lynx.bundle - Native side: a normal Gradle project depending on
org.lynxsdk.lynx:lynx:4.0.0, with aMainActivitythat instantiates aLynxViewand 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:
- 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. - 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
MainActivityand 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.