Firestore Data Bundles(データバンドル)は、Cloud Firestoreのドキュメントやクエリ結果をサーバー側で1つの静的ファイルにまとめ、CDNなどから配ってクライアントのローカルキャッシュへ読み込ませる機能です。公式ドキュメントは「アプリの規模が大きくなってからクエリ費用を管理するための、任意の高度な機能」と位置づけています。
本記事では、Firestore Emulator上で firebase-admin 14.5.0 と Web SDK(firebase 12.19.0)を使い、バンドルを実際に作って読み込んだ結果をもとに、仕組み・費用・配信・読み込みの順に整理します。
まとめ
- バンドルの実体は、長さ(UTF-8のバイト数)を先頭に付けたJSON要素の連結です。ファイル全体は1つのJSONではないため、
JSON.parseでは読めません。 - 作れるのはサーバー側SDK(Node.js・Java・Pythonなど)で、読み込めるのはクライアントSDK(Web・Android・Apple・Flutter・C++・Unity)です。Admin SDKにはバンドルを読み込む関数がありません。
- 読み取り課金が発生するのは、バンドルを作るときのサーバー側クエリです。クライアントがローカルキャッシュから読む分は、Firestoreへの問い合わせになりません。
- Admin SDKはセキュリティルールを評価しないため、バンドルには誰に見られてもよい公開データだけを入れます。
- Admin SDK(Node.js)は v14 で
admin.firestore()形式の書き方が削除されました。Bundle Builder拡張機能を含むFirebase Extensionsは2027年3月31日に終了します。
Firestore Data Bundlesの仕組み
通常のクエリとの違い
通常のFirestoreでは、アプリを開いた利用者それぞれがバックエンドへ同じクエリを送り、そのたびにドキュメントの読み取りが課金されます。ニュースサイトのトップに並ぶ最新記事や、ECサイトの売れ筋商品のように、全員が同じ結果を読むデータでもそうなります。
バンドルでは、サーバーが一度だけクエリを実行して結果をファイルにし、そのファイルをCDNでキャッシュして配ります。公式ドキュメントの表現を借りると「100万のアプリインスタンスが同じ最初の100件を問い合わせる費用を払う代わりに、その100件をバンドルにするためのクエリ分だけを払う」仕組みです。読み込んだ後のクエリは、キャッシュに対してもバックエンドに対しても通常どおり実行できます。
バンドルファイルの中身
公式は「静的なバイナリファイル」と説明していますが、フォーマットを定める google/firestore/bundle/bundle.proto には「BundleElement のJSON表現を長さ接頭辞付きで並べたストリーム」と書かれています。エミュレーターで2件のクエリ結果から作ったバンドル(1,447バイト)を先頭から読むと、次の6要素が並んでいました。
| 順 | 要素 | 長さ(バイト) | 主な中身 |
|---|---|---|---|
| 1 | metadata | 143 | バンドルID・作成時刻・総ドキュメント数 |
| 2 | namedQuery | 348 | クエリ名・クエリ条件・readTime |
| 3 | documentMetadata | 196 | ドキュメントパス・該当クエリ名 |
| 4 | document | 273 | フィールド値 |
| 5 | documentMetadata | 196 | 2件目のパス |
| 6 | document | 273 | 2件目のフィールド値 |
metadataは先頭に1つだけ置かれ、その後に名前付きクエリ、各documentMetadataの直後に対応するdocumentが続く、という並び順もprotoで決まっています。ファイルは 143{"metadata":...}348{"namedQuery":...} のように始まるため、全体を JSON.parse すると、長さ接頭辞の直後にある4文字目の「{」で失敗します。中身を確かめたいときは、次のコードを decode.cjs として保存し、node decode.cjs bundle.txt で実行すると、長さを読みながら1要素ずつ取り出せます。
const fs = require("fs");
const buf = fs.readFileSync(process.argv[2]);
let pos = 0;
while (pos < buf.length) {
const start = buf.indexOf(0x7b, pos); // 次の「{」までが長さ
const len = Number(buf.toString("utf8", pos, start));
const element = JSON.parse(buf.toString("utf8", start, start + len));
console.log(Object.keys(element)[0], len);
pos = start + len;
}
日本語のフィールド値を入れたバンドルでも、このスクリプトはすべての要素を正しく取り出せました。長さは文字数ではなくUTF-8のバイト数です。この点は、後述するFlutterでの読み込みにも関わります。
バンドルに向いているデータと向かないデータ
向いているのは、全利用者が同じ結果を読み、数分程度の古さを許容できる公開データです。公式のBundle Builder拡張機能の説明は、公開のデータ可視化(投票結果や災害情報)とECの商品カタログを例に挙げています。
反対に、次のデータはバンドルに入れないでください。
- 利用者ごとに異なるデータ(マイページ、下書き、購入履歴など)
- セキュリティルールで読み取りを制限しているデータ
- 数秒単位で変わり、常に最新を見せる必要があるデータ(在庫数、チャットなど)
とくに2つ目は誤解されやすい点です。公式ドキュメントは「Admin SDKは特権ユーザーとして動作しセキュリティルールを評価しないため、ルールに関係なくあらゆるドキュメントをバンドルに入れてしまう」と注意し、公開読み取り可能なデータだけを入れるよう求めています。本記事のように認証なしで公開配信するバンドルは、URLを知っていれば誰でも取得できるため、ルールで守っているつもりのデータを入れると、そのまま外部に出ます。Firestoreのルールや料金体系の全体像はCloud Firestoreの仕組みと料金の解説で確認できます。
バンドル生成・読み込み時の読み取り課金
Firestoreの読み取り課金は、サーバーがバンドル生成時に実行するクエリで発生します。総費用を比較するときは、Cloud Functionsの実行費用とFirebase Hostingの配信費用も含めます。クライアントが読み込んだバンドルを getDocsFromCache などでキャッシュから読む処理は、バックエンドへの問い合わせになりません。
公式のソリューションページは、1日10万人が同じ50件の記事を読み込むニュースサイトで試算しています。キャッシュが無い場合は 50件×10万人=1日500万読み取り、5分キャッシュでバンドルを配る場合は 12回×24時間×50件=1日14,400読み取りです。読み取り回数が利用者数ではなく、キャッシュを作り直す回数に比例する点が要点です。なお同じページのサンプルヘッダーは s-maxage=600(CDNでの保持10分)で、試算の前提(5分)とは一致していません。自分の構成ではCDNの保持時間を目安にしつつ、URL・クエリ文字列・Varyヘッダーによるキャッシュの分割やキャッシュミスも考慮し、関数の実行回数から生成頻度を確認してください。1読み取りあたりの単価はFirebaseの料金の解説にまとめています。
バンドルから取り出したクエリをバックエンドに対して実行した場合の扱いも公式に書かれています。作成から30分未満のバンドルの名前付きクエリであれば、課金されるのはバックエンドの状態に合わせるための差分のドキュメントだけです。30分を過ぎたバンドルでは、この差分課金の扱いを前提にできません。また料金ページには、オフライン永続化が有効なリスナーが30分を超えて切断されると、再接続時に新しいクエリと同じように読み取りが課金されるとあります。バンドルを読み込んだ後にリスナーをつなぐ設計では、この30分の境界を意識して作り直しの間隔を決めます。
バンドルを作って配信する手順(Cloud Functions+Firebase Hosting)
Admin SDKによるバンドル生成
公式が案内する構成は、Cloud Functionsの関数がクエリを実行してバンドルを返し、Firebase HostingのCDNがその応答をキャッシュする形です。公式サンプルは旧来の functions.https.onRequest と admin.firestore() で書かれていますが、firebase-admin は v14.0.0(2026年6月8日リリース)で旧来の名前空間APIを削除し、Node.js 18と20のサポートも終了しました。手元の14.5.0では admin.firestore が存在せず、実行すると TypeError: admin.firestore is not a function になります。現行のモジュール形式で書くと次のとおりです。
import { onRequest } from "firebase-functions/https";
import { initializeApp } from "firebase-admin/app";
import { getFirestore } from "firebase-admin/firestore";
initializeApp();
const db = getFirestore();
export const createBundle = onRequest(async (req, res) => {
// 全利用者に同じ結果を返すクエリだけを束ねる
const latest = await db.collection("stories")
.orderBy("timestamp", "desc")
.limit(50)
.get();
const buffer = db.bundle("latest-stories")
.add("latest-stories-query", latest)
.build();
// ブラウザは5分、CDNは10分保持する
res.set("Cache-Control", "public, max-age=300, s-maxage=600");
res.end(buffer);
});
bundle() で作ったビルダーに、ドキュメントのスナップショットは add(docSnapshot)、クエリ結果は add("名前", querySnapshot) で追加し、build() がBufferを返します。ここで付けた名前が、クライアントで取り出すときの名前付きクエリになります。Javaでは bundleBuilder()、Pythonでは FirestoreBundle クラスで同じことができます。関数の書き方や第2世代の設定はCloud Functions for Firebaseの解説、Admin SDKの初期化はFirebase Admin SDKの解説を参照してください。
Firebase HostingのrewritesとCDNキャッシュ設定
関数の応答をCDNに載せるには、firebase.json のrewritesでURLと関数を結び付けます。
{
"hosting": {
"rewrites": [ {
"source": "/createBundle",
"function": {
"functionId": "createBundle",
"region": "us-central1"
}
} ]
}
}
見落としやすいのが Cache-Control です。Firebase Hostingは関数が返す動的コンテンツに既定で private を付けるため、明示的に public を指定しないとCDNにはキャッシュされず、リクエストのたびに関数が走ってFirestoreを読みます。max-age はブラウザ、s-maxage はCDNなどの共有キャッシュに効きます。値は許容できるデータの古さを基準に決めます。ただし、HTTPキャッシュの期限が切れてもFirestoreのローカルキャッシュは自動更新されないため、アプリ側のバンドル再取得タイミングも合わせて設計します。Hosting側の設定はFirebase Hostingの解説にまとめています。
クライアントSDKでのバンドル読み込み
WebモジュラーAPIでの読み込みとキャッシュ取得
Webでは loadBundle でキャッシュへ読み込み、namedQuery で名前付きクエリを取り出し、getDocsFromCache でキャッシュだけを読みます。次のコードは、同じFirebaseプロジェクトの設定で初期化したFirebaseAppを変数 app として用意し、Firebase Hostingと同一オリジンで実行する前提です。この初期化を含む検証用コードは型検査(TypeScript 7.0.2、strict)を通しています。
import { getFirestore, loadBundle, namedQuery, getDocsFromCache } from "firebase/firestore";
const db = getFirestore(app);
export async function loadLatestStories() {
const resp = await fetch("/createBundle");
if (!resp.ok) throw new Error("バンドル取得失敗: HTTP " + resp.status);
const task = loadBundle(db, await resp.arrayBuffer());
task.onProgress((p) => console.log(p.documentsLoaded, p.totalDocuments));
await task;
const query = await namedQuery(db, "latest-stories-query");
if (query === null) throw new Error("named query がバンドルに無い");
// キャッシュだけを読む(バックエンドへは問い合わせない)
const snap = await getDocsFromCache(query);
return snap.docs.map((d) => d.data());
}
loadBundle が受け取れるのは ArrayBuffer・ReadableStream<Uint8Array>・文字列の3種類で、戻り値の LoadBundleTask はそのまま await できます。エミュレーターで2件のバンドルを読み込むと、進捗は Running 0/2、1/2、2/2 と進み、最後に Success になりました。getDocsFromCache の結果は fromCache: true で、存在しない名前を namedQuery に渡すと例外ではなく null が返ります。getDocsFromCache の代わりに通常の getDocs を使うとバックエンドへ問い合わせる点に注意してください。
また、軽量版のFirestore Lite(firebase/firestore/lite)には loadBundle も namedQuery もありません。バンドルを使う画面では通常版のSDKを読み込みます。
Android・Apple・Flutter・C++・Unityの対応
クライアントSDKごとのAPI名は次のとおりです。
| SDK | 読み込み | 名前付きクエリの取得 |
|---|---|---|
| Web(モジュラー) | loadBundle(db, data) | namedQuery(db, name) |
| Android | db.loadBundle(stream) | db.getNamedQuery(name) |
| Apple(Swift) | firestore.loadBundle(stream) | getQuery(named:) |
| Flutter | loadBundle(Uint8List) | namedQueryGet(name) |
| C++ | LoadBundle | NamedQuery |
| Unity | LoadBundleAsync | GetNamedQueryAsync |
Appleプラットフォームでは、watchOSとApp Clipのターゲットでは使えないと公式に明記されています。Androidの公式サンプルはタスクを連結し、キャッシュ指定の Source.CACHE で結果を取ります。
Flutterは日本語データで注意が要ります。公式サンプルは response.body の文字列を codeUnits で Uint8List に変換していますが、Dartのhttpパッケージは Content-Type に charset=utf-8 が付く場合や application/json の場合、本文をUTF-8として文字列化します。このとき日本語の文字は1バイトに収まらず、Uint8List へ入れる段階で下位8ビットに切り詰められます。同じ変換をNode.jsで再現して日本語入りのバンドルを読み込ませると、Invalid bundle format: Reached the end of bundle when more is expected. で失敗しました。長さ接頭辞がバイト数なので、バイト列が1つでも欠けると全体が読めなくなります。受け取ったバイト列をそのまま渡す response.bodyBytes を使えば、配信側のヘッダーに左右されません。
Bundle Builder拡張機能の選定とExtensionsのサービス終了
自前で関数を書かずに済む公式の拡張機能「Firestore Bundle Builder」もあります。Firestoreのドキュメントにバンドルの定義(収録するドキュメントID、名前付きクエリ、URLで受け取るパラメータ、ブラウザ・CDN・Cloud Storageそれぞれのキャッシュ秒数)を書くと、拡張機能が配備したHTTP関数がバンドルをその場で作って返します。2026年9月時点の最新版は0.1.6で、実行環境はNode.js 20です。
ただし、Firebase Extensionsのサービス自体が非推奨となり、2027年3月31日に終了すると公式FAQが告知しています。導入済みの拡張機能はCloud Functionsなどの上でその後も動き続けます(旧ランタイムが廃止されれば止まる可能性はあると同FAQは注記しています)。ただし終了後はコンソールやCLIからの更新・設定変更・アンインストールができなくなります。これから導入するなら、前章の数十行の関数を自分で管理する方が長く保守できます。拡張機能そのものの仕組みはFirebase Extensionsの解説を参照してください。
実装でつまずきやすい4つの誤解
ネット上の解説にも見られる誤りを、エミュレーターでの実行結果と照らして整理します。
- 「サーバーでも
db.loadBundle()でバンドルを読み込める」:firebase-adminのFirestoreにloadBundleはありません(実行時はundefined)。読み込みはクライアントSDKの機能です。 - 「バンドルはJSONファイルなので
JSON.parseで中身を見られる」:長さ接頭辞付きの連結なので失敗します。前述のスクリプトのように要素ごとに取り出します。 - 「
firebase emulators:startでバンドルを作れる」:エミュレーターはFirestoreをローカルで動かすだけで、バンドルを作るのはAdmin SDKのbundle().build()です。エミュレーター上で作って試すことはできます(Firebase Local Emulator Suiteの解説)。 - 「セキュリティルールでバンドル内のデータを守れる」:Admin SDKはルールを評価せず、バンドルは静的ファイルとして配られます。本記事の公開CDN配信では、守る必要があるデータをバンドルに含めないでください。
エミュレーターでの検証は、FIRESTORE_EMULATOR_HOST を設定してAdmin SDKからテストデータを書き込み、同じスクリプト内でバンドルを作れば、本番のデータベースに触れずに生成から読み込みまでを確かめられます。
よくある質問
Firestore Data Bundlesとは何ですか?
Firestoreのドキュメントやクエリ結果をサーバー側で1つの静的ファイルにまとめ、CDNなどから配ってクライアントのローカルキャッシュに読み込ませる機能です。全利用者が同じデータを読む場面で、初回表示を速くし、読み取り回数を減らすために使います。
Data Bundlesを使うと読み取り料金はかからなくなりますか?
ゼロにはなりません。バンドルを作るときのサーバー側クエリは通常どおり課金されます。減るのは、各クライアントが同じクエリを個別に実行していた分です。読み取り回数はCDNのキャッシュを作り直す回数に比例するため、s-maxage の値が費用を左右します。
バンドルにユーザーごとのデータを入れてもよいですか?
入れないでください。バンドルを作るAdmin SDKはセキュリティルールを評価せず、本記事の公開配信構成では、できたファイルはURLを知る誰でも取得できます。公式も公開読み取り可能なデータだけを入れるよう求めています。
Firestore Liteでも使えますか?
使えません。Web向けの軽量版Firestore Liteには loadBundle と namedQuery が含まれていないため、通常版の firebase/firestore を使います。
Firestore Bundle Builder拡張機能はまだ使えますか?
2026年9月時点ではインストールできますが、Firebase Extensionsは2027年3月31日に終了し、その後は更新やアンインストールがコンソールからできなくなります。新規に導入するなら、Cloud FunctionsとFirebase Hostingで自前の関数を組む方法をおすすめします。