Skip to content

[firebase_database]: runTransaction(applyLocally: false) no longer suppresses local-cache emit on 12.1.0+ (regression from #17686) #18247

Description

@eli1stark

Is there an existing issue for this?

  • I have searched the existing issues.

Which plugins are affected?

Database

Which platforms are affected?

Android, iOS

Description

DatabaseReference.runTransaction(updater, applyLocally: false) no longer suppresses the local-cache onValue emit in firebase_database 12.1.0+. The flag is documented to deliver only the server-confirmed value once the transaction has been finalized, but on 12.1.0+ the listener now also receives a local-estimate emit immediately, producing two onValue callbacks per write whenever the payload contains a server-resolved field such as ServerValue.timestamp.

Bisected to 12.1.0 — the Pigeon + iOS Swift / Android Kotlin rewrite shipped in #17686. 12.0.1 (last release before the rewrite) honors the flag correctly. The bug persists through 12.3.0 (latest at time of filing).

Test matrix

firebase_database applyLocally onValue events per write
12.0.1 false 1 ✓ correct
12.0.1 true (default) 2 (local + server, expected)
12.1.0 false 2regression
12.1.0 true (default) 2

Steps to reproduce

  1. flutter create repro && cd repro
  2. Add firebase_core and firebase_database: 12.1.0 to pubspec.yaml.
  3. flutterfire configure against a test Firebase project that has a Realtime Database.
  4. Make sure RTDB rules at the test path allow read and write, e.g. { "rules": { ".read": true, ".write": true } }.
  5. Replace lib/main.dart with the Sample code below. Update _databaseUrl and _path to match your test project.
  6. flutter run on iOS or Android.
  7. Tap the button several times. Each tap → 2 onValue events.
  8. Pin firebase_database: 12.0.1 in pubspec.yaml, flutter pub get, full restart (not hot reload — native plugin must reload), tap again. Each tap → 1 onValue event.

Expected behavior (and what 12.0.1 does)

One onValue event per write, carrying the server-confirmed value:

onValue: 2026-05-03T21:33:39.340054  value={n: 1, ts: 1777869219321}
onValue: 2026-05-03T21:33:40.555765  value={n: 2, ts: 1777869220536}
onValue: 2026-05-03T21:33:41.211048  value={n: 3, ts: 1777869221187}

Actual behavior (12.1.0+)

Two onValue events per write, ~50-70 ms apart, differing only in the ts field — first emit has the local-clock estimate, second has the server-resolved time. applyLocally: false is being ignored:

onValue: 2026-05-03T21:53:05.334006  value={n: 1, ts: 1777870385317}
onValue: 2026-05-03T21:53:05.405591  value={n: 1, ts: 1777870385387}   ← duplicate
onValue: 2026-05-03T21:53:06.440080  value={n: 2, ts: 1777870386423}
onValue: 2026-05-03T21:53:06.512202  value={n: 2, ts: 1777870386492}   ← duplicate
onValue: 2026-05-03T21:53:07.054799  value={n: 3, ts: 1777870387038}
onValue: 2026-05-03T21:53:07.127411  value={n: 3, ts: 1777870387108}   ← duplicate

Sample code

import 'dart:async';

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart'; // generated by `flutterfire configure`

// TODO: replace with your test RTDB URL and a path you can read/write.
const _databaseUrl = 'https://YOUR-INSTANCE.firebaseio.com';
const _path = 'repro/double_emit';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const _ReproApp());
}

class _ReproApp extends StatefulWidget {
  const _ReproApp();
  @override
  State<_ReproApp> createState() => _ReproAppState();
}

class _ReproAppState extends State<_ReproApp> {
  late final DatabaseReference _ref;
  StreamSubscription<DatabaseEvent>? _sub;
  final _events = <String>[];
  int _writeCounter = 0;

  @override
  void initState() {
    super.initState();

    final db = FirebaseDatabase.instanceFor(
      app: Firebase.app(),
      databaseURL: _databaseUrl,
    );
    _ref = db.ref(_path);

    _sub = _ref.onValue.listen((event) {
      final line =
          '${DateTime.now().toIso8601String()}  value=${event.snapshot.value}';
      debugPrint('onValue: $line');
      setState(() => _events.insert(0, line));
    });
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }

  Future<void> _write() async {
    _writeCounter++;
    await _ref.runTransaction(
      (_) => Transaction.success({
        'n': _writeCounter,
        'ts': ServerValue.timestamp,
      }),
      applyLocally: false,
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('runTransaction(applyLocally:false) regression'),
        ),
        body: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(16),
              child: ElevatedButton(
                onPressed: _write,
                child: const Text(
                  'runTransaction(applyLocally:false) + ServerValue.timestamp',
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Text('onValue events received: ${_events.length}'),
            ),
            const Divider(height: 1),
            Expanded(
              child: ListView.builder(
                itemCount: _events.length,
                itemBuilder: (_, i) => ListTile(
                  dense: true,
                  title: Text(_events[i]),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Environment

  • firebase_database: 12.1.0 (broken) / 12.0.1 (works) — bug persists on 12.3.0
  • firebase_core: 4.7.0
  • Flutter 3.41.5 (stable channel)
  • Dart 3.11.3
  • Verified on iOS (physical device + simulator) and Android

Related

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions