
Debug Mobile Chart & Data Visualization Crashes
A data visualization crash is one of the most frustrating failures to debug on mobile, because the stack trace almost never points at your chart. It points at a layout pass, a canvas draw call, or a deallocated data source, while the real culprit is the dataset you handed to the rendering engine. Whether you are building with Swift Charts on iOS 16+, DGCharts for legacy UIKit, MPAndroidChart on Android, or fl_chart in Flutter, the same handful of chart crash causes account for the overwhelming majority of production failures: NaN and Infinity values flowing into axis math, huge datasets rendered on the main thread, data mutated while a draw is in flight, layout cycles, invalid geometry, and off-main-thread access to CoreGraphics or the Android Canvas. This guide walks through each failure class and shows you how to instrument it so the next chart crash ships with enough context to fix it the same day.
Why chart crashes hide in the rendering layer
Chart frameworks are thin wrappers around the platform's drawing system, and that indirection is why their crashes are so hard to read. On iOS, Swift Charts and DGCharts ultimately render through CoreGraphics inside UIView and CALayer; on Android, MPAndroidChart draws through View.onDraw and the Skia-backed Canvas; in Flutter, fl_chart paints through the Skia Canvas exposed by CustomPainter. When one of these crashes, the report names the drawing system, not your data. A CALayerInvalidGeometry exception or a NullPointerException inside onDraw tells you where the drawing died, but the root cause is usually a value you allowed to reach the renderer: an infinite y-coordinate, a zero-width frame, or a data source that was released between the layout pass and the draw pass.
This is the same class of problem we describe in our UI jank and rendering debugging guide: the renderer is a shared, tightly-scheduled resource, and malformed input does not fail at the point where you produced it — it fails later, inside a system you do not control. Treat every chart crash as a two-layer investigation: first identify which framework call died, then trace backwards to the value or lifecycle event that poisoned it.
NaN and Infinity: the silent chart killer
The single most common cause of a chart crash is a non-finite floating-point value entering the framework's axis, label, or geometry math. Chart libraries divide by data ranges to map values onto pixels, and a dataset with a single zero range, a division by zero, or an Infinity produced upstream turns the renderer's arithmetic into NaN. The crash that follows is never labeled "NaN" — on iOS it typically surfaces as an assertion or an exception in the layer system, and on Android it frequently shows up as an ArithmeticException or an uncaught NumberFormatException when the framework tries to format a label string from a non-finite number.
The IEEE 754 standard defines NaN and Infinity as values that propagate through arithmetic rather than throwing, which is exactly why they are dangerous here: your computation does not fail where the non-finite value is born, it fails wherever the value is finally consumed, which is usually deep inside the rendering library. A classic repro is a horizontal bar chart whose values are all zero, producing a zero range and therefore a division by zero when the framework normalizes the axis.
The fix is to sanitize at the boundary, before data reaches the chart. In Swift Charts and DGCharts, filter or clamp non-finite values as you build the data model:
let safePoints = rawPoints.compactMap { p -> ChartPoint? in
guard p.x.isFinite, p.y.isFinite else { return nil }
return p
}On Android with MPAndroidChart, the equivalent guard belongs where you construct your Entry list:
val safe = raw.filter { it.x.isFinite() && it.y.isFinite() }
.map { Entry(it.x, it.y) }For Flutter's fl_chart, wrap the same check around every FlSpot. The principle is identical across all four frameworks: never let a non-finite value reach the renderer, because the renderer will not tell you it was non-finite — it will just crash somewhere downstream.
Large datasets rendered on the main thread
The second major cause of chart crashes is not a bad value but a bad volume. Mobile charts routinely receive thousands or tens of thousands of points, and every major framework will happily attempt to draw all of them in a single frame. On iOS that means CoreGraphics spends far longer than one frame inside the main run loop, which triggers the watchdog and kills the app with a hang termination. On Android, the same overload produces an ANR, and in Flutter it manifests as dropped frames that can escalate into a full freeze.
The crash signature here is telling: it is not an exception at all, but a watchdog kill or an ANR whose stack trace shows a draw or layout method as the leaf frame. When you see 0x8badf00d on iOS alongside a draw or render frame, you are looking at main-thread rendering overload, not a logic bug. Our watchdog termination guide covers the mechanics of that kill in detail.
The remedy has two parts. First, downsample before you draw: for a time-series of 100,000 points rendered into a 400-point-wide view, the user can only ever see 400 columns of data, so aggregating into buckets before the framework ever sees the array removes 99.6% of the work. Second, move aggregation off the main thread and only hand the chart a pre-reduced, immutable snapshot. Both DGCharts and MPAndroidChart support disabling animations and setting a maximum visible window, which prevents the "draw everything every frame" behavior that causes the worst of these crashes.
Dataset mutation mid-draw: the race you did not write
The third failure class is a race condition that most teams do not realize they are creating. Chart frameworks are not thread-safe, and they do not snapshot your data unless you tell them to. If a background task mutates the array that backs the chart while the main thread is mid-draw — an entry removed during iteration, a value swapped between the measure and draw passes — the framework iterates a collection that is changing underneath it, which produces IndexOutOfBounds, NSRangeException, or a concurrent-modification crash inside the renderer.
This is especially easy to trigger with real-time charts: a socket handler appends a point on a background queue while the chart redraws on the main thread. The defensive pattern is a single-owner data model with an immutable snapshot handed to the chart. Reassign a new array to the chart's data rather than mutating the existing one in place, and never let a background queue touch the live collection:
// on a background queue
let snapshot = makeImmutableSnapshot(from: raw)
DispatchQueue.main.async {
chart.data = snapshot // chart never sees a half-updated array
}The same discipline applies in React Native with Recharts and Victory Native: state updates must produce a new array reference, never mutate the old one, or the renderer can read an array mid-reconciliation.
Layout cycles and invalid geometry
Layout is where chart crashes get genuinely subtle. Charts are often embedded inside cells, stack views, and scroll views that measure themselves repeatedly, and a chart whose intrinsic content size depends on its own data can enter a feedback loop: the view measures, asks the chart for a size, the chart reports a size that changes the layout, which triggers a re-measure. When that loop never converges, iOS surfaces it as a "Unable to simultaneously satisfy constraints" warning that can escalate into a crash, and Android's layout system can throw an IllegalStateException from a stale measurement pass.
Invalid geometry is the sharper cousin. A chart given a frame of zero width or zero height — for example, one measured before its parent finished laying out, or one inside a collapsed view — hands the renderer a degenerate draw region. CoreGraphics throws CALayerInvalidGeometry when asked to draw into a non-finite or zero-size rect, and the same input produces confusing exceptions in Skia-backed canvas code. The guard is to check the chart's bounds before the first draw and skip rendering when the region is degenerate:
override fun onDraw(canvas: Canvas) {
if (width <= 0 || height <= 0) return // skip degenerate geometry
super.onDraw(canvas)
}Off-main-thread CoreGraphics and Canvas access
CoreGraphics and the Android Canvas are not thread-safe, and both platforms will crash — or corrupt memory in ways that crash later — when drawing primitives are touched from a background thread. This happens most often in chart code because teams try to pre-render charts to an offscreen bitmap on a background queue for performance, then hand the bitmap to the main thread. The pre-render itself violates the platform contract: on iOS, UIKit drawing must happen on the main thread, and on Android, a Canvas derived from a Bitmap is only safe on the thread that created it.
The safe pattern is to do the math off the main thread — aggregation, axis computation, label generation — and keep only the final draw call on the main thread. Generate your labels and your downsampled points in the background, then construct the chart's data object on the main thread and let the framework draw it in its own main-thread pass. This keeps you off the forbidden path entirely while still reclaiming most of the performance benefit.
Deallocated data sources and delegate pointers
The last classic is a lifetime bug: the chart holds a weak reference to a data source or delegate, the object is deallocated, and the next draw call dereferences a dangling pointer. On iOS this surfaces as a EXC_BAD_ACCESS or an over-released object inside the chart's layout or draw pass; on Android it is a NullPointerException in onDraw when a listener or adapter reference has gone stale. In React Native and Flutter, the equivalent is a disposed widget or an unmounted component whose chart still receives a state update after teardown.
The fix is disciplined ownership: null out the chart's delegate and data source in deinit (iOS) or onDetachedFromWindow (Android), and guard draw-time access to optional references. In React Native, cancel any pending state updates in the component's cleanup function so the chart never receives data after unmount.
Instrumenting chart crashes in production
All of these failure classes share one property: the crash report alone is almost never enough, because the bad value or the bad lifetime event is long gone by the time the renderer dies. Attach context at the boundary — the size of the dataset, whether any value was non-finite, whether a mutation or teardown was in flight, and the chart's frame at draw time — as breadcrumbs that ride along with the crash. BugsPulse captures those breadcrumbs alongside the exception, so a bare CALayerInvalidGeometry becomes "chart frame was 0×0 because the cell measured before its parent," which is a fix you can ship today rather than a mystery you chase for a week.
Chart rendering on mobile is a solved problem once you understand the failure surface: sanitize your values, downsample your data, snapshot instead of mutate, and keep drawing on the main thread. Start capturing chart crashes with full context and stop guessing at the stack: https://app.bugspulse.com/register.