diff --git a/src/import/load-costume.js b/src/import/load-costume.js
index 6d0a2d259..4852399bd 100644
--- a/src/import/load-costume.js
+++ b/src/import/load-costume.js
@@ -99,7 +99,7 @@ const canvasPool = (function () {
  *     assetMatchesBase is true if the asset matches the base layer; false if it required adjustment
  */
 const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) {
-    if (!costume || !costume.asset) {
+    if (!costume || !costume.asset) { // TODO: We can probably remove this check...
         return Promise.reject('Costume load failed. Assets were missing.');
     }
     if (!runtime.v2BitmapAdapter) {
@@ -176,7 +176,7 @@ const fetchBitmapCanvas_ = function (costume, runtime, rotationCenter) {
                 assetMatchesBase: scale === 1 && !textImageElement
             };
         })
-        .catch(() => {
+        .finally(() => {
             // Clean up the text layer properties if it fails to load
             delete costume.textLayerMD5;
             delete costume.textLayerAsset;
@@ -325,7 +325,11 @@ const loadCostumeFromAsset = function (costume, runtime, optVersion) {
                 
             });
     }
-    return loadBitmap_(costume, runtime, rotationCenter, optVersion);
+    return loadBitmap_(costume, runtime, rotationCenter, optVersion)
+        .catch(error => {
+            log.warn(`Error loading bitmap image: ${error}`);
+            return handleCostumeLoadError(costume, runtime);
+        });
 };
 
 
@@ -377,18 +381,25 @@ const loadCostume = function (md5ext, costume, runtime, optVersion) {
         textLayerPromise = Promise.resolve(null);
     }
 
-    return Promise.all([costumePromise, textLayerPromise]).then(assetArray => {
-        if (assetArray[0]) {
-            costume.asset = assetArray[0];
-        } else {
-            return handleCostumeLoadError(costume, runtime);
-        }
+    return Promise.all([costumePromise, textLayerPromise])
+        .then(assetArray => {
+            if (assetArray[0]) {
+                costume.asset = assetArray[0];
+            } else {
+                return handleCostumeLoadError(costume, runtime);
+            }
 
-        if (assetArray[1]) {
-            costume.textLayerAsset = assetArray[1];
-        }
-        return loadCostumeFromAsset(costume, runtime, optVersion);
-    });
+            if (assetArray[1]) {
+                costume.textLayerAsset = assetArray[1];
+            }
+            return loadCostumeFromAsset(costume, runtime, optVersion);
+        })
+        .catch(error => {
+            // Handle case where storage.load rejects with errors
+            // instead of resolving null
+            log.warn('Error loading costume: ', error);
+            return handleCostumeLoadError(costume, runtime);
+        });
 };
 
 module.exports = {
diff --git a/src/virtual-machine.js b/src/virtual-machine.js
index a75ff0802..0d2cab346 100644
--- a/src/virtual-machine.js
+++ b/src/virtual-machine.js
@@ -877,6 +877,7 @@ class VirtualMachine extends EventEmitter {
     updateBitmap (costumeIndex, bitmap, rotationCenterX, rotationCenterY, bitmapResolution) {
         const costume = this.editingTarget.getCostumes()[costumeIndex];
         if (!(costume && this.runtime && this.runtime.renderer)) return;
+        if (costume && costume.broken) delete costume.broken;
 
         costume.rotationCenterX = rotationCenterX;
         costume.rotationCenterY = rotationCenterY;
diff --git a/test/fixtures/corrupt_png.sb2 b/test/fixtures/corrupt_png.sb2
new file mode 100644
index 000000000..9e19272bd
Binary files /dev/null and b/test/fixtures/corrupt_png.sb2 differ
diff --git a/test/fixtures/corrupt_png.sprite2 b/test/fixtures/corrupt_png.sprite2
new file mode 100644
index 000000000..b292e438a
Binary files /dev/null and b/test/fixtures/corrupt_png.sprite2 differ
diff --git a/test/fixtures/missing_png.sb2 b/test/fixtures/missing_png.sb2
new file mode 100644
index 000000000..1539fed21
Binary files /dev/null and b/test/fixtures/missing_png.sb2 differ
diff --git a/test/fixtures/missing_png.sprite2 b/test/fixtures/missing_png.sprite2
new file mode 100644
index 000000000..514119934
Binary files /dev/null and b/test/fixtures/missing_png.sprite2 differ
diff --git a/test/integration/sb2_corrupted_png.js b/test/integration/sb2_corrupted_png.js
new file mode 100644
index 000000000..78584ec2c
--- /dev/null
+++ b/test/integration/sb2_corrupted_png.js
@@ -0,0 +1,126 @@
+/**
+ * This test mocks render breaking on loading a corrupted bitmap costume.
+ * The VM should handle this safely by displaying a Gray Question Mark,
+ * but keeping track of the original costume data and serializing the
+ * original costume data back out. The saved project.json should not
+ * reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const md5 = require('js-md5');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+const projectUri = path.resolve(__dirname, '../fixtures/corrupt_png.sb2');
+const project = readFileToBuffer(projectUri);
+const costumeFileName = '1.png';
+const originalCostume = extractAsset(projectUri, costumeFileName);
+// We need to get the actual md5 because we hand modified the png to corrupt it
+// after we downloaded the project from Scratch
+// Loading the project back into the VM will correct the assetId and md5
+const brokenCostumeMd5 = md5(originalCostume);
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => {
+        const base64Image = image.src.split(',')[1];
+        const decodedText = Buffer.from(base64Image, 'base64').toString();
+        if (decodedText.includes('Here is some')) {
+            image.onerror();
+        } else {
+            image.onload();
+        }
+    }, 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+let defaultBitmapAssetId;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
+
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project);
+});
+
+const test = tap.test;
+
+test('load sb2 project with corrupted bitmap costume file', t => {
+    t.equal(vm.runtime.targets.length, 2);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = vm.runtime.targets[1];
+    t.equal(greenGuySprite.getName(), 'GreenGuy');
+    t.equal(greenGuySprite.getCostumes().length, 1);
+    
+    const corruptedCostume = greenGuySprite.getCostumes()[0];
+    t.equal(corruptedCostume.name, 'GreenGuy');
+    t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
+    t.equal(corruptedCostume.dataFormat, 'png');
+    // Runtime should have info about broken asset
+    t.ok(corruptedCostume.broken);
+    t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
+    // Verify that we saved the original asset data
+    t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
+
+    t.end();
+});
+
+test('load and then save project with corrupted bitmap costume file', t => {
+    const resavedProject = JSON.parse(vm.toJSON());
+
+    t.equal(resavedProject.targets.length, 2);
+    
+    const stage = resavedProject.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = resavedProject.targets[1];
+    t.equal(greenGuySprite.name, 'GreenGuy');
+    t.equal(greenGuySprite.costumes.length, 1);
+    
+    const corruptedCostume = greenGuySprite.costumes[0];
+    t.equal(corruptedCostume.name, 'GreenGuy');
+    // Resaved project costume should have the metadata that corresponds to the original broken costume
+    t.equal(corruptedCostume.assetId, brokenCostumeMd5);
+    t.equal(corruptedCostume.dataFormat, 'png');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(corruptedCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume saves orignal broken costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[1].id);
+    t.equal(costumeDescs.length, 1);
+    const costume = costumeDescs[0];
+    t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
+    t.equal(md5(costume.fileContent), brokenCostumeMd5);
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sb2_missing_png.js b/test/integration/sb2_missing_png.js
new file mode 100644
index 000000000..0d633f225
--- /dev/null
+++ b/test/integration/sb2_missing_png.js
@@ -0,0 +1,111 @@
+/**
+ * This test ensures that the VM gracefully handles an sb2 project with
+ * a missing bitmap costume. The VM should handle this safely by displaying
+ * a Gray Question Mark, but keeping track of the original costume data
+ * and serializing the original costume data back out. The saved project.json
+ * should not reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+const projectUri = path.resolve(__dirname, '../fixtures/missing_png.sb2');
+const project = readFileToBuffer(projectUri);
+
+
+const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa';
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => image.onload(), 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project);
+});
+
+const test = tap.test;
+
+test('loading sb2 project with missing bitmap costume file', t => {
+    t.equal(vm.runtime.targets.length, 2);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = vm.runtime.targets[1];
+    t.equal(greenGuySprite.getName(), 'GreenGuy');
+    t.equal(greenGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = greenGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'GreenGuy');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    const defaultVectorAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
+    t.equal(missingCostume.assetId, defaultVectorAssetId);
+    t.equal(missingCostume.dataFormat, 'png');
+    // Runtime should have info about broken asset
+    t.ok(missingCostume.broken);
+    t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
+
+    t.end();
+});
+
+test('load and then save sb2 project with missing costume file', t => {
+    const resavedProject = JSON.parse(vm.toJSON());
+
+    t.equal(resavedProject.targets.length, 2);
+    
+    const stage = resavedProject.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = resavedProject.targets[1];
+    t.equal(greenGuySprite.name, 'GreenGuy');
+    t.equal(greenGuySprite.costumes.length, 1);
+    
+    const missingCostume = greenGuySprite.costumes[0];
+    t.equal(missingCostume.name, 'GreenGuy');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    t.equal(missingCostume.assetId, missingCostumeAssetId);
+    t.equal(missingCostume.dataFormat, 'png');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(missingCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume does not save data for missing costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime);
+    
+    t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
+    t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.png`);
+
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sb2_missing_svg.js b/test/integration/sb2_missing_svg.js
index 46bbb676b..f8a9da30d 100644
--- a/test/integration/sb2_missing_svg.js
+++ b/test/integration/sb2_missing_svg.js
@@ -13,6 +13,7 @@ const FakeRenderer = require('../fixtures/fake-renderer');
 const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
 const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
 const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
 
 const projectUri = path.resolve(__dirname, '../fixtures/missing_svg.sb2');
 const project = readFileToBuffer(projectUri);
@@ -95,6 +96,15 @@ test('load and then save sb2 project with missing costume file', t => {
     // Test that we didn't save any data about the costume being broken
     t.notOk(missingCostume.broken);
 
+    t.end();
+});
+
+test('serializeCostume does not save data for missing costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime);
+    
+    t.equal(costumeDescs.length, 1); // Should only have one costume, the backdrop
+    t.not(costumeDescs[0].fileName, `${missingCostumeAssetId}.svg`);
+
     t.end();
     process.nextTick(process.exit);
 });
diff --git a/test/integration/sprite2_corrupted_png.js b/test/integration/sprite2_corrupted_png.js
new file mode 100644
index 000000000..d579e5310
--- /dev/null
+++ b/test/integration/sprite2_corrupted_png.js
@@ -0,0 +1,125 @@
+/**
+ * This test mocks render breaking on loading a sprite2 with a
+ * corrupted bitmap costume.
+ * The VM should handle this safely by displaying a Gray Question Mark,
+ * but keeping track of the original costume data and serializing the
+ * original costume data back out. The saved project.json should not
+ * reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const md5 = require('js-md5');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const {extractAsset, readFileToBuffer} = require('../fixtures/readProjectFile');
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
+const project = readFileToBuffer(projectUri);
+
+const spriteUri = path.resolve(__dirname, '../fixtures/corrupt_png.sprite2');
+const sprite = readFileToBuffer(spriteUri);
+
+const costumeFileName = '0.png';
+const originalCostume = extractAsset(spriteUri, costumeFileName);
+// We need to get the actual md5 because we hand modified the png to corrupt it
+// after we downloaded the project from Scratch
+// Loading the project back into the VM will correct the assetId and md5
+const brokenCostumeMd5 = md5(originalCostume);
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => {
+        const base64Image = image.src.split(',')[1];
+        const decodedText = Buffer.from(base64Image, 'base64').toString();
+        if (decodedText.includes('Here is some')) {
+            image.onerror();
+        } else {
+            image.onload();
+        }
+    }, 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+let defaultBitmapAssetId;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
+
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project).then(() => vm.addSprite(sprite));
+});
+
+const test = tap.test;
+
+test('load sprite2 with corrupted bitmap costume file', t => {
+    t.equal(vm.runtime.targets.length, 3);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = vm.runtime.targets[2];
+    t.equal(greenGuySprite.getName(), 'GreenGuy');
+    t.equal(greenGuySprite.getCostumes().length, 1);
+    
+    const corruptedCostume = greenGuySprite.getCostumes()[0];
+    t.equal(corruptedCostume.name, 'GreenGuy');
+    t.equal(corruptedCostume.assetId, defaultBitmapAssetId);
+    t.equal(corruptedCostume.dataFormat, 'png');
+    // Runtime should have info about broken asset
+    t.ok(corruptedCostume.broken);
+    t.equal(corruptedCostume.broken.assetId, brokenCostumeMd5);
+    // Verify that we saved the original asset data
+    t.equal(md5(corruptedCostume.broken.asset.data), brokenCostumeMd5);
+
+    t.end();
+});
+
+test('load and then save sprite with corrupted costume file', t => {
+    const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
+
+    t.equal(resavedSprite.name, 'GreenGuy');
+    t.equal(resavedSprite.costumes.length, 1);
+    
+    const corruptedCostume = resavedSprite.costumes[0];
+    t.equal(corruptedCostume.name, 'GreenGuy');
+    // Resaved project costume should have the metadata that corresponds to the original broken costume
+    t.equal(corruptedCostume.assetId, brokenCostumeMd5);
+    t.equal(corruptedCostume.dataFormat, 'png');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(corruptedCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume saves orignal broken costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
+    t.equal(costumeDescs.length, 1);
+    const costume = costumeDescs[0];
+    t.equal(costume.fileName, `${brokenCostumeMd5}.png`);
+    t.equal(md5(costume.fileContent), brokenCostumeMd5);
+    t.end();
+    process.nextTick(process.exit);
+});
diff --git a/test/integration/sprite2_missing_png.js b/test/integration/sprite2_missing_png.js
new file mode 100644
index 000000000..dad0d5314
--- /dev/null
+++ b/test/integration/sprite2_missing_png.js
@@ -0,0 +1,107 @@
+/**
+ * This test ensures that the VM gracefully handles a sprite2 file with
+ * a missing bitmap costume. The VM should handle this safely by displaying
+ * a Gray Question Mark, but keeping track of the original costume data
+ * and serializing the original costume data back out. The saved project.json
+ * should not reflect that the costume is broken and should therefore re-attempt
+ * to load the costume if the saved project is re-loaded.
+ */
+const path = require('path');
+const tap = require('tap');
+const makeTestStorage = require('../fixtures/make-test-storage');
+const FakeRenderer = require('../fixtures/fake-renderer');
+const FakeBitmapAdapter = require('../fixtures/fake-bitmap-adapter');
+const readFileToBuffer = require('../fixtures/readProjectFile').readFileToBuffer;
+const VirtualMachine = require('../../src/index');
+const {serializeCostumes} = require('../../src/serialization/serialize-assets');
+
+// The particular project that we're loading doesn't matter for this test
+const projectUri = path.resolve(__dirname, '../fixtures/default.sb3');
+const project = readFileToBuffer(projectUri);
+
+const spriteUri = path.resolve(__dirname, '../fixtures/missing_png.sprite2');
+const sprite = readFileToBuffer(spriteUri);
+
+const missingCostumeAssetId = 'aadce129bfe4e57f0dd81478f3ed82aa';
+
+global.Image = function () {
+    const image = {
+        width: 1,
+        height: 1
+    };
+    setTimeout(() => image.onload(), 1000);
+    return image;
+};
+
+global.document = {
+    createElement: () => ({
+        // Create mock canvas
+        getContext: () => ({
+            drawImage: () => ({})
+        })
+    })
+};
+
+let vm;
+
+tap.beforeEach(() => {
+    const storage = makeTestStorage();
+
+    vm = new VirtualMachine();
+    vm.attachStorage(storage);
+    vm.attachRenderer(new FakeRenderer());
+    vm.attachV2BitmapAdapter(new FakeBitmapAdapter());
+
+    return vm.loadProject(project).then(() => vm.addSprite(sprite));
+});
+
+const test = tap.test;
+
+test('loading sprite2 with missing bitmap costume file', t => {
+    t.equal(vm.runtime.targets.length, 3);
+    
+    const stage = vm.runtime.targets[0];
+    t.ok(stage.isStage);
+
+    const greenGuySprite = vm.runtime.targets[2];
+    t.equal(greenGuySprite.getName(), 'GreenGuy');
+    t.equal(greenGuySprite.getCostumes().length, 1);
+    
+    const missingCostume = greenGuySprite.getCostumes()[0];
+    t.equal(missingCostume.name, 'GreenGuy');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    const defaultBitmapAssetId = vm.runtime.storage.defaultAssetId.ImageBitmap;
+    t.equal(missingCostume.assetId, defaultBitmapAssetId);
+    t.equal(missingCostume.dataFormat, 'png');
+    // Runtime should have info about broken asset
+    t.ok(missingCostume.broken);
+    t.equal(missingCostume.broken.assetId, missingCostumeAssetId);
+
+    t.end();
+});
+
+test('load and then save sprite2 with missing bitmap costume file', t => {
+    const resavedSprite = JSON.parse(vm.toJSON(vm.runtime.targets[2].id));
+
+    t.equal(resavedSprite.name, 'GreenGuy');
+    t.equal(resavedSprite.costumes.length, 1);
+    
+    const missingCostume = resavedSprite.costumes[0];
+    t.equal(missingCostume.name, 'GreenGuy');
+    // Costume should have both default cosutme (e.g. Gray Question Mark) data and original data
+    t.equal(missingCostume.assetId, missingCostumeAssetId);
+    t.equal(missingCostume.dataFormat, 'png');
+    // Test that we didn't save any data about the costume being broken
+    t.notOk(missingCostume.broken);
+
+    t.end();
+});
+
+test('serializeCostume does not save data for missing costume', t => {
+    const costumeDescs = serializeCostumes(vm.runtime, vm.runtime.targets[2].id);
+   
+    t.equal(costumeDescs.length, 0);
+    
+    t.end();
+    process.nextTick(process.exit);
+});