document.execCommand('copy') has been marked deprecated since 2018 and is still one of the most widely used clipboard APIs on the web. The reason is simple: the textarea-select-execCommand trio works in every browser, including insecure-context contexts and iframes that would refuse the modern API. Migrating away without regressing compatibility is the tricky part.
This article walks through a migration that keeps the happy path on the modern async API and falls back to the legacy approach only where the modern API cannot reach. No flag day, no users left behind.
Why migrate at all
execCommand is not going away soon — removing it would break too much of the web. But browsers have stopped implementing improvements to it. Specifically:
- It cannot write images or custom MIME types. Only the active selection's text ends up on the clipboard.
- It returns
trueorfalsesynchronously, with no useful error information. - It requires a visible DOM element with a selection, which forces awkward helper code.
- It is flagged by modern linters and some frameworks log warnings when it is used.
The async Clipboard API fixes all of these — once your runtime environment supports it.
The modern replacement
navigator.clipboard.writeText(value) is the direct equivalent of the legacy copy trick. No hidden textarea, no selection management, no synchronous boolean:
await navigator.clipboard.writeText(value);
On HTTPS, in a user gesture, on a modern browser — this just works. That is the target.
The migration shape
A migration that does not break users looks like this:
- Feature-detect the modern API.
- If available, call it and catch errors.
- On any failure, fall back to the legacy approach.
- On a second failure, show a visible copy-failed message.
async function copy(text) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) { /* fall through */ }
}
return legacyCopy(text);
}
Keeping the fallback in place is worth the maintenance cost. It catches insecure-context deployments, cross-origin iframes, and browsers where the modern API is present but gated.
The legacy function, cleaned up
The textarea approach is usually copied from Stack Overflow and accumulates bugs over time — forgotten cleanup, wrong positioning, broken mobile behaviour. A cleaner version:
function legacyCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.appendChild(ta);
const selection = document.getSelection();
const prevRange = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
ta.select();
ta.setSelectionRange(0, ta.value.length);
let ok = false;
try { ok = document.execCommand('copy'); } catch { }
document.body.removeChild(ta);
if (prevRange) { selection.removeAllRanges(); selection.addRange(prevRange); }
return ok;
}
The improvements over the common version:
readonlyprevents mobile keyboards from popping up during the copy.position: fixedwithtop/leftset avoids the page scrolling to the textarea.pointer-events: nonestops the invisible element from swallowing clicks if cleanup is delayed.- The user's prior text selection is restored after the operation.
Writing rich content during the migration
If your legacy code copies HTML by calling execCommand('copy') on a styled contenteditable region, the modern equivalent is a ClipboardItem with both text/html and text/plain blobs:
const item = new ClipboardItem({
'text/plain': new Blob([plain], { type: 'text/plain' }),
'text/html': new Blob([html], { type: 'text/html' }),
});
await navigator.clipboard.write([item]);
The async API has no legacy equivalent for copying binary data like images — if you need image copy support, ship only the modern API and show a clear message on unsupported environments rather than trying to fake it.
Handling the migration in a large codebase
A typical app has 5-30 call sites, scattered across React components, utility files, and third-party libraries. A clean migration:
- Introduce a single
copyToClipboard(text)utility implementing the async-with-legacy-fallback pattern. - Grep for
execCommandand replace each call site with a call to the utility. - Grep for
navigator.clipboardand consolidate direct uses into the utility. - Delete any homegrown fallback implementations.
The grep is usually faster than you expect — the pattern is distinctive and each hit is a small, isolated change.
Tests
Legacy tests often mock document.execCommand. Replace them with mocks of navigator.clipboard.writeText. Keep one integration-style test that asserts the full fallback path runs when the async API throws — that is the path regressions sneak through.
What to verify after the switch
After deploying, sanity-check these paths on a real device, not in headless CI:
- Desktop Chrome, Firefox, Safari, Edge — on HTTPS. All should take the async path.
- Mobile iOS Safari and Android Chrome. Both should take the async path with no permission prompts.
- Any staging environment without HTTPS — the fallback must still copy correctly.
- Any page rendered inside a cross-origin iframe. Depending on
Permissions-Policy, either the async API works or the fallback kicks in.
When to remove the fallback
Eventually. The async API is widely supported; the fallback exists mainly for insecure-context and iframe cases. If your app runs only on HTTPS and controls its iframe environments, you can delete the fallback after a sunset period. For public apps, keeping the fallback for another few years is the pragmatic call.
After migrating, use Clipboard Inspector to confirm the copy produces the expected MIME types and values on each target browser — especially the HTML variant if you write rich content. For a deep dive into the broader API, see Clipboard API vs Paste Event.