Get Started for FREE

How to Clean Up Orphaned Froala Image and Video Uploads in Express

Froala image replace

In this tutorial, you’ll get a complete Node.js + Express example that receives Froala uploads, queues deletions on remove/replace events, and only deletes files after a successful save, preserving Undo and preventing race conditions.

In Froala v5.5, the image.replaced and video.replaced events tell you which file was replaced. We will use them to clean up every removed or replaced image and video with a few lines of code.

Key Takeaways

  • Froala never deletes files from your server on its own. You decide when a file is safe to remove.
  • image.removed and video.removed tell you when media leaves the content, whether the user clicks the remove button, presses Delete, or cuts it.
  • New in v5.5, image.replaced passes a third argument, old_src, and video.replaced passes the upload response and old_src. This makes cleaning up replaced files as simple as cleaning up removed ones.
  • Your delete route should only remove files it created, and it should be safe to call twice for the same file.
  • The safest pattern is to queue deletions while the user edits and delete files only after the new content is saved. This keeps Undo working.Froala media cleanup

How Froala Reports Removed and Replaced Media

When you upload an image, Froala sends it to your server and inserts the returned URL into the content. From then on, the editor only knows about the URL. The file itself lives on your server, and only your server can delete it.

The Deleting Images guide already covers removal. You listen to image.removed and send a delete request with the image’s src. This works because Froala compares the images in the content each time it changes. When an image element disappears, the event fires with that element.

Replacing works differently. When a user selects an image and uploads a new one, Froala keeps the same <img> element and swaps its src. Because the element stays in place, this action is reported through image.replaced rather than image.removed. That is where the new v5.5 argument comes in.

Froala v5.5 introduces a new old_src argument on both replaced events. As the v5.5.0 release notes describe, image.replaced now receives the replaced image’s source as its third argument, and video.replaced receives the upload response and old_src. The new arguments are added after the existing ones, so your current handlers keep working unchanged.

Event When it fires Where to read the file to delete
image.removed An image leaves the content (remove button, Delete key, cut) $img.attr('src')
video.removed A video leaves the content $video.attr('src')
image.replaced A user swaps an image for a new upload or URL old_src (third argument, v5.5+)
video.replaced A user swaps a video for a new upload, URL or embed old_src (third argument, v5.5+); the second argument is the upload response, or undefined for URL and embed

With these four events, every way a file can stop being used now gives you its address. The rest of this tutorial turns that into a working cleanup flow.

Prerequisites

  • Node.js 18 or newer (Express 5 requires it). Check with node -v.
  • Basic familiarity with JavaScript and the terminal.
  • Froala Editor v5.5.0 or newer. Older versions do not pass old_src.
  • A code editor and a modern browser.

The example uses Express 5 and Multer 2 for uploads. The editor code is plain JavaScript, so you can move the same event handlers into the React, Vue or Angular wrapper without changes.

Step-by-Step Guide

You will build a small app with one editor, a Save button and an Express server that stores uploads in an uploads folder. Here is the final structure:

Project structure
froala-media-cleanup/
├── public/
│   ├── index.html   (the editor page)
│   └── app.js       (editor setup and cleanup logic)
├── uploads/         (created automatically)
├── package.json
└── server.js        (Express server)

Step 1. Create the Project

Open a terminal and run:

Terminal
mkdir froala-media-cleanup
cd froala-media-cleanup
npm init -y
npm pkg set type=module
npm install express@5 multer@2 froala-editor@5.5.0
mkdir public

type=module lets you use import statements in Node.js. Multer is the Express middleware that reads uploaded files. Installing froala-editor from npm lets the server hand the exact same version to the browser, so the client and your typings always match.

Step 2. Build the Upload Routes

Create server.js and add the setup code and the two upload routes:

server.js
// server.js
import express from 'express';
import multer from 'multer';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = path.join(__dirname, 'uploads');
await fs.mkdir(UPLOAD_DIR, { recursive: true });
 
const app = express();
app.use(express.json());
 
// Serve the editor page, the uploaded files, and Froala's own JS/CSS.
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(UPLOAD_DIR));
app.use('/froala', express.static(path.join(__dirname, 'node_modules/froala-editor')));
 
// Allow-list of MIME types and the extension we save each one with.
const ALLOWED = {
  image: { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp' },
  video: { 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/ogg': '.ogv' }
};
 
// Every stored file is named <uuid>.<ext>, so the delete route can check names strictly.
const STORED_NAME = /^[0-9a-f-]{36}\.(jpg|png|gif|webp|mp4|webm|ogv)$/;
 
function createUploader(kind, maxBytes) {
  return multer({
    storage: multer.diskStorage({
      destination: UPLOAD_DIR,
      // Never trust the client's file name. Generate our own.
      filename: (req, file, cb) => cb(null, crypto.randomUUID() + ALLOWED[kind][file.mimetype])
    }),
    limits: { fileSize: maxBytes },
    // Reject anything that is not on the allow-list.
    fileFilter: (req, file, cb) => cb(null, Boolean(ALLOWED[kind][file.mimetype]))
  });
}
 
const imageUpload = createUploader('image', 5 * 1024 * 1024);  // 5 MB
const videoUpload = createUploader('video', 50 * 1024 * 1024); // 50 MB
 
// Froala sends the file in a field named "file" by default.
app.post('/upload/image', imageUpload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'Unsupported image type' });
  console.log('[upload] image saved:', req.file.filename);
  res.json({ link: `/uploads/${req.file.filename}` }); // Froala reads "link"
});
 
app.post('/upload/video', videoUpload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'Unsupported video type' });
  console.log('[upload] video saved:', req.file.filename);
  res.json({ link: `/uploads/${req.file.filename}` });
});

Two details matter here. First, Froala expects a JSON response with a link property, as the image upload guide explains. Second, the server picks every file name itself. Random names prevent collisions, and they also give the delete route a strict pattern to check against in the next step.

Step 3. Build a Safe Delete Route

Add one route that deletes both images and videos. It receives a src from the browser and removes the matching file:

server.js
// One delete route for both images and videos.
app.post('/media/delete', async (req, res) => {
  const src = req.body?.src;
  if (typeof src !== 'string' || src.length === 0) {
    return res.status(400).json({ error: 'Missing src' });
  }
 
  // Turn "/uploads/abc.jpg" or "http://localhost:3000/uploads/abc.jpg" into a URL object.
  let url;
  try {
    url = new URL(src, `${req.protocol}://${req.get('host')}`);
  } catch {
    return res.status(400).json({ error: 'Invalid src' });
  }
 
  // Only touch files that this server stored.
  const filename = path.basename(url.pathname);
  const isOurs =
    url.host === req.get('host') &&
    url.pathname === `/uploads/${filename}` &&
    STORED_NAME.test(filename);
 
  if (!isOurs) {
    return res.json({ deleted: false, reason: 'not-a-local-upload' });
  }
 
  try {
    await fs.unlink(path.join(UPLOAD_DIR, filename));
    console.log('[delete] removed:', filename);
    res.json({ deleted: true });
  } catch (err) {
    if (err.code === 'ENOENT') return res.json({ deleted: false, reason: 'already-gone' });
    throw err; // Express 5 forwards async errors to the error handler
  }
});

This route treats the incoming src as untrusted input, because anyone can send a request to it. Each check has a job:

  • Same host. A URL pointing at another website is ignored, so a YouTube embed or a hotlinked image never reaches fs.unlink.
  • Exact folder and name. The path must be /uploads/<name> and the name must match the UUID pattern. Requests like /uploads/../server.js fail this check, which blocks path traversal.
  • Already gone is fine. If the file was deleted earlier, the route answers with already-gone instead of an error. As you will see in the pitfalls section, the same file can be reported more than once, so the route must be safe to call twice.

Step 4. Add a Save Route and Start the Server

The cleanup flow depends on saving content, so add a minimal in-memory store, an error handler and the listen call at the end of server.js:

server.js
// A tiny in-memory "database" so the demo can save content.
// Replace this with your real database.
const posts = new Map();
 
app.get('/api/posts/:id', (req, res) => {
  res.json({ html: posts.get(req.params.id) ?? '' });
});
 
app.put('/api/posts/:id', (req, res) => {
  if (typeof req.body?.html !== 'string') return res.status(400).json({ error: 'Missing html' });
  posts.set(req.params.id, req.body.html);
  res.json({ saved: true });
});
 
// Turn Multer errors (like "file too large") into JSON responses.
app.use((err, req, res, next) => {
  console.error(err);
  res.status(err instanceof multer.MulterError ? 400 : 500).json({ error: err.message });
});
 
app.listen(3000, () => console.log('Open http://localhost:3000'));

In a real app you would sanitize the HTML on the server before storing it. Editor content is user input, and the editor’s client-side options are not a security boundary.

Step 5. Create the Editor Page

Create public/index.html. It loads Froala from the /froala route you added in Step 2, so the browser uses the exact version installed in node_modules:

public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Froala media cleanup demo</title>
  <!-- Froala CSS, served from node_modules by server.js -->
  <link rel="stylesheet" href="/froala/css/froala_editor.pkgd.min.css">
  <link rel="stylesheet" href="/froala/css/froala_style.min.css">
</head>
<body>
  <div id="editor"></div>
  <button id="save">Save</button>
  <span id="status"></span>
 
  <!-- Froala JS (packaged build with every plugin, including image and video) -->
  <script src="/froala/js/froala_editor.pkgd.min.js"></script>
  <script src="/app.js"></script>
</body>
</html>

The packaged build (.pkgd) already includes the image and video plugins, so you don’t need to load them separately.

Step 6. Track Files the User Removes

You could call the delete route directly inside image.removed. It looks simple, but it breaks Undo. If a user deletes an image by mistake and presses Ctrl+Z, Froala puts the <img> back, yet the file is already gone from your server. The user now sees a broken image.

So instead of deleting right away, record the file in a list called pendingDeletes. You will decide what to delete later, when the user saves. Start public/app.js with these helpers:

public/app.js
// Files the user removed or replaced since the last save.
const pendingDeletes = new Set();
 
// Turn any src into a path like "/uploads/abc.png" if it is one of our uploads.
function toUploadPath(src) {
  if (!src || src.startsWith('blob:') || src.startsWith('data:')) return null;
  const url = new URL(src, window.location.origin);
  if (url.origin !== window.location.origin) return null;
  return url.pathname.startsWith('/uploads/') ? url.pathname : null;
}
 
function queueDelete(src, reason) {
  const uploadPath = toUploadPath(src);
  if (!uploadPath) return; // external URL, YouTube embed, blob preview: nothing to clean
  pendingDeletes.add(uploadPath); // a Set ignores duplicates
  console.log(`[cleanup] queued (${reason}), will be deleted on Save if unused:`, uploadPath);
}

toUploadPath() turns both relative (/uploads/a.png) and absolute (http://localhost:3000/uploads/a.png) addresses into the same path. That way, one file always produces one entry in the Set.

The log message says the file will be deleted on Save, and only if the content no longer uses it. Removing an image in the editor does not delete the file right away, and the message makes that clear to anyone watching the console.

Now connect the removal events. They go inside the events object of the editor options (you will see the full file in Step 9):

public/app.js (events)
// Fires whenever an image leaves the content (popup, Delete key, cut...).
'image.removed': function ($img) {
  queueDelete($img.attr('src'), 'image removed');
},
 
// $video is the removed <video> (or <iframe>) element.
'video.removed': function ($video) {
  queueDelete($video.attr('src'), 'video removed');
},

Step 7. Track Files the User Replaces with old_src

This is where the new old_src argument from v5.5 does the work. The replaced events hand you the previous address directly, so tracking a replacement takes one line:

public/app.js (events)
// v5.5+: the third argument is the src of the image that was replaced.
'image.replaced': function ($img, response, old_src) {
  if (old_src !== $img.attr('src')) queueDelete(old_src, 'image replaced');
},
 
// v5.5+: response is the upload response (undefined for URL/embed), old_src is the old source.
'video.replaced': function ($video, response, old_src) {
  queueDelete(old_src, 'video replaced');
},

The old_src !== $img.attr('src') check skips the rare case where a user replaces an image with the same URL. Without it, you would queue a file that is still on the page. The save step protects against that too, but the check keeps your logs clean.

Because old_src arrives with the event, you don’t need any extra state or popup hooks to know which file was replaced. The editor gives you the exact value at the moment the replacement happens.

Step 8. Delete Queued Files After Saving

The Save button does two things, in this order:

  1. It sends the HTML to the server. If saving fails, nothing is deleted, because the last saved version may still use those files.
  2. It reads the saved HTML, collects every upload it still uses, and deletes only the queued files that are no longer there.

The second part is what makes Undo safe. If the user deletes an image and then undoes it, the file sits in pendingDeletes, but it is also in the saved HTML, so it is kept.

public/app.js
// Collect every upload path the saved HTML still uses.
function getUploadPathsInUse(html) {
  const doc = new DOMParser().parseFromString(html, 'text/html');
  const paths = new Set();
  doc.querySelectorAll('img[src], video[src]').forEach((el) => {
    const uploadPath = toUploadPath(el.getAttribute('src'));
    if (uploadPath) paths.add(uploadPath);
  });
  return paths;
}

DOMParser reads the HTML as a document without displaying it or running scripts, so you can safely search it for <img> and <video> tags.

Step 9. Put It All Together

Here is the complete public/app.js. Copy it as is:

public/app.js
// public/app.js
const POST_ID = 'demo';
 
// Files the user removed or replaced since the last save.
const pendingDeletes = new Set();
 
// Turn any src into a path like "/uploads/abc.png" if it is one of our uploads.
function toUploadPath(src) {
  if (!src || src.startsWith('blob:') || src.startsWith('data:')) return null;
  const url = new URL(src, window.location.origin);
  if (url.origin !== window.location.origin) return null;
  return url.pathname.startsWith('/uploads/') ? url.pathname : null;
}
 
function queueDelete(src, reason) {
  const uploadPath = toUploadPath(src);
  if (!uploadPath) return; // external URL, YouTube embed, blob preview: nothing to clean
  pendingDeletes.add(uploadPath); // a Set ignores duplicates
  console.log(`[cleanup] queued (${reason}), will be deleted on Save if unused:`, uploadPath);
}
 
async function deleteFromServer(uploadPath) {
  const res = await fetch('/media/delete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ src: uploadPath })
  });
  return res.json();
}
 
// Collect every upload path the saved HTML still uses.
function getUploadPathsInUse(html) {
  const doc = new DOMParser().parseFromString(html, 'text/html');
  const paths = new Set();
  doc.querySelectorAll('img[src], video[src]').forEach((el) => {
    const uploadPath = toUploadPath(el.getAttribute('src'));
    if (uploadPath) paths.add(uploadPath);
  });
  return paths;
}
 
const editor = new FroalaEditor('#editor', {
  // key: 'YOUR_LICENSE_KEY', // load from config, never commit it
 
  imageUploadURL: '/upload/image',
  imageAllowedTypes: ['jpeg', 'jpg', 'png', 'gif', 'webp'],
  imageMaxSize: 5 * 1024 * 1024,
 
  videoUploadURL: '/upload/video',
  videoAllowedTypes: ['mp4', 'webm', 'ogg'],
  videoMaxSize: 50 * 1024 * 1024,
 
  events: {
    initialized: async function () {
      const res = await fetch(`/api/posts/${POST_ID}`);
      const { html } = await res.json();
      this.html.set(html);
    },
 
    // Fires whenever an image leaves the content (popup, Delete key, cut...).
    'image.removed': function ($img) {
      queueDelete($img.attr('src'), 'image removed');
    },
 
    // v5.5+: the third argument is the src of the image that was replaced.
    'image.replaced': function ($img, response, old_src) {
      if (old_src !== $img.attr('src')) queueDelete(old_src, 'image replaced');
    },
 
    // $video is the removed <video> (or <iframe>) element.
    'video.removed': function ($video) {
      queueDelete($video.attr('src'), 'video removed');
    },
 
    // v5.5+: response is the upload response (undefined for URL/embed), old_src is the old source.
    'video.replaced': function ($video, response, old_src) {
      queueDelete(old_src, 'video replaced');
    }
  }
});
 
document.querySelector('#save').addEventListener('click', async () => {
  const status = document.querySelector('#status');
  const html = editor.html.get();
 
  // 1. Save the content first. If this fails, delete nothing.
  const saveRes = await fetch(`/api/posts/${POST_ID}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ html })
  });
  if (!saveRes.ok) {
    status.textContent = 'Save failed. No files were deleted.';
    return;
  }
 
  // 2. Delete only queued files the saved content no longer uses.
  const inUse = getUploadPathsInUse(html);
  for (const uploadPath of [...pendingDeletes]) {
    if (!inUse.has(uploadPath)) {
      const result = await deleteFromServer(uploadPath);
      console.log('[cleanup] delete', uploadPath, result);
    }
    pendingDeletes.delete(uploadPath);
  }
  status.textContent = 'Saved.';
});

Step 10. Test the Cleanup

Start the server:

Terminal
node server.js

Open http://localhost:3000, open the browser console, and keep an eye on the uploads folder. Then try these four scenarios:

What you do What you should see
Insert image A, click Save uploads holds A
Replace A with image B, click Save Console logs queued (image replaced), then A is deleted. Only B remains
Select all, press Delete, press Ctrl+Z, click Save B is queued but kept, because the saved HTML still uses it
Select all, press Delete, click Save B is deleted and uploads is empty

The terminal shows the server side of the same story, one [upload] or [delete] line per file. We ran these four scenarios in Chromium against Froala 5.5.0, Express 5.3.0 and Multer 2.5.0, and the folder ended each one in the state shown above.

Common Pitfalls

Deleting the file inside the event handler. This is the most common mistake. The removed events fire for Delete, Backspace and cut, and Undo can bring the element back. Cut and paste is a good example: the cut fires image.removed, the paste puts the same image back. Queue the deletion and confirm it against saved content, as in Step 8.

Assuming each file is reported once. In our tests, replacing an uploaded video reported the old file through both video.removed and video.replaced. Images reported a replacement only through image.replaced. Don’t build logic that counts events. Use a Set on the client and make the delete route return already-gone instead of an error.

Conclusion

Froala leaves file storage to you, and the v5.5 events now give you every address you need to manage it. image.removed and video.removed cover media that leaves the content, while old_src on image.replaced and video.replaced covers media that is swapped out. Combine them with a strict delete route and a save-then-delete flow, and your uploads folder only holds files your content actually uses.

For the full list of arguments, see the Events reference and the Deleting Images guide.

Ready to try it? Download Froala Editor, run the example above, and replace a few images while you watch the uploads folder shrink.

FAQ

Does Froala delete images from my server automatically?

No. Froala removes the element from the content only. Your server stored the file, so your server decides when to delete it. The events in this tutorial tell you which files are candidates.

What is inside the response argument of image.replaced?

When the replacement comes from an upload, it holds what your upload route returned. In our tests it arrived as the raw text of the response, for example '{"link":"/uploads/b.png"}', so call JSON.parse(response) if you need the new link. You can also read the new address with $img.attr('src'). For video.replaced, the response is undefined when the user inserts a URL or embed code.

What happens to files a user uploads but never saves?

They stay on the server, and the pendingDeletes list disappears when the page reloads. Add a scheduled job that lists files in uploads older than a day and deletes any that no saved document references. The events handle the everyday cases, and the job catches anything left over.

Does loading content with html.set() trigger image.removed?

In our tests with v5.5.0, no. Calling editor.html.set() to load a different document did not fire image.removed or video.removed, so loading saved content will not queue deletions by accident.

Can I use the same events in React, Vue or Angular?

Yes. Every official wrapper accepts the same options object, including events. Move the handlers and helper functions into your component, and the Express server stays exactly the same. The framework guides show where the config goes for each wrapper.

How do I adapt this for Amazon S3 or another cloud storage service?

The client code does not change. On the server, extract the object key from the src instead of a file name, check that it belongs to your bucket and folder, and call the storage provider’s delete method instead of fs.unlink. Keep the same ownership checks.

Posted on November 6, 2026

Mostafa Yousef

Senior web developer with a profound knowledge of the Javascript and PHP ecosystem. Familiar with several JS tools, frameworks, and libraries. Experienced in developing interactive websites and applications.

No comment yet, add your voice below!


Add a Comment

Your email address will not be published. Required fields are marked *