Autosave and a reliable unsaved-changes badge for an embedded email builder
Two features every embedded builder eventually needs: autosave, so a user never loses work, and an honest "unsaved changes" badge, so they know whether it's safe to close the tab. Both are easy to get subtly wrong. The classic bug is the false-dirty warning — the editor flashes "unsaved changes" the instant it loads, before the user has touched anything, because a change signal fires while the design is still being hydrated. BuilderJS gives you the right primitives to handle this cleanly: a debounced DOCUMENT_CHANGED event for "something mutated," and a distinct DOCUMENT_SAVED signal for "we just persisted." BuilderJS is a framework-agnostic, vanilla-JavaScript drag-and-drop editor for HTML email and pages that you self-host and embed in your own app — it is the editor, not a platform, so it does not send, host, or schedule anything. That division is exactly why this guide is honest about scope: the events are real and shipped, but the throttle, the dirty-state pill, and the beforeunload guard are yours to wire around them. This page shows the precise pattern — subscribe to DOCUMENT_CHANGED to mark dirty and throttle a getData() POST, listen for DOCUMENT_SAVED to clear the flag and toast, and guard the tab close — plus the one real detail most teams miss: load() commits a baseline that fires DOCUMENT_CHANGED, so you need a hydration guard, not a hopeful guess about subscription order.
Mount it
// BuilderJS is loaded as a global script (window.Builder) — no npm import.
const builder = new window.Builder({
mainContainer: '#bjs-canvas', // required CSS selector string
widgetsContainer: '#bjs-widgets', // optional
settingsContainer: '#bjs-settings', // optional
// saveUrl is optional — builder.save() would POST html + data for you.
eventDebounceMs: 120, // default; engine debounces DOCUMENT_CHANGED
});
const DEBOUNCE_MS = 120;
let dirty = false;
let saveTimer = null;
let saving = false;
let hydrating = true; // swallow the DOCUMENT_CHANGED that load()'s baseline fires
function setDirty(v) {
dirty = v;
document.getElementById('savePill').textContent = v ? 'Unsaved changes' : 'All changes saved';
}
async function autosave() {
if (saving) return; // one in-flight save at a time
saving = true;
try {
const data = builder.getData(); // lossless JSON snapshot
const res = await fetch('/api/designs/42', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!res.ok) throw new Error('save failed: ' + res.status);
// Engine does NOT auto-fire DOCUMENT_SAVED — you emit it on success.
builder.events.emit(window.Builder.EVENTS.DOCUMENT_SAVED);
} finally {
saving = false;
}
}
// 1) Mutations: mark dirty + throttle the save (engine already debounced).
const onChanged = () => {
if (hydrating) return; // ignore the load()-baseline fire
setDirty(true);
clearTimeout(saveTimer);
saveTimer = setTimeout(autosave, 1500); // save after a 1.5s pause
};
// 2) Saved: clear dirty + toast (fires for both autosave and Save button).
const onSaved = () => {
setDirty(false);
showToast('Saved ' + new Date().toLocaleTimeString());
};
builder.events.on(window.Builder.EVENTS.DOCUMENT_CHANGED, onChanged);
builder.events.on(window.Builder.EVENTS.DOCUMENT_SAVED, onSaved);
// load() commits a baseline that emits a DEBOUNCED DOCUMENT_CHANGED. Clearing
// the guard must wait until AFTER that ~120ms emit has dispatched — a setTimeout
// of 0 would clear too early and let the false-dirty through. Subscribing after
// load() does NOT help: the debounced emit still reaches a late listener.
builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL);
setTimeout(() => { hydrating = false; }, DEBOUNCE_MS + 20);
// Optional manual Save (e.g. Cmd-S): drain the pending autosave first so it
// can't race your POST, then save + emit DOCUMENT_SAVED yourself.
// function onCmdS() {
// builder.events.flushPending(window.Builder.EVENTS.DOCUMENT_CHANGED);
// clearTimeout(saveTimer);
// autosave();
// }
// 3) Tab-close guard — prompts only while there are pending edits.
const onBeforeUnload = (e) => { if (dirty) { e.preventDefault(); e.returnValue = ''; } };
window.addEventListener('beforeunload', onBeforeUnload);
// Teardown (SPA route change / modal close): unsubscribe to avoid leaks.
// builder.events.off(window.Builder.EVENTS.DOCUMENT_CHANGED, onChanged);
// builder.events.off(window.Builder.EVENTS.DOCUMENT_SAVED, onSaved);
// window.removeEventListener('beforeunload', onBeforeUnload);
// (No destroy() exists — the framework discards the container DOM on unmount.)
Two events, two jobs: DOCUMENT_CHANGED and DOCUMENT_SAVED
BuilderJS exposes a small event bus on the live instance, builder.events, with canonical constants on Builder.EVENTS. Two of them carry the whole autosave/dirty-state story. DOCUMENT_CHANGED fires whenever the design tree commits a mutation — a typed character, a dragged block, a property change, an undo, a programmatic insert. It is debounced by default (120 ms, configurable via new Builder({ eventDebounceMs: ... })), so a burst of typing collapses into one trailing-edge signal instead of a flood. That single property is what makes it safe to hang autosave off: you are not POSTing on every keystroke. DOCUMENT_SAVED is the other half — it marks 'this design is now persisted.' The honest detail to internalize: the engine does not auto-fire DOCUMENT_SAVED for you. It is the constant your code emits (builder.events.emit(Builder.EVENTS.DOCUMENT_SAVED)) right after your save request resolves successfully. Keeping the two signals distinct is the whole trick: 'changed' and 'saved' are separate facts, so your dirty flag becomes the simple difference between them rather than a guess based on timers or HTML diffing. The bus also gives you off() to unsubscribe and flushPending() to fire any debounced DOCUMENT_CHANGED synchronously — handy when a manual Save must not race a pending autosave.
Subscribe to DOCUMENT_CHANGED — mark dirty, throttle the save
On each DOCUMENT_CHANGED fire, do two cheap things: flip a dirty flag (which your badge reads) and schedule a throttled save. Because the event is already debounced inside the engine, your throttle is a second, coarser gate — a typical pattern is a trailing setTimeout of one to a few seconds that you reset on each fire, so autosave runs once the user pauses, not mid-edit. When the timer fires, call builder.getData() to get the lossless JSON snapshot and POST it to your own backend route. (If you also want the rendered markup, builder.getHtml() returns it; for email you'd run that through the export path before sending — but the JSON is your source of truth.) BuilderJS is JSON-first and persistence-agnostic by design: getData() hands the document to your code and your backend decides where it lives — a database column, a file, object storage. Two production niceties: keep a single in-flight save (skip or queue if one is still pending) so a fast typist can't stack requests, and remember the value of the throttle window is a UX call — shorter feels safer, longer is gentler on your server.
Kill the false-dirty-on-load false positive
This is the bug the whole guide exists to prevent, and it is more subtle than 'subscribe at the right time.' DOCUMENT_CHANGED is emitted from the history layer for every committed mutation — and a programmatic builder.load() commits a baseline snapshot at the end of hydration, which counts as a commit. So load() does fire DOCUMENT_CHANGED while restoring a saved design the user never touched. The trap most teams fall into: assuming you can dodge it by subscribing only after load() resolves. You can't. That baseline emit is debounced (120 ms by default), so it dispatches a fraction of a second later — after your post-load subscription is already attached — and your handler runs anyway. Subscription order alone does not save you. The reliable fix is a deterministic hydration guard: set an isHydrating flag before load(), have your DOCUMENT_CHANGED handler ignore fires while it's true, and clear the flag only after the debounce window has passed (e.g. a setTimeout slightly longer than eventDebounceMs) so the baseline fire is swallowed. This anchors 'clean' to a concrete point in time rather than to wall-clock heuristics — and it's exactly why the separate DOCUMENT_SAVED signal matters: you clear dirty on a real saved event, never by trying to diff HTML or compare snapshots to decide whether a change 'counted.'
Clear on DOCUMENT_SAVED, then guard the tab close
Close the loop in two places. First, after your save POST returns success, clear the dirty flag and emit DOCUMENT_SAVED yourself so any other listener (a toast, a 'Last saved 12:04' pill, a header state) updates from one source of truth instead of from inside the fetch callback. Subscribing a toast to DOCUMENT_SAVED keeps your save-button path and your autosave path firing the same feedback without duplicated code. Second, add a window beforeunload guard that prompts only while the dirty flag is set — so a user who has pending, un-POSTed edits gets the browser's native 'leave site?' confirmation, and a user who is fully saved closes the tab with no friction. That guard is honest only because the dirty flag is honest, which is the payoff of the earlier steps. Finally, if your host tears the editor down (SPA route change, modal close), unsubscribe with builder.events.off(Builder.EVENTS.DOCUMENT_CHANGED, handler) using the same function reference so listeners don't leak across re-mounts. Note there is no destroy() method on the instance — on a real unmount your framework discards the container DOM and the editor with it; the only manual cleanup is removing listeners and your beforeunload handler.
What's real vs. what you wire — the honest scope
To be exact about the division of labor: the events are shipped engine behavior, the orchestration is yours. Real and provided by BuilderJS: the builder.events bus, the Builder.EVENTS.DOCUMENT_CHANGED constant (debounced, default 120 ms, eventDebounceMs-configurable, emitted from the history layer on every committed mutation — including the baseline commit during load()), the DOCUMENT_SAVED constant, getData()/getHtml() for serialization, on()/off() for subscribe/teardown, flushPending() to drain a pending debounced change before a manual save, and optional saveUrl + builder.save() which POSTs html and data as url-encoded form fields if you'd rather not hand-roll the fetch. Yours to build: the dirty flag itself, the throttle/timer around the save POST, the visual badge or 'last saved' pill, the toast, the beforeunload guard, and the hydration guard that ignores the load-time fire. There is no built-in 'autosave mode,' no hosted save service, and no save:click event — saving is host-driven precisely so you keep CSRF, routing, slugs, and storage under your own control. That's the BuilderJS contract end to end: it owns the editor and the edit/save signals; you own persistence, UX, and delivery. You buy the source once under a one-time CodeCanyon license, self-host it, and wire these few seams to fit your app.
Frequently asked questions
-
Why does my embedded builder show 'unsaved changes' the moment it loads?
Because a programmatic builder.load() commits a baseline snapshot as it hydrates a saved design, and in BuilderJS that commit fires DOCUMENT_CHANGED — for content the user never touched. The common mistake is assuming you can avoid it by subscribing only after load() resolves; you can't, because that baseline emit is debounced (about 120 ms) and dispatches after your handler is already attached. The reliable fix is a hydration guard: set an isHydrating flag, have your DOCUMENT_CHANGED handler ignore fires while it's true, and clear the flag only after the debounce window passes. Then clear dirty solely on the distinct DOCUMENT_SAVED signal — never by diffing HTML.
-
How do I throttle autosave so I'm not POSTing on every keystroke?
Two gates. DOCUMENT_CHANGED is already debounced inside the engine (120 ms by default, set via new Builder({ eventDebounceMs: ... })), so a typing burst collapses to one signal. On top of that, add your own trailing timer — reset a setTimeout of one to a few seconds on each fire and call builder.getData() to POST when it elapses. Keep a single in-flight save so fast edits can't stack requests. If you also expose a manual Save button, call builder.events.flushPending(Builder.EVENTS.DOCUMENT_CHANGED) first so the pending autosave doesn't race your manual POST.
-
Does builder.save() fire DOCUMENT_SAVED automatically?
No — and being precise about this avoids a subtle bug. The engine never auto-emits DOCUMENT_SAVED; it's the canonical constant your code emits after your save resolves successfully (builder.events.emit(Builder.EVENTS.DOCUMENT_SAVED)). Whether you use the built-in builder.save() with a saveUrl or hand-roll your own fetch around getData(), emit DOCUMENT_SAVED on success so your dirty flag, toast, and 'last saved' pill all update from one source of truth.
-
How do I warn the user before they close the tab with unsaved edits?
Add a window beforeunload listener that prompts only while your dirty flag is set. Because the flag is set on DOCUMENT_CHANGED and cleared on DOCUMENT_SAVED, the native 'leave site?' prompt appears exactly when there are pending, un-POSTed edits and stays silent when everything is saved. Remember to remove the beforeunload handler (and call builder.events.off with the same handler reference) when you tear the editor down.
-
Is any of this an autosave feature inside BuilderJS, or do I build it?
The events are real and shipped — DOCUMENT_CHANGED, DOCUMENT_SAVED, the builder.events bus, getData()/getHtml(), on()/off(), flushPending(), and optional saveUrl/save(). The autosave throttle, the dirty-state badge, the toast, the beforeunload guard, and the load-time hydration guard are yours to wire. BuilderJS is the editor, not a platform: there's no hosted save service and no built-in 'autosave mode,' which is by design so persistence, routing, CSRF, and storage stay under your control.