A React Native WebView debugging story about LCP, data URLs, and trace attributes
We recently ran into a strange Sentry performance issue in a React Native app.
The short version:
Our LCP transaction existed in Sentry, but we could not query it by the attributes we attached to it.
That sounded contradictory at first. If the transaction was there, why could not Trace Explorer find it?
The answer turned out to be a single span attribute:
lcpUrl = data:image/png;base64,...
In one iOS event, that value was about 114KB before Sentry normalized it.
The symptom
We were measuring WebView performance inside a React Native app. For each WebView page load, we reported two custom Sentry transactions:
Online Customer Support (FCP)
Online Customer Support (LCP)
Both transactions used the same operation:
ui.web_page_load
And we attached searchable attributes such as:
metricInfo = FCP | LCP
pageTitle
pageUrl
host
path
durationMs
In Sentry Transaction Summary, the LCP transaction was visible. We could open sampled events and see slow LCP data for the page.
But in Trace Explorer, this query returned no iOS LCP rows:
span.op:ui.web_page_load metricInfo:LCP os.name:iOS pageTitle:"Online Customer Support"
FCP worked. Android worked. iOS LCP did not.
That is the kind of bug that makes you distrust the dashboard before you distrust your own instrumentation.
What we found in the raw event
When we pulled the raw event from the Sentry API, the iOS LCP transaction existed. The transaction name was correct:
Online Customer Support (LCP)
But the trace data was not what we expected. The event still had fields like:
durationMs
host
lcpElement
lcpUrl
But important fields we expected to query by were missing from the indexed span data:
metricInfo
pageTitle
pageUrl
path
The suspicious field was lcpUrl.
For that iOS LCP event, lcpUrl was not a normal network URL. It was a base64 data URL:
data:image/png;base64,...
Sentry showed the value as truncated, and the event metadata indicated the original value was much larger. In our case, the original attribute value was around 114KB.
The production difference: iOS sent 114KB, Android sent 100 characters
This was the detail that made the investigation click.
In production, the same WebView LCP instrumentation behaved very differently across platforms:
| Field | iOS | Android |
|---|---|---|
lcpUrl original length |
114,130 chars | 100 chars |
Sentry _meta
|
Saved as 7,683 chars with !limit
|
Complete |
metricInfo |
Missing | Present |
pageTitle |
Missing | Present |
pageUrl |
Missing | Present |
The iOS LCP transaction was real, but it carried an enormous lcpUrl value. Sentry normalized the oversized field, and the attributes we depended on for aggregation were not available in Explore.
That explains the contradiction:
- Transaction Summary could still find the event by transaction name.
- Explore could not find it by attributes like
metricInfo,pageTitle, orpath.
Android looked fine because its entry.url value was already short before we sent it to Sentry.
Why Android looked fine
We built a small WebView test page to isolate where the truncation happened.
The test page generated a real 800x500 PNG, embedded it as a base64 data URI, and made it the LCP candidate. The full data URI was 1,601,014 characters.
On Android, the results were:
DOM img.src.length = 1,601,014
AndroidBridge.postLcpUrl(url) = 1,601,014
PerformanceObserver entry.url = 100
That told us the React Native bridge was not truncating the value. A normal JavaScript-to-Android bridge could receive the full 1.6MB string.
The 100-character value came from PerformanceObserver / Chromium before our injected script sent anything to React Native.
On iOS Safari WebView, entry.url kept the full data URI. That is closer to the literal resource URL, but in this case it was exactly what made the telemetry event dangerous.
The platform chain looked like this:
iOS Safari WebView
PerformanceObserver -> entry.url = full base64 URI
-> postMessage to React Native
-> Sentry span attribute
-> oversized field normalized
-> aggregation attributes missing in Explore
Android Chromium WebView
PerformanceObserver -> entry.url = 100 characters
-> Sentry span attributes stay small
-> dashboard query works
We also found Chromium source paths that use a 100-character URL elision pattern for internal presentation/logging-style output. I would treat this as an implementation detail, not a Web API contract. It explained our production data, but it is not something application logic should depend on.
Why did LCP have a data URL?
The browser Largest Contentful Paint API can expose information about the element that became the LCP candidate. Depending on the content, the entry may include a URL-like value for the resource.
In a WebView, the largest contentful element can be an image. If that image is embedded as a data URL, the value can become:
data:image/png;base64,<a very long string>
Our injected WebView script collected that value and forwarded it to React Native. React Native then attached it as a Sentry span attribute.
That was the mistake.
We did not actually need the image bytes. We only wanted to measure page performance by page title, host, path, metric type, and duration.
The fix
We removed lcpUrl and lcpElement from the Sentry span attributes.
Before, our LCP payload included extra diagnostic details:
reportWebSpan("LCP", lcpValue, {
lcpElement,
lcpUrl,
});
After the fix, we only report the metric value and stable dimensions:
reportWebSpan("LCP", lcpValue);
The Sentry attributes now stay small and useful:
metricInfo
pageTitle
pageUrl
host
path
durationMs
After removing the oversized fields, the LCP data appeared correctly in the query again.
What I should have caught earlier
This bug came from a well-intentioned bit of instrumentation code. It tried to capture more context for LCP debugging. That sounds reasonable when you are writing the code.
But observability data has different rules from application data.
A field is not harmless just because it is technically available. Before sending it to a telemetry system, it should pass a few checks:
- Is it bounded in size?
- Is it low-cardinality enough to query and aggregate?
- Does it help answer a real production question?
- Could it contain private or user-specific data?
- Could one bad value make the rest of the event harder to index or search?
lcpUrl failed those checks.
lcpElement was also not needed for our dashboard. It added noisy DOM details without improving the metrics we actually used.
One more small trap: our performance transactions were sampled. With tracesSampleRate: 0.2, historical examples were naturally sparse. That made the bug feel more mysterious than it was.
Lessons learned
For custom performance instrumentation, especially in WebViews, keep span attributes boring.
Good attributes are small, stable, and useful for grouping:
metricInfo = LCP
pageTitle = Online Customer Support
host = example.com
path = /customer-service
durationMs = 3020
Bad attributes are large, unique, or accidentally full of payload data:
lcpUrl = data:image/png;base64,...
outerHTML = <huge DOM subtree>
requestBody = ...
My final notes from this one:
-
entry.urlis not equally useful across WebViews. Android Chromium may give you a short, elided value, while iOS Safari WebView may give you the full data URI. - Sentry span attributes should not contain potentially huge strings.
- A platform that silently truncates a dangerous value can hide a bug in your instrumentation.
- Spec-like behavior is not always operationally safe. Returning the full URL may be correct, but sending it to telemetry was still wrong.
The dashboard was not wrong. The data model was wrong.
And in this case, deleting two fields made the metric visible again.
Top comments (0)