DEV Community

Cover image for Share an Audio Clip Straight to WhatsApp from a Web App: navigator.share() with Files, User Activation and Fallbacks
Abid Ali
Abid Ali

Posted on

Share an Audio Clip Straight to WhatsApp from a Web App: navigator.share() with Files, User Activation and Fallbacks

If your web app plays short audio clips, people will want to send them somewhere. In Pakistan that "somewhere" is almost always WhatsApp. The lazy version is a share button that sends a URL. It works, but the person on the other end gets a link, has to open a browser, wait for the page, and press play. Half the joke is gone by then.

What people actually want is to send the file, so it lands in the chat as audio and plays in place. On mobile browsers you can do that with the Web Share API's file support. It's a small API with a few sharp edges, and most of the bugs show up as "the button does nothing" on one phone and works fine on another.

This post builds a share button for an audio clip, step by step, with the fallbacks I'd ship alongside it.

The API in one paragraph

navigator.share(data) opens the operating system's share sheet. data can contain title, text, url and, in supporting browsers, files, an array of File objects. Before calling it with files you ask navigator.canShare({ files }), which returns false if this browser can't share that kind of file. The promise resolves when the user picks a target and rejects with AbortError if they close the sheet.

Support varies by browser and platform, and it changes, so don't hardcode a list. Feature-detect every time.

Step 1: Turn your clip into a File

You need actual bytes, not a URL. Fetch the clip and wrap the blob:

async function clipToFile(url, name) {
  const res = await fetch(url, { mode: "cors" });
  if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
  const blob = await res.blob();

  const type = blob.type || "audio/mpeg";
  const safeName = name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 60) || "clip";

  return new File([blob], `${safeName}.mp3`, { type });
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • The MIME type. Browsers keep an allowlist of shareable file types, and common audio formats such as MP3, OGG and WAV are on it in Chromium-based browsers. If your server sends application/octet-stream, canShare may say no. Fix the Content-Type on the server, or set the type explicitly as above.
  • The file name. It's what the recipient sees in some apps. "vine-boom.mp3" is friendlier than "a8f3c1e9.mp3". Keep it ASCII and short.

If your audio lives on a different origin (a CDN), that origin must send CORS headers or the fetch fails.

Step 2: The user activation problem

Here's the bug almost everyone hits. This looks correct:

button.addEventListener("click", async () => {
  const file = await clipToFile(clip.url, clip.title); // network request
  await navigator.share({ files: [file] });             // sometimes throws
});
Enter fullscreen mode Exit fullscreen mode

On a fast connection it works. On a slow one, share() rejects with NotAllowedError. The reason: share() requires transient user activation, meaning it must be called shortly after a real tap. That activation expires after a short window. If your fetch takes longer than that, by the time you call share() the browser no longer considers it a response to the tap.

On mobile data, a slow fetch is normal, so you need the file ready before the tap. A few ways:

  • Fetch the file when the share UI opens, for example when the user opens a clip's menu.
  • Start fetching on pointerdown and share on click. This buys a little time, not much.
  • Cache blobs for clips the user just played, since you've already downloaded them once.

A small cache does the job:

const fileCache = new Map();

function prepareFile(clip) {
  if (!fileCache.has(clip.id)) {
    fileCache.set(clip.id, clipToFile(clip.url, clip.title));
  }
  return fileCache.get(clip.id); // a Promise<File>
}

// Warm it up early: when the menu opens, or after the clip plays.
menuButton.addEventListener("click", () => prepareFile(clip));
Enter fullscreen mode Exit fullscreen mode

Then the share handler only awaits a promise that's usually already resolved.

Step 3: The share button

async function shareClip(clip) {
  let file;
  try {
    file = await prepareFile(clip);
  } catch {
    return fallbackShare(clip);
  }

  const data = { files: [file], title: clip.title };

  if (navigator.canShare?.(data)) {
    try {
      await navigator.share(data);
      return "shared";
    } catch (err) {
      if (err.name === "AbortError") return "cancelled"; // user closed the sheet
      // NotAllowedError (activation expired) or a platform error:
      return fallbackShare(clip);
    }
  }

  return fallbackShare(clip);
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Don't treat AbortError as a failure. The user changed their mind. Showing an error toast here is annoying.
  • Don't add url and text together with files without testing. Some targets drop the file or the text when given both. If the file is the point, send the file and a title.
  • Keep it in a click handler. Calling share() from a timer, an effect or after awaiting something slow will fail for the activation reason above.

Step 4: Fallbacks that still feel good

Desktop browsers, older phones and in-app browsers (the ones inside Instagram or Facebook) may not support sharing files at all. Plan for three fallbacks, in order:

1. Share the link instead. If navigator.share exists but can't take files, share the URL of the clip's page:

async function fallbackShare(clip) {
  const pageUrl = `${location.origin}/sound/${clip.slug}`;

  if (navigator.share) {
    try {
      await navigator.share({ title: clip.title, url: pageUrl });
      return "shared-link";
    } catch (err) {
      if (err.name === "AbortError") return "cancelled";
    }
  }
  return showFallbackMenu(clip, pageUrl);
}
Enter fullscreen mode Exit fullscreen mode

2. A WhatsApp link. WhatsApp's click-to-chat format https://wa.me/?text=... opens WhatsApp with a prefilled message and lets the user pick a chat. It only carries text, so it sends the page link, not the audio:

const waHref = `https://wa.me/?text=${encodeURIComponent(`${clip.title} ${pageUrl}`)}`;
Enter fullscreen mode Exit fullscreen mode

3. Download. For people who want the file, give them a proper download, then they can attach it manually:

async function downloadClip(clip) {
  const file = await prepareFile(clip);
  const href = URL.createObjectURL(file);
  const a = Object.assign(document.createElement("a"), { href, download: file.name });
  document.body.append(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(href), 10_000);
}
Enter fullscreen mode Exit fullscreen mode

The download attribute is ignored for cross-origin URLs, which is why this goes through a blob URL instead of linking to the CDN directly.

Step 5: Small things that make it feel finished

  • Label the button by what it does. "Send audio" on devices that can share files, "Share link" otherwise. Users shouldn't discover the difference after tapping.
  • Keep files small. A short clip encoded as a mono MP3 is tiny, which matters for both the pre-fetch and the person receiving it on mobile data.
  • Don't promise how it will appear. Depending on the target app and its version, a shared file may show up as an audio attachment rather than looking like a recorded voice note. It plays either way.
  • Test on real phones. Emulators don't open real share sheets. Test at least one Android phone with Chrome and one iPhone with Safari, plus the in-app browser of whatever social app sends you the most traffic.

Seeing the pattern in practice

On Meme Tap's WhatsApp audios soundboard, the clips are WhatsApp-style audio buttons you can play in the browser or download to send. That's exactly the kind of library where sending the file beats sending the link: the whole point is that the clip plays the moment it lands in the chat. The code above is my own generic version of the pattern, not the site's source.

Recap

  • Share files with navigator.share({ files }), after checking navigator.canShare.
  • Make sure the server sends a real audio Content-Type and CORS headers.
  • Fetch the file before the tap, or the user activation expires and share() throws.
  • Treat AbortError as a cancel, not an error.
  • Fall back to sharing the link, a wa.me text link, and a blob-based download.
  • Test on real devices and in-app browsers.

It's a small feature, but it's the difference between "here's a link to a joke" and the joke itself.


Abid Ali is a full-stack web developer in Pakistan. He builds Meme Tap, a free browser soundboard with one-tap meme sound buttons.

Top comments (0)