A MongoDB document can’t exceed 16 MB. That’s plenty for JSON, but not for a scanned contract bundle, a training video or a database export. GridFS is MongoDB’s convention for storing files of any size: it splits a file into chunks, stores each chunk as its own document, and keeps one metadata document that ties them together. It isn’t a separate server feature; it’s a specification that every official driver implements.
Most interview questions about GridFS are really two questions: how does it work, and should you be using it at all? This note covers both. No MongoDB server was available while writing it, so the documents and outputs shown are illustrative, based on the GridFS specification and the Node.js driver v6 API.
Two collections and a chunk size
A GridFS “bucket” (named fs unless you choose otherwise) is a pair of ordinary collections.
fs.files has one document per file:
{
_id: ObjectId('6715a8e2c4b1a2f3d4e5f607'),
length: 52428800, // bytes
chunkSize: 261120, // 255 KiB
uploadDate: ISODate('2026-10-20T09:12:44.118Z'),
filename: 'onboarding-2026.mp4',
metadata: { ownerId: 'u_381', contentType: 'video/mp4', sha256: 'c0f1...' }
}fs.chunks has one document per chunk:
{ _id: ObjectId('...'), files_id: ObjectId('6715a8e2c4b1a2f3d4e5f607'), n: 0, data: BinData(0, '...') }files_id points at the file and n is the chunk’s position, starting at 0. The default chunk size is 255 KiB (261,120 bytes), chosen to stay under 256 KiB with room for the chunk document’s own fields. A 50 MiB file therefore becomes 201 chunks: 200 full ones and a final partial chunk. A 1 GiB file becomes 4,113.
Drivers create two indexes automatically: a unique { files_id: 1, n: 1 } on chunks, so any chunk can be found directly, and { filename: 1, uploadDate: 1 } on files. Reading byte 30,000,000 of a file means computing n = floor(30000000 / 261120) = 114 and fetching that one chunk, which is why GridFS supports range reads cheaply.
You’ll see an md5 field on files uploaded by older drivers. Drivers deprecated it (MD5 isn’t allowed under FIPS 140-2), so if you need a checksum, compute one yourself and store it in metadata, as above.
Streaming uploads and downloads in Node.js
The driver’s GridFSBucket exposes Node streams, which is the right way to handle large files: memory use stays at about one chunk regardless of file size.
import { MongoClient, GridFSBucket, ObjectId } from 'mongodb';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
const client = new MongoClient(process.env.MONGO_URL);
const db = client.db('media');
const bucket = new GridFSBucket(db, { bucketName: 'videos' }); // collections videos.files / videos.chunks
// upload
const upload = bucket.openUploadStream('onboarding-2026.mp4', {
metadata: { ownerId: 'u_381', contentType: 'video/mp4' },
});
await pipeline(createReadStream('./onboarding-2026.mp4'), upload);
console.log('stored as', upload.id);
// download a byte range, for example to answer an HTTP Range request
const stream = bucket.openDownloadStream(new ObjectId(id), { start: 1_000_000, end: 2_000_000 });pipeline matters here. It propagates errors and handles backpressure, so a slow disk or a client that disconnects doesn’t leave the upload stream buffering unboundedly or hanging. With { start, end }, the driver only fetches the chunks covering that range (end is exclusive), which is how you’d implement seeking in a video player served from an Express route.
Downloading by name returns the latest revision by default: bucket.openDownloadStreamByName('onboarding-2026.mp4'), or { revision: 0 } for the original.
What isn’t atomic, and the mess it leaves
GridFS is built from many separate writes, so it inherits their failure modes. The upload order is deliberate: the driver writes all the chunks first and inserts the files document last. If the process crashes at chunk 150 of 201, no files document exists, so readers never see a half-written file. But 150 chunks sit in fs.chunks with nothing pointing at them.
Deletion has the mirror problem. bucket.delete(id) removes the files document and then the chunks. A failure in between leaves orphaned chunks again.
At scale those orphans add up to real storage. A periodic cleanup job can find them. This aggregation (illustrative, run against the videos bucket) groups chunks by file and keeps the groups with no matching files document:
db.videos.chunks.aggregate([
{ $group: { _id: '$files_id', chunks: { $sum: 1 } } },
{ $lookup: { from: 'videos.files', localField: '_id', foreignField: '_id', as: 'file' } },
{ $match: { file: { $size: 0 } } },
{ $project: { chunks: 1 } }
], { allowDiskUse: true })Two cautions. Don’t delete orphans that might belong to an upload in progress. Since files_id is an ObjectId, its embedded timestamp tells you when the upload began, so only remove groups older than, say, a day. And on a big chunks collection this pipeline is expensive, so run it on a secondary or during quiet hours.
Replacing a file safely
There’s no way to modify a stored file in place, and you shouldn’t try to rewrite chunks yourself. To change a file, upload a new one. The manual’s recommended pattern for “the current version” is to store a pointer you can flip atomically:
- Upload the new revision. It gets its own
_id. - In one
updateOne, point the owning record at it:{ $set: { currentFileId: newId } }on the user’s profile or the document record. - Delete the old revision later, after anything still streaming it has finished.
Readers never see a partial file, because the pointer only moves after the new upload is complete.
Should you use GridFS at all?
This is where a strong answer separates itself. GridFS is the right tool when:
- files are larger than 16 MB, and you need them to be replicated, backed up and restored with exactly the same consistency as your other data, or
- you’re in an environment where adding an object store isn’t possible, such as on-premises deployments, air-gapped systems or edge devices.
It’s the wrong tool more often than people expect. Files stored in GridFS go through your database’s working set, oplog and backups. Uploading a 2 GB video generates 2 GB of oplog, which can shrink your replication window and slow secondaries. Database storage is usually far more expensive per gigabyte than object storage, and you can’t put a CDN or a presigned URL directly in front of a GridFS bucket.
For most web applications the better design is: files in S3, GCS or Azure Blob Storage, and a small MongoDB document holding the object key, size, content type, checksum and owner. And for files under 16 MB that you do want in the database (thumbnails, small attachments), a BinData field on an ordinary document is simpler than GridFS.
If GridFS data grows large on a sharded cluster, shard the chunks collection on { files_id: 1, n: 1 } or { files_id: 1 }. The files collection is typically small enough to stay unsharded.
Interview exercise: the storage bill
A document-management product stores customer uploads in GridFS. Average file size is 3 MB, the database has grown to 4 TB, almost all of it in fs.chunks, and the team reports that backups take nine hours and secondaries lag after bulk imports. What would you recommend, and how would you migrate?
Answer and reasoning
The data is mostly files that never change and are rarely queried by content, sitting in the most expensive storage the system has. With a 3 MB average, nearly none of them even needed GridFS: they’d fit in a single document. The symptoms follow directly from storing them in the database. Backups copy 4 TB of blobs. Bulk imports push large chunk writes through the oplog, which secondaries have to replay.
I’d move the bytes to object storage and keep the metadata in MongoDB. The migration can run online:
- Add a
storagefield to the file metadata, defaulting togridfs. - Change the write path so new uploads go to object storage, saving the key, size and checksum in a normal document with
storage: 'object'. - Change the read path to check
storageand stream from the right place. - Backfill: a worker streams each GridFS file to the object store, verifies the checksum, then flips
storagein one atomic update. Only after that does it delete the GridFS file and its chunks. - Once
fs.filesis empty, drop both collections and compact or resync to reclaim space.
Downloads can then use presigned URLs or a CDN, and backups shrink to the metadata. I’d keep GridFS only if there’s a hard requirement for files to share the database’s point-in-time restore, and say so explicitly, because that’s the one real argument for leaving them where they are.