A collection does not have to hold one shape. Images, videos and third-party embeds are all media: they share kind and url and differ in everything else. Three collections would make "the newest twenty media items, whatever they are" a three-way merge; one collection makes it a single indexed query. That is the polymorphic pattern — one collection, a discriminator field, and per-kind fields only those documents carry. Add a version number and it doubles as a migration strategy: v tells your code which shape it holds, so old and new documents coexist while a background job rewrites them.
db.media.drop();
db.media.insertMany([
{ kind: 'image', v: 2, url: 'https://cdn.example.com/a.jpg', width: 1600, height: 900 },
{ kind: 'video', v: 2, url: 'https://cdn.example.com/b.mp4', durationSec: 212,
captions: ['en', 'de'] },
{ kind: 'embed', v: 2, provider: 'codepen', embedId: 'abc123', ratio: '16:9' },
{ kind: 'image', v: 1, url: 'https://cdn.example.com/c.png', size: '800x600' }]);
db.media.createIndex({ kind: 1, v: 1 });
const r = db.media.updateMany({ kind: 'image', v: 1 }, [
{ $set: { width: { $toInt: { $first: { $split: ['$size', 'x'] } } },
height: { $toInt: { $last: { $split: ['$size', 'x'] } } }, v: 2 } },
{ $unset: 'size' }]);
print('migrated ' + r.modifiedCount + ' -> '
+ JSON.stringify(db.media.findOne({ url: /c\.png/ }, { _id: 0 })));migrated 1 -> {"kind":"image","v":2,"url":"https://cdn.example.com/c.png","width":800,"height":
600}The { kind: 1, v: 1 } index turns "find every document still on the old shape" into an indexed seek, which is what makes a background migration cheap enough to run in batches. The update itself is an aggregation pipeline update — an array instead of an object as the second argument — so it derives the new fields from the old ones inside the server: '800x600' becomes width: 800, height: 600 with no round trip per document, and $unset drops the field it replaced.
Two rules keep such collections manageable. Index only the fields shared queries use, plus the discriminator; a wildcard index over wildly different shapes (Special Index Types) is a symptom, not a solution. And read the version, never infer it: code branching on typeof doc.size === 'string' breaks the first time an absent field looks like an old document. Mongoose 243,355 models this with discriminators (Discriminators).