The moment your app needs to remember
You've got a Flutter app. It works. State lives in BLoC or Riverpod, screens rebuild beautifully, and the API calls are crisp. Then someone asks: "Can I use this offline?"
And you realize: everything your app knows, it knows because the server told it. Close the app, lose the connection, and it's blank. Every list, every preference, every piece of data the user cared about — gone until the next API call.
You need local storage. Not SharedPreferences for a theme toggle. Not a JSON file dumped to disk. A real database — one that handles entities, relationships, queries, and doesn't make you write SQL strings inside Dart code.
This is where ObjectBox enters. And it enters differently than you might expect.
What ObjectBox actually is
ObjectBox is an embedded NoSQL database written in C and C++. It runs inside your app process — no server, no daemon, no network. Data lives in a single file on the device's storage. When your app starts, ObjectBox opens that file and gives you a Dart API that feels like working with normal objects.
The key phrase is "written in C and C++." ObjectBox is not a pure Dart library. Under the hood, it uses Dart FFI to call into a pre-compiled native library — the same mechanism this series has been exploring since Post 1. Every box.put() and box.query() crosses the FFI boundary, executes native code, and returns. That's why it's fast: the actual database operations run as compiled C, not interpreted or JIT-compiled Dart.
If you've read the earlier posts in this series, you already understand what's happening behind ObjectBox's API. The native library is loaded via DynamicLibrary.open(). The store handle is an opaque pointer. Entity data is serialized into flat buffers (a binary format — no JSON parsing overhead), passed across the FFI boundary, and written to memory-mapped storage. Queries are compiled to native predicates. None of this matters for using ObjectBox — but knowing it explains why it benchmarks the way it does.
Let's build something.
Starting from nothing
Create a fresh Flutter project. We're going to build a personal workout tracker — a simple domain, but one with real entities, relations, and queries that justify a database.
flutter create --empty workout_tracker
cd workout_trackerAdd ObjectBox:
flutter pub add objectbox objectbox_flutter_libs
flutter pub add --dev objectbox_generator build_runnerThree packages:
objectbox— the Dart APIobjectbox_flutter_libs— pre-built native libraries for Android, iOS, macOS, Linuxobjectbox_generator— code generator that runs at build time (dev dependency only)
That last one is important. ObjectBox uses code generation to create the binding layer between your Dart entities and the native database. You define a class, annotate it, run the generator, and it produces the glue code. This is similar to how json_serializable works — but instead of JSON, it generates database bindings.
Your first entity
An entity is a Dart class that ObjectBox can store. The rules are minimal:
// lib/domain/models/exercise.dart
import 'package:objectbox/objectbox.dart';
@Entity()
class Exercise {
@Id()
int id = 0; // ObjectBox assigns this — 0 means "not yet stored"
String name;
String muscleGroup;
String? notes;
Exercise({
required this.name,
required this.muscleGroup,
this.notes,
});
}That's it. @Entity() marks the class. @Id() marks the primary key — an int that ObjectBox manages. When id is 0, the next put() assigns a new ID automatically. When id is non-zero, put() updates the existing record.
No table definitions. No column types. No migration files. ObjectBox reads the class structure and handles the schema. The fields become stored properties. The types map directly: String → string, int → integer, double → double, bool → boolean, DateTime → timestamp, List<String> → string list.
Run the generator:
dart run build_runner buildThis creates objectbox-model.json (the schema definition — commit this to git) and objectbox.g.dart (generated code — also commit, though it can be regenerated). The generator produces a Store initializer and typed Box<T> accessors for each entity.
Opening the store
The Store is ObjectBox's top-level object — the database connection. Open it once when the app starts, keep it alive for the app's lifetime, close it when the app exits.
// lib/core/database/objectbox_store.dart
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import '../../objectbox.g.dart';
class ObjectBoxStore {
late final Store store;
ObjectBoxStore._create(this.store);
static Future<ObjectBoxStore> create() async {
final dir = await getApplicationDocumentsDirectory();
final store = await openStore(directory: p.join(dir.path, 'objectbox'));
return ObjectBoxStore._create(store);
}
void close() => store.close();
}The openStore() function is generated by the build runner. It knows about every entity you've defined. The directory path is where the database file lives on disk.
Initialize it at app startup:
// lib/main.dart
import 'package:flutter/material.dart';
import 'core/database/objectbox_store.dart';
late final ObjectBoxStore objectbox;
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
objectbox = await ObjectBoxStore.create();
runApp(const WorkoutTrackerApp());
}late final at the top level. One instance, lives forever. This is the standard pattern — you'll see it in every ObjectBox Flutter project. The Store manages its own connection pool and threading internally. You don't need to worry about concurrent access from different isolates (ObjectBox handles it natively).
Using the store from a background isolate
That last sentence needs a qualifier. ObjectBox handles concurrent access natively — but you can't pass the Store object itself to a new isolate. Dart isolates have separate heaps; a Store instance from one isolate is meaningless in another.
Future<void> importExercisesInBackground(List<Map<String, dynamic>> json) async {
final ref = objectbox.store.reference;
await Isolate.run(() {
// Reconstruct the store from its reference — same underlying database
final workerStore = Store.fromReference(getObjectBoxModel(), ref);
final box = workerStore.box<Exercise>();
final exercises = json.map((j) => Exercise(
name: j['name'] as String,
muscleGroup: j['muscleGroup'] as String,
)).toList();
box.putMany(exercises);
workerStore.close();
});
} Store.reference gives you a transferable handle — an integer that identifies the underlying native store. Store.fromReference() in the worker isolate opens a new Dart-side connection to the same native database. Both isolates can read and write concurrently; ObjectBox's C core handles the locking.
The rule: never pass a Store to an isolate. Always pass store.reference and reconstruct with Store.fromReference().
A note on Android release builds and ProGuard
If you search for ObjectBox production issues, you'll find old Stack Overflow posts about release builds crashing on Android due to ProGuard/R8 stripping symbols the native core needs. This was a real problem in earlier versions. It isn't anymore.
The objectbox_flutter_libs package now ships its own ProGuard rules bundled in the AAR. When you build a release APK, Gradle merges them automatically. The native symbols are preserved. You don't need to add anything to your proguard-rules.pro.
The one thing you should not do: add your own ObjectBox-related ProGuardrules "just in case." Custom keep rules can conflict with the bundled ones — overly broad rules can prevent R8 from shrinking unrelated code, and overly specific rules can break if ObjectBox updates its internal class names. Trust the library's bundled rules. If your release build crashes on startup with an UnsatisfiedLinkError or a missing class, check that your objectbox_flutter_libs version is current before reaching for ProGuard — the fix is almost always a version update, not a custom rule.
CRUD: the basic operations
ObjectBox gives you a Box<T> for each entity — a typed container that handles all persistence operations:
// Get a box
final exerciseBox = objectbox.store.box<Exercise>();
// Create
final pushups = Exercise(name: 'Push-ups', muscleGroup: 'Chest');
final id = exerciseBox.put(pushups); // returns the assigned ID
// pushups.id is now non-zero
// Read
final exercise = exerciseBox.get(id); // Exercise? — null if not found
// Read all
final all = exerciseBox.getAll(); // List<Exercise>
// Update — same as create, just put() with a non-zero ID
exercise!.notes = 'Focus on form, not speed';
exerciseBox.put(exercise);
// Delete
exerciseBox.remove(id); // returns true if found and deleted
// Bulk operations
exerciseBox.putMany([
Exercise(name: 'Squats', muscleGroup: 'Legs'),
Exercise(name: 'Deadlift', muscleGroup: 'Back'),
Exercise(name: 'Bench Press', muscleGroup: 'Chest'),
]);Every put() is transactional. Either the entire write succeeds or nothing changes. For multiple writes that must be atomic:
objectbox.store.runInTransaction(TxMode.write, () {
exerciseBox.put(exercise1);
exerciseBox.put(exercise2);
workoutBox.put(workout);
// All three writes commit together — or none of them do
});This is where the C-level performance matters. A transaction that writes 1,000 objects completes in single-digit milliseconds. Try that with SharedPreferences or a JSON file.
Queries: finding what you need
This is where ObjectBox starts to feel different from key-value stores like Hive. You can query by any field, combine conditions, sort, limit, and paginate — all executed natively:
final chestExercises = exerciseBox
.query(Exercise_.muscleGroup.equals('Chest'))
.build()
.find();
// Returns: [Push-ups, Bench Press]Exercise_ (with underscore) is a generated class that provides type-safe property references. No strings, no typos, no runtime errors when a field name changes.
Compound queries:
// Chest exercises with notes
final query = exerciseBox.query(
Exercise_.muscleGroup.equals('Chest') &
Exercise_.notes.notNull()
).build();
final results = query.find();
query.close(); // always close queries when doneSorting:
final sorted = (exerciseBox.query()
..order(Exercise_.name))
.build()
.find();Pagination:
final query = exerciseBox.query()
..order(Exercise_.name);
final page1 = query.build()
..offset = 0
..limit = 20;
final results = page1.find();
page1.close();String queries support case-insensitive matching, prefix matching, and contains:
// All exercises where the name contains "press" (case-insensitive)
exerciseBox.query(
Exercise_.name.contains('press', caseSensitive: false)
).build().find();Every query compiles to a native predicate. The filtering happens in C, not in Dart. This matters when you have 10,000 records — Dart would have to deserialize all of them and filter in a loop. ObjectBox filters natively and only deserializes the matches.
Relations: connecting entities
A workout tracker needs more than exercises. It needs workouts that contain sets of exercises, with weights and reps:
// lib/domain/models/workout_set.dart
import 'package:objectbox/objectbox.dart';
import 'exercise.dart';
import 'workout.dart';
@Entity()
class WorkoutSet {
@Id()
int id = 0;
double weight;
int reps;
int setNumber;
final exercise = ToOne<Exercise>();
final workout = ToOne<Workout>();
WorkoutSet({
required this.weight,
required this.reps,
required this.setNumber,
});
}// lib/domain/models/workout.dart
import 'package:objectbox/objectbox.dart';
import 'workout_set.dart';
@Entity()
class Workout {
@Id()
int id = 0;
@Property(type: PropertyType.date)
DateTime date;
String? notes;
int durationMinutes;
@Backlink('workout')
final sets = ToMany<WorkoutSet>();
Workout({
required this.date,
this.notes,
this.durationMinutes = 0,
});
}Two relation types:
`ToOne<T>` — a single reference. WorkoutSet.exercise points to one Exercise. Stored as a foreign key internally.
`ToMany<T>` with @Backlink — the reverse side of a relation. Workout.sets is all the WorkoutSet objects whose workout ToOne points to this Workout. It's not stored separately — it's a computed reverse lookup.
Using relations:
// Create a workout with sets
final workout = Workout(date: DateTime.now(), durationMinutes: 45);
final set1 = WorkoutSet(weight: 60, reps: 10, setNumber: 1);
set1.exercise.target = pushups; // link to an exercise
final set2 = WorkoutSet(weight: 80, reps: 8, setNumber: 2);
set2.exercise.target = benchPress;
// Put the workout first to get its ID
workoutBox.put(workout);
// Link sets to the workout and save
set1.workout.target = workout;
set2.workout.target = workout;
setBox.putMany([set1, set2]);
// Later: load a workout and access its sets
final loaded = workoutBox.get(workout.id)!;
final allSets = loaded.sets; // lazy-loaded ToMany — fetched on first access
print('${allSets.length} sets in this workout');The ToMany is lazy — accessing loaded.sets triggers a native query behind the scenes. The first access hits the database; subsequent accesses use the cached result. This is important for performance: if you load a list of 50 workouts, the sets aren't fetched until you actually expand one.
Reactive streams: ObjectBox meets Flutter
This is the feature that makes ObjectBox feel native to Flutter. You can watch a query and get a Stream<List<T>> that emits whenever the underlying data changes:
// lib/features/exercises/exercise_repository.dart
class ExerciseRepository {
final Box<Exercise> _box;
ExerciseRepository(Store store) : _box = store.box<Exercise>();
// One-time fetch
List<Exercise> getAll() => _box.getAll();
// Reactive stream — emits whenever exercises change
Stream<List<Exercise>> watchAll() {
return _box
.query()
.watch(triggerImmediately: true)
.map((query) => query.find());
}
// Filtered reactive stream
Stream<List<Exercise>> watchByMuscleGroup(String group) {
return _box
.query(Exercise_.muscleGroup.equals(group))
.watch(triggerImmediately: true)
.map((query) => query.find());
}
int save(Exercise exercise) => _box.put(exercise);
bool delete(int id) => _box.remove(id);
}Now in your BLoC or widget:
// In a BLoC
class ExerciseListBloc extends Cubit<List<Exercise>> {
final ExerciseRepository _repository;
StreamSubscription? _subscription;
ExerciseListBloc(this._repository) : super([]) {
_subscription = _repository.watchAll().listen(emit);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
}Or directly in a widget with StreamBuilder:
StreamBuilder<List<Exercise>>(
stream: exerciseRepository.watchAll(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
final exercises = snapshot.data!;
return ListView.builder(
itemCount: exercises.length,
itemBuilder: (_, i) => ExerciseCard(exercise: exercises[i]),
);
},
)The stream triggers on any change to the box — put, update, delete. You save an exercise somewhere else in the app, the list rebuilds automatically. No event bus, no manual refresh, no polling. The database tells you when it changed.
This is powered by native listeners inside the C library — not Dart streams polling a file. The notification is synchronous with the write. By the time put() returns, the stream has already emitted.
The architecture that makes this production-ready
So far we've had code scattered for clarity. In a real app, here's how it fits:
lib/
├── core/
│ └── database/
│ └── objectbox_store.dart # Store singleton
├── domain/
│ └── models/
│ ├── exercise.dart # @Entity
│ ├── workout.dart # @Entity with ToMany
│ └── workout_set.dart # @Entity with ToOne relations
├── data/
│ └── repositories/
│ ├── exercise_repository.dart # Box operations + streams
│ └── workout_repository.dart
├── features/
│ ├── exercises/
│ │ ├── exercise_list_screen.dart
│ │ └── exercise_form_screen.dart
│ └── workouts/
│ ├── workout_list_screen.dart
│ └── workout_detail_screen.dart
├── objectbox.g.dart # Generated — commit to git
├── objectbox-model.json # Schema — commit to git
└── main.dart # Store initThe entities live in domain/models/. The repositories live in data/repositories/. Screens talk to repositories through BLoC or directly — never to Box<T> directly. This means you can swap ObjectBox for any other database by replacing the repository implementations. The domain layer has no idea what stores the data.
This is Clean Architecture applied to persistence. The same pattern from the enterprise playbook, applied to a specific technology choice.
Indexing: the performance detail you'll forget and then need
By default, ObjectBox only indexes the @Id field. Queries on other fields work — they just do a full scan. For small datasets (hundreds of records), this is fast enough. For larger datasets or frequent queries, add explicit indexes:
@Entity()
class Exercise {
@Id()
int id = 0;
@Index()
String name;
@Index()
String muscleGroup;
String? notes;
Exercise({required this.name, required this.muscleGroup, this.notes});
}@Index() creates a native index — the same kind of B-tree index you'd create in SQL. Reads on indexed fields are O(log n) instead of O(n). Writes are slightly slower because the index must be updated.
For unique constraints:
@Unique()
@Index()
String name;Now put() throws a UniqueViolationException if you try to store two exercises with the same name. The uniqueness check happens natively — no Dart-side validation needed.
Schema migrations: what happens when entities change
This is the question everyone asks eventually: "I shipped the app. Now I need to add a field. What happens?"
ObjectBox handles most schema changes automatically:
- Adding a new field — works. Existing records get the field's default value (0, null, empty string).
- Removing a field — works. The data for that field is silently ignored.
- Renaming a field — requires a
@Uid()annotation to tell ObjectBox it's the same field with a new name, not a new field replacing an old one. - Changing a field's type — not automatic. Requires a migration strategy.
The objectbox-model.json file tracks the schema. It contains UIDs (unique identifiers) for every entity and property. These UIDs are how ObjectBox maps "the name field in version 2" to "the name field in version 1" — even if you rearranged the class.
Team warning: In a multi-developer project, objectbox-model.json is the most dangerous file to merge incorrectly. If two developers add a new entity or field on separate branches, both will generate new UIDs in the same file. A careless Git merge — or worse, accepting "both changes" without understanding the structure — can produce a model.json with duplicate or mismatched UIDs. The result: the next app update silently corrupts the schema mapping, and existing users' data is misread or lost. Treat objectbox-model.json merges like database migration conflicts: review them manually, regenerate if in doubt (dart run build_runner build after resolving the entity source files), and never auto-merge.
For a rename:
// Before: String muscleGroup;
// After:
@Property(uid: 8274652394) // the UID from objectbox-model.json
String targetMuscle;The UID tells ObjectBox: "this is the same property, just renamed." Without it, ObjectBox would see muscleGroup disappearing and targetMuscle appearing — it'd drop the old data and create an empty new field.
For complex migrations (changing types, splitting fields, restructuring relations), you run a migration in your store initialization:
static Future<ObjectBoxStore> create() async {
final dir = await getApplicationDocumentsDirectory();
final store = await openStore(directory: p.join(dir.path, 'objectbox'));
// One-time migration: populate new field from old data
_migrateIfNeeded(store);
return ObjectBoxStore._create(store);
}
static void _migrateIfNeeded(Store store) {
final prefs = store.box<AppPreferences>();
final current = prefs.get(1);
if (current == null || current.schemaVersion < 2) {
// Run migration logic
final exercises = store.box<Exercise>().getAll();
for (final e in exercises) {
if (e.targetMuscle.isEmpty) {
e.targetMuscle = _inferMuscleGroup(e.name);
}
}
store.box<Exercise>().putMany(exercises);
// Update schema version
prefs.put(AppPreferences(schemaVersion: 2));
}
}This is the same pattern Rails uses with its migration runner — check a version number, run migrations sequentially, update the version. Simple, reliable, and explicit.
Testing: mocking vs real database
ObjectBox is fast enough that you don't need to mock it in tests. Seriously. Opening a store in a temporary directory and running real queries takes milliseconds. This is one of the advantages of an embedded database — no Docker, no test server, no network:
// test/repositories/exercise_repository_test.dart
import 'dart:io';
import 'package:test/test.dart';
import 'package:workout_tracker/objectbox.g.dart';
import 'package:workout_tracker/data/repositories/exercise_repository.dart';
import 'package:workout_tracker/domain/models/exercise.dart';
void main() {
late Store store;
late ExerciseRepository repository;
late Directory tempDir;
setUp(() {
tempDir = Directory.systemTemp.createTempSync('objectbox_test_');
store = openStore(directory: tempDir.path);
repository = ExerciseRepository(store);
});
tearDown(() {
store.close();
tempDir.deleteSync(recursive: true);
});
test('saves and retrieves an exercise', () {
final exercise = Exercise(name: 'Push-ups', muscleGroup: 'Chest');
final id = repository.save(exercise);
final loaded = store.box<Exercise>().get(id);
expect(loaded, isNotNull);
expect(loaded!.name, equals('Push-ups'));
});
test('watches for changes reactively', () async {
final stream = repository.watchAll();
// First emission: empty
expectLater(
stream,
emitsInOrder([
hasLength(0),
hasLength(1),
]),
);
// Trigger second emission
repository.save(Exercise(name: 'Squats', muscleGroup: 'Legs'));
});
}No mocks. Real database, real queries, real reactive streams. The test runs in milliseconds because the native library is that fast. If your repository interface has an abstraction boundary (and it should), you can still mock it for widget tests where database speed isn't the bottleneck — but for repository and integration tests, use the real thing.
When to choose ObjectBox vs the alternatives
There are three serious local database options in Flutter as of 2026:
ObjectBox — NoSQL, native C core via FFI, reactive streams, relations, type-safe queries. Best for: apps with complex queries, relations between entities, or high write throughput. The native core means it's the fastest option for large datasets.
Hive — Key-value / document store, pure Dart, no native dependencies. Best for: simple storage needs (settings, cached API responses, small collections), apps where adding native dependencies is a problem (web targets, some CI environments). Simpler than ObjectBox but less powerful.
Isar — NoSQL, native core (also by the original Hive author), full-text search, compound indexes. Best for: apps that need full-text search or complex compound queries. Similar performance to ObjectBox. As of 2026, Isar's development has slowed — check the repository activity before committing.
Drift (formerly Moor) — SQL, built on SQLite via FFI. Best for: teams that prefer SQL, apps migrating from a SQL-based architecture, or when you need raw SQL for complex reporting queries.
The decision tree:
- Need relations and queries? → ObjectBox or Drift
- Pure Dart, no native deps? → Hive
- SQL preferred? → Drift
- Maximum write speed? → ObjectBox
- Full-text search? → Isar or Drift (with FTS extension)
- Just caching API responses? → Hive (simplest setup)
For the workout tracker — or any domain-driven app with entities and relations — ObjectBox is the right tool. You get reactive streams, type-safe queries, native performance, and a persistence layer that disappears behind a clean repository interface.
The complete picture
You started with an empty Flutter project. Now you have:
- Entities with relations (
ToOne,ToMany,@Backlink) - Type-safe queries compiled to native predicates
- Reactive streams that emit on every data change
- Repositories that hide the database behind a clean interface
- Transactions for atomic multi-entity writes
- Indexes for O(log n) lookups
- Schema migrations with UID-based field tracking
- Tests running against real database instances
The database file sits in the app's documents directory. It works offline. It syncs to no server. It survives app restarts, OS kills, and device reboots. It's a single native binary — no SQLite, no SQL, no ORM, no connection strings.
And every operation — every put(), every query(), every stream notification — crosses the FFI boundary into compiled C code and back. The same boundary this series has been teaching you to understand. ObjectBox just wraps it in an API so clean you forget it's there.
That's the point. The best infrastructure is the kind you stop thinking about.