Skip to content

Firestore

Firenook’s Firestore emulator serves Cloud Firestore in Native mode, Standard edition, on the port your firebase.json gives it. Read this page to see what it covers, how your SDKs reach it, and the few places where it behaves differently from the official emulator.

Firestore starts when firebase.json has a firestore section. The emulators.firestore entry sets its ports:

firebase.json
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"firestore": { "port": 8080, "websocketPort": 9150 }
}
}
Setting Default What it is
emulators.firestore.port 8080 gRPC, the web SDK’s WebChannel and REST, all on one port
emulators.firestore.websocketPort 9150 The requests feed that tools read

Your app connects as it does to the official emulator. In a web app:

import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';
const db = getFirestore(app);
connectFirestoreEmulator(db, '127.0.0.1', 8080);

Admin and server SDKs read FIRESTORE_EMULATOR_HOST=127.0.0.1:8080, which firenook emulators:exec and your Functions get set for them. Connect your app has the snippet for each platform.

  • Every SDK transport. The Admin and native SDKs over gRPC, the web SDK in a browser over WebChannel, and the REST API.
  • Reads and writes. Gets, sets, updates, deletes, batched writes, transactions, field transforms such as increment() and serverTimestamp().
  • Realtime listeners on documents and queries, with added, modified and removed changes.
  • Queries. Filters, or queries, ordering, cursors, limits, collection group queries, count() aggregations and vector search with findNearest().
  • Named databases, each with its own rules (see below).
  • Security Rules, with request tracing and coverage (Security Rules).
  • Import and export in the official format (Export and snapshots).

A short run with the web SDK in Node, writing, reading, listening, querying and running a transaction:

orders.mjs
await setDoc(doc(db, 'orders', 'o1'), { status: 'paid', total: 42 });
const snap = await getDoc(doc(db, 'orders', 'o1'));
console.log('read', snap.id, snap.data());
const paid = query(collection(db, 'orders'), where('status', '==', 'paid'));
onSnapshot(paid, (s) => {
console.log('listen', s.size, s.docChanges().map((c) => `${c.type} ${c.doc.id}`).join(', '));
});
await setDoc(doc(db, 'orders', 'o2'), { status: 'paid', total: 7 });
const sorted = await getDocs(query(paid, orderBy('total', 'desc')));
console.log('query', sorted.docs.map((d) => `${d.id}:${d.data().total}`).join(' '));
await runTransaction(db, async (tx) => {
const order = await tx.get(doc(db, 'orders', 'o1'));
tx.update(order.ref, { total: order.data().total + 1 });
});
console.log('after transaction', (await getDoc(doc(db, 'orders', 'o1'))).data().total);
Terminal window
$ node orders.mjs
read o1 { status: 'paid', total: 42 }
listen 1 added o1
listen 2 added o2
query o1:42 o2:7
listen 2 modified o1
after transaction 43

Timestamps are stored to the microsecond, as production stores them: a finer value is rounded down before rules see the write.

  • The Enterprise edition. Pipeline queries need it, and the suite runs Standard, so db.pipeline() fails as it would on a Standard database: 9 FAILED_PRECONDITION: Pipeline Operations are only available for Firestore databases in Enterprise edition.
  • Index enforcement. Like the official emulator, Firenook answers a query whether or not production would need a composite index for it. The next section shows how it tells you.

Firenook reads the indexes file that firebase.json names when it starts. Like the official emulator, it never uses the file to refuse a query, and a file it cannot read does not stop the start; it says so and goes on:

firenook: the Firestore indexes in ~/demo-shop/firestore.indexes.json were not read (invalid index JSON: invalid type: string "oops", expected a sequence at line 2 column 19); the console explains the queries of database "(default)" as if it declared none

The console’s query plan, on the Firestore page, uses the file: for the query you are looking at, it shows how the engine answers it, the composite index production would need, whether your firestore.indexes.json already declares it, and the entry to copy into the file. So a query that works here and would fail after a deploy shows up before you deploy.

Saved data keeps its own index over every document’s top-level fields, sorted by value. Firenook uses it in two ways:

  • Ordered and range queries. A query ordered by one top-level field, with a limit, a range filter on that field or a cursor, reads documents in that order and stops at its limit, instead of reading and sorting the whole collection.
  • Equality. A query with == or in on a top-level field reads only the documents that hold the value, and equality on other fields is checked against the index before a document is read.

Results are the same either way. FIRENOOK_FIELD_INDEXES=0 turns the index off for a comparison, and queries then read the whole collection.

Data an earlier release saved builds the index once, on the first start of 0.2.0, and says so. Here 50,000 documents 0.2.0-next.8 wrote, kept with --keep-data:

firenook: indexed the top-level fields of 50000 Firestore documents in 0.2 s

On the benchmark server that took 7.4 s for 211,202 documents and 49.3 s for 2.7 million; every start after it opens the data at once. 0.2.0-next.8 can still open data 0.2.0 saved, and builds its own index first. The benchmarks compare the query shapes with the official emulator.

Firenook serves every database a client names, and each database listed in firebase.json gets its own rules:

firebase.json
{
"firestore": [
{ "rules": "firestore.rules", "indexes": "firestore.indexes.json" },
{ "database": "reports", "rules": "reports.rules" }
]
}

An entry without database is (default), and a project may list only named databases. Connect to one by name, getFirestore(app, 'reports') in the web and Admin SDKs. An export holds every database, and an import restores them all.

A database your config does not list follows the rules of the (default) entry, or of the first entry when there is no (default) one, and follows them as they change. While any rules are configured, no database is open to every request. A project-wide rules upload (PUT /emulator/v1/projects/<project>:securityRules) replaces the rules of every database, listed or not.

The official emulator refuses more than one database (“Cloud Firestore Emulator does not support multiple databases yet.”) and then loads no rules at all.

Firenook loads each rules file firebase.json names, watches it, and reloads it one second after you save:

firenook firestore: database "(default)" rules reloaded from ~/demo-shop/firestore.rules

A file that does not compile leaves the rules in force serving requests, and the log says where:

firenook firestore: database "(default)" rules in ~/demo-shop/firestore.rules did not compile; the rules in force are unchanged: 9:37: expected a rules expression

Firenook decides who sent a request as the official emulator does, the same way over gRPC, WebChannel and REST:

The request carries The rules see
No Authorization header A signed-out client. The client SDKs send none while nobody is signed in.
Authorization: Bearer owner The owner: the rules are skipped. The Admin SDKs send this.
Any other Bearer token A signed-in user. The token is read, not verified: request.auth.uid is its sub claim and request.auth.token its payload, whatever its times, project or signature.

So a signed-out client is checked by your rules on every transport, and the tokens @firebase/rules-unit-testing makes work as they do on the official emulator. Listing collection ids needs the owner, as on the official emulator.

A refused request fails with PERMISSION_DENIED and production’s message, then, on the next line, the allow statements that refused it, as the official emulator adds them. With these rules:

firestore.rules
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /orders/{orderId} {
allow read: if request.auth != null && resource.data.owner == request.auth.uid;
allow create: if request.auth != null && request.resource.data.owner == request.auth.uid;
}
match /notes/{noteId} {
allow read, write: if true;
}
}
}

the web SDK in Node, signed out, reads an order and a path no rule matches:

signed-out.mjs
for (const path of ['orders/o1', 'users/u1']) {
try {
await getDoc(doc(db, path));
} catch (e) {
console.log(path, e.code);
console.log(e.message);
}
}
Terminal window
$ node signed-out.mjs
orders/o1 permission-denied
Missing or insufficient permissions.
false for 'get' @ L5
users/u1 permission-denied
Missing or insufficient permissions.
No matching allow statements

@ L5 is the line of the rules file. Several allows that each refused are listed one after another, and No matching allow statements means no allow applied to the path. The console’s log names the file as well:

Rules denied get on orders/o1 for an unauthenticated client at firestore.rules:5

Security Rules covers reloading, testing and tracing.

The REST API answers on the same port: GET, PATCH and DELETE on a document, POST to a collection to create one, listing a collection with GET, and :commit, :batchGet, :batchWrite, :beginTransaction, :rollback, :runQuery, :runAggregationQuery and :listCollectionIds. Who a request is from follows the table above.

POST to a collection creates a document with a 20-character automatic id, or the id you give in documentId. The rules check it as a create:

Terminal window
$ curl -X POST -H 'Content-Type: application/json' \
--data '{"fields":{"title":{"stringValue":"Hello"}}}' \
'http://127.0.0.1:8080/v1/projects/demo-shop/databases/(default)/documents/notes'
{"createTime":"2026-10-22T11:29:13.999205Z","fields":{"title":{"stringValue":"Hello"}},"name":"projects/demo-shop/databases/(default)/documents/notes/6M5S28wz7ql6FtTobfMv","updateTime":"2026-10-22T11:29:13.999205Z"}
$ curl -X POST -H 'Content-Type: application/json' \
--data '{"fields":{"title":{"stringValue":"Hello"}}}' \
'http://127.0.0.1:8080/v1/projects/demo-shop/databases/(default)/documents/notes?documentId=n1'
{"createTime":"2026-10-22T11:29:14.010774Z","fields":{"title":{"stringValue":"Hello"}},"name":"projects/demo-shop/databases/(default)/documents/notes/n1","updateTime":"2026-10-22T11:29:14.010774Z"}

The same request again answers 409 ALREADY_EXISTS, and a create the rules refuse answers with the line that refused it:

{"error":{"code":403,"message":"Missing or insufficient permissions.\nfalse for 'create' @ L6","status":"PERMISSION_DENIED"}}

With the owner token, a seeding script skips the rules, here to write an order the rules refuse to a signed-out client:

Terminal window
curl -X PATCH -H 'Authorization: Bearer owner' -H 'Content-Type: application/json' \
--data '{"fields":{"owner":{"stringValue":"grace"}}}' \
'http://127.0.0.1:8080/v1/projects/demo-shop/databases/(default)/documents/orders/o5'
  • Transactions are optimistic. A write that conflicts with a read-write transaction succeeds, and the transaction’s commit then fails with ABORTED, so the SDK runs it again. The official emulator holds a lock instead and aborts the write. A test that races a write against a transaction sees a different side fail.
  • REST follows production where the official emulator’s REST adapter departs from it: Firenook answers :partitionQuery, reads readTime and transaction given to a get or a list, and ends a :runQuery stream as production does. Most error messages are Firenook’s own words, with the official status codes.

Firestore keeps its data on disk, not in memory. A write is acknowledged once it is journaled, and the journal reaches the drive once a second and at every stop. Killing the emulator loses nothing; a kernel crash or a power cut can lose the last second of writes, and the data stays consistent. To sync every write before it is acknowledged, at the drive’s flush time (typically 10 to 30 ms a write instead of under a millisecond):

Terminal window
npx firenook emulators:start --durability per-commit

write-behind is the default and the only other value. Without --keep-data, each start begins with a new, empty store; Keep data between runs explains how to carry it over.