DEV Community

Cover image for The Contours That Refused to Exist: A Three-Bug Pileup in a Flutter Noise Map
Anietimfon Effiong
Anietimfon Effiong

Posted on

The Contours That Refused to Exist: A Three-Bug Pileup in a Flutter Noise Map

Summer Bug Smash: Smash Stories 🐛🛹

The Contours That Refused to Exist: A Three-Bug Pileup in a Flutter Noise Map

The setup

I built Ihanyi Elechi, a Flutter app that visualizes urban noise pollution as a live heatmap over real city maps. Sensor readings stream in from Firestore, get interpolated into a smooth noise surface using IDW (inverse distance weighting), and get painted onto a Mapbox map as a raster layer — think weather radar, but for decibels instead of rain.

The core promise of the app is the contour surface. If it doesn't render, the app is just a list of dots on a map. So when it silently stopped appearing on certain devices and certain zoom levels, that was a five-alarm bug.

The symptom

On some Android devices — never consistently reproducible on my own test phone, which is the worst kind of bug — the noise contour layer simply wasn't there. No error toast, no crash log, no exception in flutter run. The heatmap markers loaded fine. The base map loaded fine. The contour surface just... didn't exist. Users would zoom in, zoom out, switch map styles, and the colored noise overlay would never appear.

Chasing the wrong things first

My first instinct was that this was a rendering bug — maybe the GeoJSON was malformed, or the raster bytes were corrupted. I spent a day logging payload sizes and validating the generated PNG bytes before the file even reached Mapbox. They were fine. The image was valid every time.

That ruled out the data itself. So the problem had to be somewhere in the pipeline between "data is ready" and "layer is on screen" — and that's where it got interesting, because it wasn't one bug. It was three, and they only failed together.

Root cause #1 — a coordinate type mismatch silently killing valid readings

Some historical documents in the noise_readings collection had latitude/longitude stored as Firestore integers (from an earlier ingestion script) instead of doubles. My renderability filter:

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

looked harmless, but upstream, Noise.fromMap wasn't normalizing numeric types consistently. Depending on how a given document had been written, latitude could arrive as an int, and a stray type coercion elsewhere in the parsing chain meant a subset of readings got dropped or, worse, defaulted to 0.0. Zero readings clustered at (0,0) don't throw — they just quietly poison the bounding-box calculation used to place the raster image on the map.

Root cause #2 — a Firestore query that needed a composite index it didn't have

The live stream query orders by timestamp and limits to the most recent 1200 documents:

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

On its own this doesn't need a composite index. But an earlier version of this query added a where clause on area for regional filtering, alongside the orderBy. Firestore needs a composite index for that combination, and without it the query doesn't throw a loud client-side error in release builds the way it does in debug — it just returns an empty or partial snapshot on some devices depending on cached index state. That meant allReadings was sometimes a near-empty list, which meant the bounding box for the raster was degenerate (a single point, or NaN-adjacent), so _doLayerUpdate would bail out silently:

if (!_isMapReady || _isUpdatingLayers || _mapboxMap == null) return;
if (visibleReadings.isEmpty) return;
Enter fullscreen mode Exit fullscreen mode

No crash. No log. Just... nothing drawn.

Root cause #3 — a threading/style-generation race on the Mapbox Android bridge

This was the one that made the bug device- and timing-dependent rather than deterministic. Building the raster PNG happens off the main isolate:

final results = await Future.wait([
  compute(buildGeoJsonFromReadings, rawList),
  compute(buildRasterBytes, rasterPayload),
]);
Enter fullscreen mode Exit fullscreen mode

That's good practice — you don't want IDW math and Gaussian blurring blocking the UI thread. But it means there's a real gap in time between "start building the layer" and "attach the layer to the map." On slower Android devices, if the user switched map styles or the style reloaded during that gap, Mapbox's Android bridge would either silently no-op the addSource/addLayer calls against a style object that no longer existed, or — worse — attach the new layer to a style that was already being torn down.

The fix was a style generation counter, checked before every mutating call:

int _styleGeneration = 0;

void onStyleLoaded() {
  _styleGeneration++;
  ...
}

Future<void> _doLayerUpdate() async {
  ...
  final gen = _styleGeneration;
  ...
  if (_mapboxMap == null || _disposed || _styleGeneration != gen) return;
  await _cleanOldLayers();
  if (_mapboxMap == null || _disposed || _styleGeneration != gen) return;
  await _mapboxMap!.style.addSource(GeoJsonSource(...));
  ...
}
Enter fullscreen mode Exit fullscreen mode

Every single await point in the layer-building pipeline re-checks that the style generation hasn't changed underneath it. If it has, the whole update aborts cleanly instead of throwing on a stale native style handle.

I also stopped relying on updateStyleImageSourceImage for pushing the raster bytes to the map — it's unreliable across different Android GPU drivers — and switched to writing the PNG to a temp file and loading it via a file:// URL through a standard ImageSource, which uses Mapbox's normal image-loading pipeline instead of a bridge call known to misbehave:

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

Why it took three fixes, not one

None of these three bugs alone reliably reproduced the missing contours:

  • Bad coordinate types alone just shifted the bounding box a bit — annoying, but the raster still usually appeared somewhere.
  • The missing composite index alone meant fewer readings, but often still enough to draw something.
  • The style-generation race alone only mattered if a style switch happened at exactly the wrong moment.

Combined, they compounded: bad data made bounding boxes fragile, missing readings made the raster payload marginal, and a badly-timed style switch turned "marginal" into "nothing rendered, no error." That's why it looked unreproducible — it needed a specific overlap of data state and user timing.

The fallback that also had to change

While fixing this, I also hardened the "no readings near the camera" fallback, which had a similar silent-failure shape — it now explicitly logs and falls back to the nearest historical readings within a capped radius rather than returning an empty set and letting the layer pipeline bail silently:

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

Lessons

  1. "No error thrown" is not the same as "nothing went wrong." Both the missing composite index and the stale-style race failed silently by design — Firestore and the Mapbox Android bridge both prefer no-ops over exceptions in these situations, which is exactly what made this bug so hard to trace.
  2. Type discipline at the data layer matters more than it seems. A single inconsistently-typed field, written months earlier, was still causing failures downstream in a completely unrelated rendering pipeline.
  3. Async pipelines need generation guards, not just null checks. Checking _mapboxMap != null isn't enough when the object is still valid but represents a torn-down state. The style generation counter was the piece that actually made the fix reliable across devices.

Three bugs, one symptom, and a debugging path that only made sense once I stopped treating "the raster is malformed" as the leading hypothesis and started asking why the pipeline would abort with zero signal.

Top comments (0)