Engineering

Text is the most expensive composable you use without thinking

Layout measures text to resolve constraints, and a changing style invalidates the cache.

MobileByteSensei 8 min read
composeperformanceandroid

TL;DR

Text("Hello") looks like the cheapest thing in Compose. Measuring it means shaping glyphs, applying font fallback, resolving line breaks against the incoming constraints, and producing a TextLayoutResult. That is real work — and in a list it happens per item.

Compose caches it, keyed on the text, the style, the constraints and the density. Construct a new TextStyle inline, or pass a slightly different width, and the cache misses every frame.

The common mistakes are all cache-defeating: a TextStyle(...) built in the composable body, a copy() on every recomposition, and AnnotatedString rebuilt per frame. Hoist them and the cost disappears.

The case this post is about

A list row with three text elements, styled inline because that reads naturally:

@Composable
fun MessageRow(message: Message, highlight: String) {
    Column {
        Text(
            text = message.sender,
            style = MaterialTheme.typography.titleSmall.copy(   // new object every recomposition
                fontWeight = if (message.unread) FontWeight.Bold else FontWeight.Normal,
            ),
        )
        Text(
            text = buildAnnotatedString {                       // rebuilt every recomposition
                val i = message.body.indexOf(highlight, ignoreCase = true)
                if (i >= 0) {
                    append(message.body.substring(0, i))
                    withStyle(SpanStyle(background = Yellow)) {
                        append(message.body.substring(i, i + highlight.length))
                    }
                    append(message.body.substring(i + highlight.length))
                } else append(message.body)
            },
            maxLines = 2,
        )
        Text(formatRelative(message.at), style = TextStyle(fontSize = 12.sp, color = Grey))
    }
}

Idiomatic-looking, and each of those three has a different cache-defeating property.

The symptom

Scroll jank that profiling attributed to text:

Macrobenchmark, scroll 60 messages
  frame P50   11.2ms
  frame P95   78.4ms
  jank        14.6%
Android Studio profiler, top frames during scroll
  38.1%  androidx.compose.ui.text.MultiParagraph.<init>
  12.4%  androidx.compose.ui.text.font.FontFamilyResolver.resolve
   9.7%  android.graphics.text.LineBreaker.computeLineBreaks

Sixty percent of scroll time in text measurement, in a list whose rows are three Text calls.

And a second, subtler symptom: typing in the search box — which changes highlight — re-measured every visible row even for rows containing no match:

type one character
  MultiParagraph constructions: 60     ← one per visible+cached row

Why the obvious fix failed

"Reduce the font count." We had two families. Font resolution was 12% of the profile, not the 38%, and consolidating to one moved the needle by about a millisecond.

"Use maxLines to limit work." Already present on the body. maxLines bounds line breaking, and the glyph shaping and style resolution still happen.

"Cache the formatted timestamp." Correct and small — formatRelative was not in the profile. The expensive part was measuring the result, not producing it.

"Draw the text manually on a Canvas." Considered, briefly. It is a large amount of work to reimplement font fallback and bidi, and it discards accessibility and selection. The actual problem turned out to be three one-line mistakes.

The actual mechanism

Measuring text is genuinely expensive, in four stages:

Style resolution. Merging the passed TextStyle with the theme's and resolving the FontFamily to concrete typefaces — including fallback chains for characters the primary font lacks.

Glyph shaping. Mapping characters to glyphs with their advances, which for complex scripts involves ligatures and reordering.

Line breaking. Fitting shaped glyphs into the incoming maxWidth, which is why the constraints are part of the problem.

And producing a TextLayoutResult that layout and drawing both consume.

Compose caches this, and the cache key is the part that matters: the string, the style, the constraints, the density, the font resolver, and the layout direction. A miss on any component re-does all four stages.

So each of the three mistakes defeats a different key component:

typography.titleSmall.copy(...) constructs a new TextStyle on every recomposition. copy() produces a new instance; TextStyle has a value-based equals, so a cache keyed by equality would hit — except the composable's skipping also sees a new unstable-ish parameter, and in a list the recompositions were happening anyway. The real cost here is that the copy runs 60 times per frame and feeds a fresh style object into measurement each time.

buildAnnotatedString { } produces a brand-new AnnotatedString every recomposition, with different content whenever highlight changes — a genuine cache miss, every row, every keystroke.

TextStyle(fontSize = 12.sp, color = Grey) constructs a new instance per call. Hoisting it to a constant makes it a single shared instance.

Four stages of text measurement — style resolution with font fallback, glyph shaping, line breaking against the incoming constraints, and producing a layout result — behind a cache keyed on the string, style, constraints, density and layout direction, with three inline constructions in a list row each missing that cache sixty times per frame

Text measurement is cached, and the cache key includes the style object and the constraints. Anything constructed inside the composable body is a new key each time, and in a list that is per item per frame.

The sub-case that survives all three fixes, because it defeats a different part of the key: the constraints. Hoist every style to a constant, memoise the annotated string perfectly, and a Text whose incoming maxWidth changes each frame still re-shapes and re-breaks on every one of them.

That happens more often than it sounds, because the width is rarely animated on purpose:

Row {
    Text(item.title, Modifier.weight(1f), style = TitleStyle)   // hoisted, still re-measured
    AnimatedVisibility(visible = item.isSyncing) { SyncSpinner() }
}

The spinner animates its own width in and out over 300ms, the Row redistributes, and the title's maxWidth lands on a different pixel value every frame — roughly eighteen distinct cache keys for one string that never changed. The style is constant; the constraint is not, and the key needs both.

The fixes are all about making the width stop moving: reserve the space with a fixed-size slot rather than animating it, animate alpha instead of visibility, or give the text a fixed width where the design allows. The diagnostic is the useful part — if measurement cost persists after the style work, stop looking at styles and watch whether the width is stable, because a moving constraint invalidates a cache that is otherwise working exactly as intended.

The fix

Hoist the styles, memoise the annotated string, and stop recomputing what did not change.

// hoisted to file scope — one instance, shared by every row
private val TimestampStyle = TextStyle(fontSize = 12.sp, color = Grey)

@Composable
fun MessageRow(message: Message, highlight: String) {
    // resolved once per theme change, not per row per frame
    val senderStyle = MaterialTheme.typography.titleSmall
    val senderUnreadStyle = remember(senderStyle) {
        senderStyle.copy(fontWeight = FontWeight.Bold)
    }

    Column {
        Text(
            text = message.sender,
            style = if (message.unread) senderUnreadStyle else senderStyle,   // pick, don't build
        )
        Text(
            text = rememberHighlighted(message.body, highlight),
            style = MaterialTheme.typography.bodyMedium,
            maxLines = 2,
        )
        Text(formatRelative(message.at), style = TimestampStyle)
    }
}

@Composable
private fun rememberHighlighted(body: String, highlight: String): AnnotatedString =
    remember(body, highlight) {                       // ← keyed on both inputs
        if (highlight.isEmpty()) return@remember AnnotatedString(body)
        val i = body.indexOf(highlight, ignoreCase = true)
        if (i < 0) return@remember AnnotatedString(body)        // ← the common case, cheap
        buildAnnotatedString {
            append(body.substring(0, i))
            withStyle(SpanStyle(background = Yellow)) { append(body.substring(i, i + highlight.length)) }
            append(body.substring(i + highlight.length))
        }
    }

Four decisions:

Two pre-built styles, selected by a condition. if (unread) boldStyle else normalStyle picks between two cached instances instead of constructing one. The remember(senderStyle) keys on the theme style so a theme change rebuilds it.

TimestampStyle at file scope. A TextStyle with no theme dependency does not belong inside a composable at all.

rememberHighlighted keyed on both inputs. The body rarely changes and the highlight changes per keystroke, so the key is honest about both — and a row with no match returns a plain AnnotatedString, which is the case for most rows most of the time.

And the early return for no match is the biggest single win on the typing path: 56 of 60 rows contain no match, so they now produce an equal AnnotatedString and hit the measurement cache.

One more thing worth knowing: if you need the measured result yourself, measure once and reuse it:

val measurer = rememberTextMeasurer()
val layout = remember(text, style, constraints) { measurer.measure(text, style, constraints = constraints) }

Rather than calling measure in a draw or layout block, which runs per frame.

How we proved it

The profile, before and after:

share of scroll time
                                  before    after
MultiParagraph.<init>              38.1%     4.2%
FontFamilyResolver.resolve         12.4%     0.3%
LineBreaker.computeLineBreaks       9.7%     1.1%

Frame timing:

Macrobenchmark, scroll 60 messages, Pixel 6a
                 before    after
frame P50        11.2ms    6.3ms
frame P95        78.4ms    8.9ms
jank frames      14.6%     0.2%

And the typing path, which the early return targeted:

type one character into the search box
  MultiParagraph constructions   before 60   after 4

Four, which is the number of visible rows that actually contained the search term. That is the measurement I would point at — it shows the fix is doing the specific thing intended rather than generally being faster.

A regression test on the construction count, since hoisted styles are exactly the kind of thing a later edit inlines again:

@Test
fun styleObjectsAreNotRebuiltPerRecomposition() {
    var styleConstructions = 0
    composeTestRule.setContent {
        val s = remember { styleConstructions++; TimestampStyle }
        repeat(20) { Text("x", style = s) }
    }
    repeat(20) { composeTestRule.runOnIdle { /* force recomposition */ } }
    assertEquals(1, styleConstructions)
}

One limit worth stating plainly: these are one device, one font configuration, and English text. Complex-script text (Arabic, Devanagari, CJK) is substantially more expensive to shape, so the absolute numbers will differ — the cache behaviour is the same, and the win is proportionally larger where shaping costs more.

What this generalizes to

The cheapest-looking call can be the most expensive. Text is one line and does four stages of work. Any API that hides a pipeline behind a simple signature deserves a profile before assumptions — and the profiler attributing 38% to a class you have never heard of is the fastest way to find it.

A cache key you construct inline is a cache you do not have. Styles, configuration objects, comparators, regexes, formatters, query objects. Constructing one inside a hot loop or a recomposing function defeats whatever memoisation sits behind it.

Optimise the common case explicitly. Most rows had no search match. Adding an early return for that path was a larger win than any of the hoisting, because it turned 56 expensive operations into 56 cheap ones.

Hoist what does not depend on the call. A style with no theme or parameter dependency is a constant. Putting constants at file scope is both faster and clearer about intent, which is the pleasant case where performance and readability point the same way.

Keep reading

This is part of 100 Days of Compose — the mechanisms underneath the API, measured rather than guessed. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS