DEV Community

Anietimfon Effiong
Anietimfon Effiong

Posted on

Clear the Lineup: Killing a Silent, Three-Cause Rendering Bug in a Flutter + Mapbox Noise Map

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

Ihanyi Elechi is a Flutter app that visualizes urban noise pollution as a live heatmap over real city maps. Sensor readings stream in from Firebase Firestore, get interpolated into a smooth noise surface using IDW (inverse distance weighting), and get painted onto a Mapbox map as a raster layer — similar to a weather radar overlay, but for decibels instead of rain. The contour surface is the app's core feature: without it, the app is just a list of unconnected data points on a map.

Bug Fix or Performance Improvement

The IDW noise contour layer would silently fail to render on some Android devices — no exception, no crash log, no error toast. It wasn't reproducible on my own test device, which made it especially hard to pin down. After ruling out malformed data (I validated every GeoJSON and PNG payload before it reached Mapbox — they were fine), I traced it to three separate, compounding root causes:

  1. A coordinate type mismatch from an older Firestore ingestion path, where some latitude/longitude fields arrived as integers instead of doubles, silently corrupting the bounding-box math used to place the raster.
  2. A Firestore query that depended on a composite index it didn't reliably have once a where filter was combined with an orderBy, causing thin or empty snapshots with zero client-side error.
  3. A race condition on the Mapbox Android bridge: layer-building work runs in a background isolate, and if the user switched map styles during that window, layer calls would silently target a style object that no longer existed.

None of these alone reliably reproduced the bug — it only appeared when a specific overlap of bad data, thin query results, and a badly-timed style switch happened together.

Code

(Private repo — code changes included as snippets below. Happy to share a diff/screenshot on request.)

1. Style generation guard to stop mutating a torn-down Mapbox style:

int _styleGeneration = 0;

void onStyleLoaded() {
  _styleGeneration++;
  _isMapReady = true;
}

Future<void> _doLayerUpdate() async {
  if (!_isMapReady || _isUpdatingLayers || _mapboxMap == null) return;
  if (visibleReadings.isEmpty) return;

  _isUpdatingLayers = true;
  final gen = _styleGeneration;

  try {
    final results = await Future.wait([
      compute(buildGeoJsonFromReadings, rawList),
      compute(buildRasterBytes, rasterPayload),
    ]);

    if (_mapboxMap == null || _disposed || _styleGeneration != gen) return;
    await _cleanOldLayers();
    if (_mapboxMap == null || _disposed || _styleGeneration != gen) return;
    await _mapboxMap!.style.addSource(GeoJsonSource(id: 'noise-source', data: geoJson));
    if (_mapboxMap == null || _disposed || _styleGeneration != gen) return;
    // ...remaining layer calls, each gated the same way
  } finally {
    _isUpdatingLayers = false;
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Replacing an unreliable native bridge call with a standard image pipeline:

final dir = await getTemporaryDirectory();
final pngFile = File('${dir.path}/noise_raster.png');
await pngFile.writeAsBytes(rasterBytes, flush: true);
final pngUrl = Uri.file(pngFile.path).toString();

await _mapboxMap!.style.addSource(ImageSource(
  id: 'noise-raster-source',
  url: pngUrl,
  coordinates: [
    [bMinLng, bMaxLat],
    [bMaxLng, bMaxLat],
    [bMaxLng, bMinLat],
    [bMinLng, bMinLat],
  ],
));
Enter fullscreen mode Exit fullscreen mode

3. Renderability guard against bad coordinate types + query simplified to avoid the composite index dependency:

bool _isRenderable(Noise n) =>
    n.latitude.abs() <= 90 && n.longitude.abs() <= 180 && n.noiseLevel > 0;

List<Noise> _parseSnapshot(QuerySnapshot snap) {
  return snap.docs
      .map((d) => Noise.fromMap({...d.data() as Map<String, dynamic>, 'id': d.id}))
      .whereType<Noise>()
      .where(_isRenderable)
      .toList();
}

_allDataSub = _db
    .collection('noise_readings')
    .orderBy('timestamp', descending: true)
    .limit(1200)
    .snapshots()
    .listen(_onAllData, onError: _onStreamError);
Enter fullscreen mode Exit fullscreen mode

4. Fail-loud fallback instead of a silent empty result:

if (nearby.isEmpty) {
  final sorted = [...allReadings];
  sorted.sort((a, b) => _calculateDistanceKm(center.lat, center.lng, a.latitude, a.longitude)
      .compareTo(_calculateDistanceKm(center.lat, center.lng, b.latitude, b.longitude)));
  readings = sorted.take(120).toList();
  debugPrint('⚠️ No nearby readings. Using fallback nearest readings: ${readings.length}');
}
Enter fullscreen mode Exit fullscreen mode

My Improvements

My approach was to stop assuming this was one bug. Once I confirmed the data payloads were valid at every stage, I had to accept the failure was happening between stages rather than in any single stage — which pointed toward timing and state, not data shape. That reframing was the actual unlock: I started asking "why would this pipeline abort with zero signal?" instead of "why is this raster malformed?"

The style-generation counter was the most interesting technical decision. A simple _mapboxMap != null check isn't enough when the object reference is still valid but represents a torn-down state — Flutter's async/await model means a style teardown can happen mid-pipeline, and every await boundary needs to independently verify it's still safe to proceed. I also chose to abandon updateStyleImageSourceImage entirely rather than patch around its inconsistency, since routing through a standard file-based ImageSource sidesteps an entire class of Android GPU driver quirks instead of chasing them one device at a time.

Fixing all three causes together — not just the most visible one — is what made the fix reliable across devices instead of just reducing the failure rate.

Top comments (0)