Web Share Target¶
Web Share Target lets an installed PWA appear in the operating system's share sheet next to native apps, so users can send it links, text, images and other files from any app. You declare a share_target member in the web app manifest; when the user picks your app, the browser launches it by navigating to your action URL, either as a GET with the shared values in the query string or as a multipart/form-data POST that your service worker intercepts, stores and answers with a redirect. It works in Chrome on Android (as part of the WebAPK) and on ChromeOS, and Microsoft documents it for PWAs installed with Edge on Windows. Safari, Firefox and Chrome on Windows, macOS and Linux do not support it.
Key takeaways
- Nothing happens until the app is installed. On Android the share target is compiled into the WebAPK that Chrome mints at install time; a plain home-screen shortcut never receives shares.
- Use
"method": "GET"for text and links you only display or draft; use"method": "POST"with"enctype": "multipart/form-data"whenever you acceptfiles(required) or the share has side effects. - Handle POST shares in the service worker: read
request.formData(), persist the data (IndexedDB storesFileobjects natively), and answer withResponse.redirect(url, 303)so the page loads with GET and a reload never re-submits. - Android never fills the
urlparameter. Links arrive intext, sometimes intitle; Chrome's own share sheet sendstextandurljoined by a space. Always extract URLs from text. - Chromium invalidates the whole
share_targetfor one malformed MIME type inaccept, forfileswithout POST and multipart, or for anactionoutside the manifestscope. Check the DevTools Manifest pane after every change. - The action URL is a public endpoint: any site can POST a form to it. Treat everything that arrives as untrusted input, validate files by content, and confirm with the user before side effects.
- Support: Chrome for Android 71+ (GET) and 76+ (POST and files), Samsung Internet 12+, ChromeOS 89+. No support in Safari (macOS, iOS, iPadOS), Firefox, or Chrome on Windows, macOS and Linux.
How a share reaches your PWA¶
Share targets are declarative. Your app does not run any code to register; the browser reads the manifest when it installs the app and registers the app with the OS (on Android by generating intent filters in the WebAPK, on ChromeOS by adding it to the sharesheet). When the user shares to it, the browser converts the platform's share payload into a navigation to your action URL.
sequenceDiagram
participant S as Source app
participant OS as OS share sheet
participant B as Browser (WebAPK on Android)
participant SW as Service worker
participant P as Receiver page
S->>OS: share text, link or files
OS->>B: user picks your PWA
B->>B: map payload to title, text, url, files
alt method GET
B->>SW: GET /share-target/?title=…&text=…
SW-->>P: cached receiver page
else method POST
B->>SW: POST /share-target/ (multipart body)
SW->>SW: formData(), validate, store in IndexedDB
SW-->>B: 303 See Other, Location /share-target/?share=id
B->>SW: GET /share-target/?share=id
SW-->>P: cached receiver page
P->>P: read share from IndexedDB, render
end Because the result is an ordinary navigation, everything you know about fetch handling applies: the request goes through your service worker if one controls the action URL, and to the network otherwise.
Specification status¶
Two documents describe the feature, and they differ in scope:
| Document | Venue and status | Scope |
|---|---|---|
| Web Share Target API | W3C Web Applications Working Group; an unofficial editor's draft that describes itself as "an early draft" | action, method, enctype, and params with title, text and url. No files. |
| Web Share Target API, Level 2 | WICG Community Group draft | Everything above plus params.files, the accept-matching algorithm and multipart encoding |
Chromium implements Level 2; its manifest parser cites the Level 2 algorithm step by step. Mozilla's standards position on Web Share Target is "positive", but Firefox has not implemented it (Firefox for Android parses the member without effect). WebKit's position is "neutral", with security and integration concerns noted. Web Share Target is independent of the Web Share API: an engine could implement either without the other, and Safari implements only the sending side.
The share_target manifest member¶
A complete declaration that accepts text, links and several kinds of files:
{
"id": "/",
"name": "Scrapbook",
"start_url": "/",
"scope": "/",
"display": "standalone",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" }
],
"share_target": {
"action": "/share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{ "name": "media", "accept": ["image/*", "video/mp4", "video/webm", ".mp4", ".webm"] },
{ "name": "documents", "accept": ["application/pdf", ".pdf"] }
]
}
}
}
Members, defaults and validation rules¶
| Member | Type | Default | Rule (specification and Chromium) |
|---|---|---|---|
action | URL string | none, required | Parsed against the manifest URL. Must be within the manifest scope and have a potentially trustworthy origin, or the whole member is ignored. |
method | "GET" or "POST" | "GET" | ASCII case-insensitive. Any other value, or a non-string, invalidates the member. Chromium warns when it is missing. |
enctype | "application/x-www-form-urlencoded" or "multipart/form-data" | "application/x-www-form-urlencoded" | ASCII case-insensitive. Any other value invalidates the member, and so does multipart/form-data with GET. Chromium warns when it is missing. |
params | object | none, required | A missing or non-object value invalidates the member. |
params.title, .text, .url | string | none | The names of the query parameters or form fields that carry the shared title, text and URL. Chromium trims them. An empty name means "do not send this field". |
params.files | object or array of objects | none | Only valid with POST and multipart/form-data; otherwise the whole member is invalid. A single object is treated as a one-element array. |
files[].name | string | none, required | Form field name for matching files. An entry with a missing or empty name is dropped. |
files[].accept | string or array of strings | none, required | MIME types (image/png), wildcards (image/*, */*) or extensions starting with a dot (.heic). An entry whose accept list ends up empty is dropped. |
The params keys map from ShareData members to your field names. With "text": "body", the shared text arrives as body. Most apps use identical names; distinct names are useful when an existing endpoint already expects subject and message.
How Chromium parses share_target¶
The specification is forgiving about bad accept strings: it removes each invalid string with a developer warning and keeps the rest. Chromium is stricter. It lowercases every accept string and requires each one to be either an extension (any string that starts with .) or a parsable type/subtype whose top-level type is */* or one of application, audio, example, font, image, message, model, multipart, text, video or an x- prefixed type. If any string fails, the entire share_target is discarded with the message "invalid mime type inside files." Common culprits are "image/", "images/*", "jpg" without a dot and "*".
The messages Chromium writes to the console and to the DevTools Application > Manifest pane are the fastest way to find the problem:
| Message | Cause | Effect |
|---|---|---|
Method should be set to either GET or POST. It currently defaults to GET. | method missing | Warning; GET is used |
Enctype should be set to either application/x-www-form-urlencoded or multipart/form-data. It currently defaults to application/x-www-form-urlencoded | enctype missing | Warning; urlencoded is used |
property 'share_target' ignored. Property 'action' is invalid. | action missing, unparsable or outside scope | Member ignored |
property 'share_target' ignored. Property 'params' type dictionary expected. | params missing or not an object | Member ignored |
invalid method. Allowed methods are:GET and POST. | Unknown or non-string method | Member ignored |
invalid enctype. Allowed enctypes are:application/x-www-form-urlencoded and multipart/form-data. | Unknown or non-string enctype | Member ignored |
invalid enctype for GET method. Only application/x-www-form-urlencoded is allowed. | GET with multipart | Member ignored |
files are only supported with multipart/form-data POST. | files with GET or urlencoded POST | Member ignored |
invalid mime type inside files. | One malformed accept string | Member ignored |
property 'name' missing. / property 'accept' ignored, type array or string expected. | Malformed file entry | Entry dropped |
The shared URL rules for manifest members apply to action exactly as to start_url or shortcut URLs: relative values resolve against the manifest's location, and "within scope" is a same-origin, string-prefix test on the path. They are explained on Advanced & Integration Members and App Identity & Updates.
How files are matched to fields¶
For each shared file the browser picks the first files entry whose accept list matches it and appends the file to that entry's field, so a field can repeat and you read it with formData.getAll(). The specification is strict about the rest: "If a file being shared is not accepted by any of a share target's files entries, the user MUST NOT be presented with that web share target as an option." In practice the operating system's filtering decides what is offered, and Chrome's WebAPK code simply skips a file for which it finds no matching entry. Either way, the order of entries matters: put specific entries before catch-alls such as */*.
Matching is by MIME type, by extension, or both. The Level 2 specification requires an implementation to support at least one of them. Chrome on Android evaluates both MIME types and extensions against the WebAPK's stored accept lists, but it takes the extension from the shared file's content URI, and many Android apps share URIs such as content://media/external/images/media/1234 that have no extension at all. ChromeOS filters only by MIME type and ignores extension criteria (a comment in Chromium's share_target_utils.cc says so explicitly). List MIME types first, and treat extensions as a supplement for types whose MIME string varies between apps (.heic, .gpx, .md).
GET or POST?¶
| GET | POST, urlencoded | POST, multipart | |
|---|---|---|---|
| Carries | title, text, url | title, text, url | title, text, url, files |
| Where the data is | Query string of the action URL | Request body | Request body |
| Service worker needed | No (but recommended for offline) | Yes, for offline and privacy | Yes, for offline and privacy |
| Visible in history, server logs, analytics | Yes, until you strip it | No | No |
| Size | The specification allows truncating each value to 2,000 bytes | Body | Body |
| Suits | Drafting a post or message for the user to edit | Saving a bookmark or note | Photos, documents, anything with files |
The specification recommends GET "when the share target drafts a message for subsequent user approval" and POST when "the share target performs a side-effect without any user interaction". In practice most apps use POST multipart for everything, so a single code path handles both text and files, and so shared content never lands in URLs.
Requirements and platform support¶
The app must be installed through the browser's own install flow, served over HTTPS, and the action URL must be inside the scope. Beyond that, support depends on the platform integration the browser has built:
| Platform | GET | POST text | POST files | Notes |
|---|---|---|---|---|
| Chrome for Android | ✅ 71 | ✅ 76 | ✅ 76 | Requires a WebAPK. Other Chromium-based Android browsers vary. |
| Samsung Internet | ✅ 12.0 | ✅ 12.0 | ✅ 12.0 | |
| ChromeOS (Chrome) | ✅ 89 | ✅ 89 | ✅ 89 | Appears in the ChromeOS sharesheet. Files filtered by MIME type only. |
| Edge on Windows | ⚠️ | ⚠️ | ⚠️ | Microsoft documents share-target registration for PWAs installed with Edge on Windows1 |
| Chrome on Windows, macOS, Linux | ❌ | ❌ | ❌ | Parsed, but not registered with the OS2 |
| Safari (macOS, iOS, iPadOS) | ❌ | ❌ | ❌ | WebKit position: neutral |
| Firefox (desktop, Android) | ❌ | ❌ | ❌ | Firefox for Android parses the member without effect |
Support data as of September 2026. See MDN's share_target reference and Chrome Platform Status for live data.
Android: the WebAPK is the share target¶
On Android, Chrome mints a WebAPK when the user installs your PWA. The WebAPK is a real Android package, and the share target is compiled into its AndroidManifest.xml as intent filters for Android's send actions, with metadata recording your action, method, enctype, param names and accept lists. Three consequences follow:
- No WebAPK, no share target. If the install falls back to a plain shortcut (for example because WebAPK minting failed, or the browser does not mint WebAPKs), your app does not appear in the share sheet.
- Manifest changes need a WebAPK update. Adding, removing or changing
share_targetrequires Chrome to regenerate the WebAPK, which happens asynchronously after Chrome notices the manifest change. While developing, openchrome://webapkson the device and use the Update button for your app; Chromium's own explanation is that the WebAPK "will check for an update the next time it launches", shows "Scheduled" when an update is pending, and installs it "once the WebAPK is closed (this may take a few minutes)". The update rules are covered on App Identity & Updates. - The OS does the filtering. Android decides whether to offer your app based on the intent filters, before Chrome runs. If your app never shows up for a file type, check the MIME type the source app declares.
ChromeOS: the sharesheet¶
On ChromeOS, installed web apps with a share_target appear in the sharesheet that Files, Chrome's own share menu and Android apps use. Chrome builds the navigation itself, filters files by MIME type only, and attributes the navigation to your app's origin, so SameSite cookies behave as for a form submission by your own app rather than as for a user-typed URL.
What actually arrives, per platform¶
The browser maps the platform's share payload to title, text, url and files, and the mapping is lossy. The receiving code must cope with every variant below.
| Situation | title | text | url | Files |
|---|---|---|---|---|
| Android, any app shares a link | Intent subject, if the source app set one | Intent text, which usually contains the link | Always empty | |
Android, Chrome shares a page (navigator.share({title, text, url}) or the Share menu) | Title | text and url joined by one space | Always empty | |
Android, text shared but your manifest has no text param | Text delivered as a file named shared.txt (text/plain) if a files entry accepts text/plain | |||
Android, a .txt file shared, no text, your manifest has a text param and no files entry accepts text/plain | File contents may arrive as the text field | |||
| ChromeOS | Title | Text, minus a trailing URL | Last whitespace-separated token of the text, if it parses as a URL | Filtered by MIME type |
The Android behavior comes straight from Chromium's WebAPK code: the GET URL is built from EXTRA_SUBJECT (title) and EXTRA_TEXT (text) only, and the POST body contains title, text and files, never a URL. The Web Share Target specification anticipates exactly this ("the host share system may not have a dedicated URL field… This is the case on Android") and allows browsers to move a URL out of the text, which ChromeOS does and Android does not.
A normalization step on the receiving side makes the rest of your app independent of these differences:
/** Android never fills url; links arrive inside text (or title). */
export function normalizeShare({ title = "", text = "", url = "" }) {
let foundUrl = safeHttpUrl(url);
if (!foundUrl) {
for (const source of [text, title]) {
const match = source.match(/https?:\/\/[^\s<>"]+/i);
// Trailing punctuation is usually sentence punctuation, not part of the link.
const candidate = match && safeHttpUrl(match[0].replace(/[).,;!?]+$/, ""));
if (candidate) {
foundUrl = candidate;
if (source === text) text = text.replace(match[0], "").replace(/\s{2,}/g, " ");
break;
}
}
}
return { title: title.trim(), text: text.trim(), url: foundUrl };
}
/** Accept only http(s) URLs; javascript:, data: and friends are dropped. */
export function safeHttpUrl(value) {
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : "";
} catch {
return "";
}
}
normalizeShare({ text: "Look at these https://example.com/album?id=1+2 !" }) returns { title: "", text: "Look at these !", url: "https://example.com/album?id=1+2" }.
Handling GET shares in the page¶
A GET share target is the simplest possible integration: the browser opens action?title=…&text=…&url=… (only the params you declared, and only those with a value) and your page reads the query string.
{
"share_target": {
"action": "/share-target/",
"method": "GET",
"params": { "title": "title", "text": "text", "url": "url" }
}
}
If an incoming share contains the title "My News" and the URL http://example.com/news, the specification's example shows the resulting navigation as /share-target/?title=My+News&url=http%3A%2F%2Fexample.com%2Fnews (on Android the URL would be inside text instead). Spaces are encoded as + because the query is produced by the application/x-www-form-urlencoded serializer, just like an HTML form. URLSearchParams decodes + as a space; decodeURIComponent() does not, which is a classic source of My+News titles.
const params = new URLSearchParams(location.search);
// Remove the shared data from the address bar and the history entry at once:
// a reload must not process it twice, and analytics must never see it.
history.replaceState(history.state, "", location.pathname);
const share = normalizeShare({
title: params.get("title") ?? "",
text: params.get("text") ?? "",
url: params.get("url") ?? "",
});
renderDraft(share); // always via textContent / value, never innerHTML
Two details make a GET target robust:
- Serve the receiver from the service worker cache, ignoring the query string. Every share has a different URL, so a cache lookup without
ignoreSearch: truemisses and the share fails offline. The service worker in the next section does this for both GET and POST targets. - Strip the query before anything else runs. Analytics scripts, error reporters and
Refererheaders otherwise pick up whatever the user shared. The defaultstrict-origin-when-cross-originreferrer policy already hides the query from cross-origin requests, but same-origin requests and your own logs still see it.
Handling POST shares in the service worker¶
With "method": "POST", the browser performs a form submission to action. If your service worker controls that URL, its fetch handler receives a navigation request whose method is POST and whose body is the encoded form. The pattern has three steps: parse the body with request.formData(), persist what you need, and respond with a 303 redirect to a GET URL that loads your receiver page. The following files are a complete implementation, and the Playwright test under Testing share targets exercises the whole flow, including the offline POST, file filtering and reload behavior, by submitting a real multipart form to the service worker.
The service worker¶
/* sw.js: Web Share Target (POST + files) handling. */
importScripts("/share-db.js"); // (1)!
const SHELL_CACHE = "shell-v1";
const SHELL_URLS = ["/share-target/", "/share-target/receiver.js", "/share-db.js"];
// Keep in sync with manifest.webmanifest > share_target.
const SHARE_ACTION_PATH = "/share-target/";
const FIELDS = { title: "title", text: "text", url: "url", files: "media" };
// Defensive limits: anyone can POST to the action URL, not only the OS share sheet.
const MAX_FILES = 10;
const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
const MAX_TEXT_CHARS = 100_000;
const ACCEPTED_TYPES = [/^image\/(png|jpeg|gif|webp|avif)$/, /^video\/(mp4|webm)$/, /^application\/pdf$/];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_URLS)));
// Activating immediately is safe only while this worker serves a few
// unversioned files. See the note after this sample before copying it.
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Remove shell caches left by previous versions of this worker.
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key.startsWith("shell-") && key !== SHELL_CACHE)
.map((key) => caches.delete(key))
);
await self.clients.claim();
})()
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.method === "POST" && url.pathname === SHARE_ACTION_PATH) {
event.respondWith(receiveShare(request)); // (2)!
// Housekeeping runs alongside; failures must not affect the share.
event.waitUntil(ShareDB.prune().catch(() => {}));
return;
}
if (request.method === "GET" && SHELL_URLS.includes(url.pathname)) {
// The receiver and its scripts come from the cache so a share opens offline.
// ignoreSearch lets "/share-target/?share=…" match the cached "/share-target/".
event.respondWith(
caches.match(url.pathname, { ignoreSearch: true }).then((cached) => cached || fetch(request))
);
}
// Everything else: your normal caching strategy.
});
async function receiveShare(request) {
let form;
try {
form = await request.formData(); // (3)!
} catch (err) {
// Wrong or missing Content-Type, or a body that fails to parse.
console.warn("share-target: unreadable body", err);
return redirectToReceiver({ error: "unreadable" });
}
const share = {
id: crypto.randomUUID(),
createdAt: Date.now(),
title: readText(form, FIELDS.title),
text: readText(form, FIELDS.text),
url: readText(form, FIELDS.url),
files: [],
rejected: 0,
};
let totalBytes = 0;
for (const entry of form.getAll(FIELDS.files)) {
// A non-file value under a file field is possible with forged requests.
if (!(entry instanceof File)) continue;
const acceptable =
share.files.length < MAX_FILES &&
totalBytes + entry.size <= MAX_TOTAL_BYTES &&
ACCEPTED_TYPES.some((re) => re.test(entry.type)); // (4)!
if (!acceptable) {
share.rejected += 1;
continue;
}
totalBytes += entry.size;
share.files.push(entry);
}
if (!share.title && !share.text && !share.url && share.files.length === 0) {
return redirectToReceiver({ error: share.rejected ? "unsupported-files" : "empty" });
}
try {
await ShareDB.put(share); // (5)!
} catch (err) {
// QuotaExceededError is the realistic failure for large files.
console.error("share-target: could not store share", err);
return redirectToReceiver({ error: err && err.name === "QuotaExceededError" ? "quota" : "storage" });
}
return redirectToReceiver({ share: share.id });
}
function readText(form, name) {
const value = form.get(name);
return typeof value === "string" ? value.slice(0, MAX_TEXT_CHARS) : "";
}
function redirectToReceiver(params) {
const target = new URL(SHARE_ACTION_PATH, self.location.origin);
for (const [key, value] of Object.entries(params)) target.searchParams.set(key, value);
// 303 See Other: the browser follows with GET, and a reload will not re-POST.
return Response.redirect(target.href, 303); // (6)!
}
- A classic service worker can share code with pages through
importScripts(). With a module service worker (register("/sw.js", { type: "module" })) useimportinstead. respondWith()must be called synchronously during thefetchevent. The async work happens inside the promise you pass to it.formData()parses bothmultipart/form-dataandapplication/x-www-form-urlencodedbodies and rejects with aTypeErrorfor anything else. The body can be read only once; callrequest.clone()first if you also need to forward the request to your server.- The file type check here is a cheap first filter on the declared MIME type. It is not a security boundary: see Validating files.
- IndexedDB stores
FileandBlobvalues natively through the structured clone algorithm, so the files survive the redirect, a service worker restart and even a browser restart. Response.redirect()accepts only 301, 302, 303, 307 and 308 and throws aRangeErrorfor anything else. Relative URLs resolve against the service worker script's URL.
The unconditional skipWaiting() keeps the sample short, and it's safe here only because old pages can't request assets that a new version removed: the worker caches three unversioned files. In an app with versioned, lazily loaded bundles, a page still running the old version can ask for a chunk the new worker no longer has. Let the new worker wait, or activate it on a user action, as described in Lifecycle and skipWaiting() breaking lazy-loaded chunks.
Because the browser performs a navigation with method POST, several service worker features behave differently from GET navigations. Navigation preload applies only to GET navigations, so event.preloadResponse resolves to undefined for the share request. If you use the Static Routing API, make sure no rule sends POST requests to the action path straight to the network; router conditions can match on requestMethod.
The shared IndexedDB store¶
/* share-db.js: a small IndexedDB store shared by the service worker and pages.
* Classic script (no modules) so the service worker can importScripts() it.
* Records look like { id, createdAt, title, text, url, files: File[], rejected }.
*/
(function (global) {
"use strict";
const DB_NAME = "share-target";
const DB_VERSION = 1;
const STORE = "shares";
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // Shares nobody opened within a day are dropped.
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const store = req.result.createObjectStore(STORE, { keyPath: "id" });
store.createIndex("createdAt", "createdAt");
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
// Another tab holds an older version open; fail loudly instead of hanging.
req.onblocked = () => reject(new DOMException("IndexedDB upgrade blocked", "InvalidStateError"));
});
}
function requestToPromise(req) {
return new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function transactionDone(tx) {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new DOMException("Transaction aborted", "AbortError"));
});
}
async function withStore(mode, fn) {
const db = await openDb();
try {
const tx = db.transaction(STORE, mode);
const result = await fn(tx.objectStore(STORE));
await transactionDone(tx);
return result;
} finally {
db.close();
}
}
/** Persist one share. Files (Blobs) are stored natively by IndexedDB. */
function put(record) {
return withStore("readwrite", (store) => {
store.put(record);
});
}
/** Read and delete a share in one transaction, so it is processed once. */
function take(id) {
return withStore("readwrite", async (store) => {
const record = await requestToPromise(store.get(id));
if (record) store.delete(id);
return record ?? null;
});
}
/** Delete shares older than maxAgeMs (default: one day). */
function prune(maxAgeMs = MAX_AGE_MS) {
return withStore("readwrite", (store) => {
const range = IDBKeyRange.upperBound(Date.now() - maxAgeMs);
const cursorReq = store.index("createdAt").openCursor(range);
cursorReq.onsuccess = () => {
const cursor = cursorReq.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
});
}
global.ShareDB = Object.freeze({ put, take, prune });
})(self);
take() awaits store.get() and then issues store.delete() in the same transaction. That works because promise reactions run as microtasks while the success event is being dispatched, when the transaction is still active; it is the same guarantee that promise wrappers such as the idb library rely on. Avoid awaiting anything that is not an IndexedDB request inside withStore(), or the transaction commits early. More on transaction lifetimes is on IndexedDB.
The receiver page¶
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shared with Scrapbook</title>
<link rel="manifest" href="/manifest.webmanifest">
<script src="/share-db.js" defer></script>
<script src="/share-target/receiver.js" defer></script>
</head>
<body>
<main>
<h1>New share</h1>
<p id="status" role="status">Loading…</p>
<dl id="fields" hidden>
<dt>Title</dt><dd id="share-title"></dd>
<dt>Text</dt><dd id="share-text"></dd>
<dt>Link</dt><dd><a id="share-url" rel="noopener noreferrer"></a></dd>
</dl>
<ul id="files"></ul>
</main>
</body>
</html>
/* receiver.js: renders a share from IndexedDB (POST targets) or the query string (GET targets). */
(async () => {
"use strict";
const params = new URLSearchParams(location.search);
const status = document.getElementById("status");
// (Re-)register on every load: if site data was cleared or a kill switch removed the
// worker, this brings it back so the next share is handled offline again.
navigator.serviceWorker?.register("/sw.js").catch((err) => console.warn("SW registration failed", err));
// Strip share data from the URL at once: keeps it out of history, reloads and analytics.
history.replaceState(history.state, "", location.pathname);
const ERRORS = {
unreadable: "The shared data could not be read.",
empty: "Nothing was shared.",
"unsupported-files": "None of the shared files are supported.",
quota: "Not enough storage space to receive these files.",
storage: "The share could not be saved.",
"too-large": "The shared files are too large.",
retry: "The app was still starting. Please share again.",
};
let share = null;
if (params.has("error")) {
status.textContent = ERRORS[params.get("error")] ?? "The share failed.";
return;
} else if (params.has("share")) {
try {
share = await ShareDB.take(params.get("share"));
} catch (err) {
console.error(err);
}
if (!share) {
status.textContent = "This share was already opened or has expired.";
return;
}
} else if (params.has("title") || params.has("text") || params.has("url")) {
// GET share target: URLSearchParams decodes "+" as a space, decodeURIComponent() does not.
share = { title: params.get("title") ?? "", text: params.get("text") ?? "", url: params.get("url") ?? "", files: [] };
} else {
status.textContent = "Nothing was shared.";
return;
}
const normalized = normalizeShare(share);
document.getElementById("share-title").textContent = normalized.title;
document.getElementById("share-text").textContent = normalized.text;
const link = document.getElementById("share-url");
if (normalized.url) {
link.href = normalized.url;
link.textContent = normalized.url;
}
document.getElementById("fields").hidden = false;
const list = document.getElementById("files");
const objectUrls = [];
for (const file of share.files ?? []) {
const li = document.createElement("li");
li.textContent = `${file.name} (${file.type || "unknown type"}, ${file.size} bytes)`;
if (file.type.startsWith("image/") && file.type !== "image/svg+xml") {
const img = document.createElement("img");
const objectUrl = URL.createObjectURL(file);
objectUrls.push(objectUrl);
img.src = objectUrl;
img.alt = file.name;
img.width = 160;
li.append(img);
}
list.append(li);
}
addEventListener("pagehide", () => objectUrls.forEach((u) => URL.revokeObjectURL(u)));
status.textContent = share.rejected ? `${share.rejected} file(s) were skipped.` : "Share received.";
})();
/** Android never fills the url field; links arrive inside text (or title). */
function normalizeShare({ title = "", text = "", url = "" }) {
let foundUrl = safeHttpUrl(url);
if (!foundUrl) {
for (const source of [text, title]) {
const match = source.match(/https?:\/\/[^\s<>"]+/i);
const candidate = match && safeHttpUrl(match[0].replace(/[).,;!?]+$/, ""));
if (candidate) {
foundUrl = candidate;
if (source === text) text = text.replace(match[0], "").replace(/\s{2,}/g, " ");
break;
}
}
}
return { title: title.trim(), text: text.trim(), url: foundUrl };
}
function safeHttpUrl(value) {
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.href : "";
} catch {
return "";
}
}
In a single-page app the receiver is usually a route rather than a separate document: the service worker serves the app shell for /share-target/, and the router's handler for that route runs the same logic. Whatever the architecture, take() makes processing idempotent: the record is deleted as it is read, so a second tab, a reload or a back navigation shows "already opened" instead of saving the same photo twice.
Why the redirect must be a 303¶
| Status | What the browser does next | Result for a share target |
|---|---|---|
| 200 with HTML | Renders the response as the result of the POST | Works once, but a reload re-submits the form (with a "confirm form resubmission" prompt) and the history entry holds a POST |
| 301, 302 | Historically rewrites POST to GET | Usually works, but the method change is legacy behavior rather than the defined meaning |
| 303 | Always follows with GET, without a body | The defined "POST, then redirect, then GET" pattern. Reloads and back navigation are safe. |
| 307, 308 | Repeats the request with the same method and body | Re-POSTs the files to the new URL, which hits the service worker again |
The specification says it directly: "replying to a POST request with a 303 See Other redirect is highly recommended, as it avoids the POST being submitted a second time if the user requests a page refresh."
Choosing where to put the data¶
| Hand-off | How | Survives | Best for |
|---|---|---|---|
| IndexedDB (shown above) | Store the record, redirect with its ID | Service worker restarts, reloads, browser restarts | The default: robust and transactional |
| Cache Storage | Store each file as a Response under a synthetic URL such as /shared/<id>/0 | Same as IndexedDB | Media the page shows via <img src> or <video src>, served back by the service worker |
postMessage | Keep the share in service worker memory, hand it over when the page asks | Nothing: lost if the browser stops the idle worker first | Prototypes and very small payloads |
Alternative: in-memory hand-off with postMessage
This variant avoids storage entirely. The page announces that it is ready, and the service worker answers with the files, which are structured-cloned as Blob handles rather than copied byte by byte. It works, but browsers stop idle service workers after a short time, and a worker that is stopped between the redirect and the page's message loses the share. Use IndexedDB for anything users would miss.
// Memory only: lost if the browser stops this worker before the page asks.
const pendingShares = new Map();
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (event.request.method !== "POST" || url.pathname !== "/share-target/") return;
event.respondWith((async () => {
const form = await event.request.formData();
const id = crypto.randomUUID();
pendingShares.set(id, { title: form.get("title") ?? "", files: form.getAll("media") });
return Response.redirect(`/share-target/?share=${id}`, 303);
})());
});
self.addEventListener("message", (event) => {
if (event.data?.type !== "share-ready") return;
const share = pendingShares.get(event.data.id) ?? null;
pendingShares.delete(event.data.id);
event.source.postMessage({ type: "share-data", id: event.data.id, share });
});
async function receiveShare(id) {
const registration = await navigator.serviceWorker.ready;
return new Promise((resolve, reject) => {
const onMessage = (event) => {
if (event.data?.type !== "share-data" || event.data.id !== id) return;
navigator.serviceWorker.removeEventListener("message", onMessage);
clearTimeout(timer);
resolve(event.data.share); // null if the worker was restarted and lost it
};
const timer = setTimeout(() => {
navigator.serviceWorker.removeEventListener("message", onMessage);
reject(new Error("service worker did not answer"));
}, 5000);
navigator.serviceWorker.addEventListener("message", onMessage);
// addEventListener() does not enable the client message queue (only onmessage
// or the end of parsing does), so start it explicitly or the reply can sit queued.
navigator.serviceWorker.startMessages();
// controller is null after a hard reload; the active worker still answers.
(navigator.serviceWorker.controller ?? registration.active).postMessage({ type: "share-ready", id });
});
}
const id = new URLSearchParams(location.search).get("share");
const share = id ? await receiveShare(id) : null; // module script: top-level await
The messaging primitives, including event.source and the controller edge cases, are covered on Messaging & the Clients API.
Alternative: stash media in Cache Storage
When the receiver mainly displays shared media, Cache Storage has one advantage over IndexedDB: the page can point <img> and <video> elements straight at a URL, and the service worker serves the bytes. (cache.match() ignores the Range header and returns the whole file; for long videos, build 206 responses as shown in Serving Range requests from a cached response.) Each file becomes a synthetic Response keyed by a same-origin URL that never exists on your server. The metadata (title, text, file names) still needs a small record, which here is a JSON response in the same cache.
const SHARE_CACHE = "incoming-shares";
const SHARED_PREFIX = "/shared/"; // synthetic URLs: /shared/<id>/meta.json, /shared/<id>/0, ...
async function stashShareInCache(form) {
const id = crypto.randomUUID();
const cache = await caches.open(SHARE_CACHE);
const files = form.getAll("media").filter((entry) => entry instanceof File);
const entries = [];
for (const [index, file] of files.entries()) {
const url = `${SHARED_PREFIX}${id}/${index}`;
await cache.put(url, new Response(file, {
headers: {
"Content-Type": file.type || "application/octet-stream",
"Content-Length": String(file.size),
// Never let a shared file render as an active document on your origin.
"Content-Security-Policy": "sandbox",
"X-Content-Type-Options": "nosniff",
},
}));
entries.push({ url, name: file.name, type: file.type, size: file.size });
}
const meta = {
id,
createdAt: Date.now(),
title: typeof form.get("title") === "string" ? form.get("title") : "",
text: typeof form.get("text") === "string" ? form.get("text") : "",
files: entries,
};
await cache.put(`${SHARED_PREFIX}${id}/meta.json`, Response.json(meta));
return id;
}
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.origin !== location.origin) return;
if (event.request.method === "POST" && url.pathname === "/share-target/") {
event.respondWith((async () => {
try {
const id = await stashShareInCache(await event.request.formData());
return Response.redirect(`/share-target/?share=${id}`, 303);
} catch (err) {
// formData() TypeError or QuotaExceededError from cache.put()
return Response.redirect(`/share-target/?error=${err.name === "QuotaExceededError" ? "quota" : "storage"}`, 303);
}
})());
return;
}
if (event.request.method === "GET" && url.pathname.startsWith(SHARED_PREFIX)) {
event.respondWith(
caches.open(SHARE_CACHE)
.then((cache) => cache.match(url.pathname))
.then((cached) => cached ?? new Response("Share expired", { status: 404 }))
);
}
});
The receiver fetches /shared/<id>/meta.json, renders each file with <img src="/shared/<id>/0">, and deletes the entries with cache.delete() once the user has saved or discarded the share. Two caveats: the cache has no transactions, so a crash between put() calls can leave a partial share (write meta.json last, as above, and treat a missing meta.json as "no share"); and nothing expires on its own, so prune old IDs on activate or on the next share. Cache Storage mechanics are on Cache Storage API.
A network fallback on the server¶
The service worker handles the POST only when it controls the action URL. After the user clears site data, during a broken service worker update, or when a kill switch has unregistered it, the POST goes to your server, and a static host answers 405 or 404, losing the share. A small handler turns that into a graceful path: text-only shares are converted into the GET form the receiver already understands, and file shares get a clear retry message (or go to your upload API, if your product has one).
// Network fallback for POST /share-target/ when no service worker intercepts it.
import { createServer } from "node:http";
import { Readable } from "node:stream";
const SHARE_PATH = "/share-target/";
const MAX_BODY_BYTES = 55 * 1024 * 1024; // a little above the 50 MiB of files we accept
function redirect(res, location) {
res.writeHead(303, { Location: location, "Cache-Control": "no-store" }).end();
}
async function handleSharePost(req, res) {
const length = Number(req.headers["content-length"] ?? 0);
if (length > MAX_BODY_BYTES) return redirect(res, `${SHARE_PATH}?error=too-large`);
let form;
try {
// Node's built-in Request (undici) parses multipart/form-data and urlencoded bodies.
// Pass only Content-Type: it carries the multipart boundary, and hop-by-hop
// headers from the incoming request have no meaning for this local parse.
// Note: a chunked body has no Content-Length; put a body-size limit in your
// reverse proxy (for example client_max_body_size in nginx) as well.
const request = new Request(new URL(req.url, "http://localhost"), {
method: "POST",
headers: { "content-type": req.headers["content-type"] ?? "" },
body: Readable.toWeb(req),
duplex: "half",
});
form = await request.formData();
} catch {
return redirect(res, `${SHARE_PATH}?error=unreadable`);
}
const hasFiles = [...form.values()].some((v) => typeof v !== "string");
if (hasFiles) {
// Files cannot reach the client's IndexedDB from here. Either store them server-side
// (if your product has an upload API) or ask the user to share again once the app loads.
return redirect(res, `${SHARE_PATH}?error=retry`);
}
const query = new URLSearchParams();
for (const name of ["title", "text", "url"]) {
const value = form.get(name);
if (typeof value === "string" && value) query.set(name, value.slice(0, 2000));
}
return redirect(res, `${SHARE_PATH}?${query}`);
}
createServer((req, res) => {
const { pathname } = new URL(req.url, "http://localhost");
if (req.method === "POST" && pathname === SHARE_PATH) {
handleSharePost(req, res).catch(() => redirect(res, `${SHARE_PATH}?error=unreadable`));
return;
}
res.writeHead(404).end(); // your static file handling goes here
}).listen(8080);
Because the receiver page registers the service worker again on load, the next share is handled offline as usual. Check the whole path with curl -i -F title="My News" -F "text=Read https://news.example/a" https://your.app/share-target/, which should answer 303 with a Location of /share-target/?title=My+News&text=Read+https%3A%2F%2Fnews.example%2Fa.
What the user sees when a share launches the app¶
- Android. Choosing your app starts the WebAPK and navigates it to the share URL, even if the app is already open. A WebAPK normally runs as a single Android task, so the share usually replaces whatever page the user had open in the app. Persist drafts (on
visibilitychangetohiddenorpagehide) so a share never destroys unsaved work, and give the receiver a clear way back ("Save to board" and "Cancel" both return to the previous screen). - ChromeOS. The share opens your action URL in an app window.
- Everywhere. The receiver is the first thing users see after the share sheet closes. Keep it light: render from the cache, show the shared content immediately, and defer anything that needs the network, such as fetching a link preview, until after first paint.
launch_handler is designed for launches such as file handling and protocol handling on desktop; do not assume it changes how share-target launches behave on Android, and test on real devices.
Testing share targets¶
Most of the logic can be tested on any desktop browser with a service worker, because a share POST is nothing more than a form submission to the action URL.
-
Simulate the OS with a form. Serve a test page from the same origin that posts exactly what the browser would send:
test/share-form.html<form method="post" action="/share-target/" enctype="multipart/form-data"> <input name="title" value="Holiday photos"> <textarea name="text">Look at these https://example.com/album?id=1+2 !</textarea> <input name="media" type="file" multiple> <button>Share</button> </form>Submitting it goes through your service worker's
fetchhandler exactly as a real share does, including the 303 redirect. Addenctype="application/x-www-form-urlencoded"ormethod="get"variants to cover the other configurations, and a text-only submission that puts the link intextto mimic Android. -
Automate it. The same form drives an end-to-end test:
tests/share-target.spec.js (Playwright)import { test, expect } from "@playwright/test"; test("POST share is stored and rendered, also offline", async ({ page, context }) => { await page.goto("/"); await page.evaluate(async () => { await navigator.serviceWorker.register("/sw.js"); await navigator.serviceWorker.ready; }); await page.goto("/test/share-form.html"); // now controlled thanks to clients.claim() await page.setInputFiles("input[type=file]", ["tests/fixtures/photo.png", "tests/fixtures/tool.exe"]); await context.setOffline(true); // the service worker must not need the network // The 303 lands on /share-target/?share=<id>; receiver.js then strips the query. await Promise.all([page.waitForURL(/\/share-target\/(\?|$)/), page.click("button")]); await expect(page.locator("#status")).toHaveText("1 file(s) were skipped."); await expect(page.locator("#share-url")).toHaveAttribute("href", "https://example.com/album?id=1+2"); await expect(page.locator("#files li")).toHaveCount(1); await page.reload(); // must not re-POST or show the share twice await expect(page.locator("#status")).toHaveText("Nothing was shared."); }); -
Test on Android with the real share sheet. Install the app from Chrome so a WebAPK is minted, then share from Photos, Files, a browser and a messenger: each app fills
EXTRA_SUBJECTandEXTRA_TEXTdifferently. Attach DevTools throughchrome://inspecton a desktop Chrome to debug the WebAPK's page and service worker, and usechrome://webapkson the device to confirm the installed version and request an update after manifest changes. -
Test on ChromeOS. Install the app, then share an image from the Files app and a page from Chrome's share menu. This is also the quickest way to see the URL-extraction behavior.
-
Check the manifest in DevTools. The Application > Manifest pane lists every parse warning and error. An ignored
share_targetis otherwise silent. See Browser DevTools.
Security and privacy considerations¶
The action URL is a public, unauthenticated endpoint
Any website can submit a cross-origin form (<form method="post" action="https://your.app/share-target/" enctype="multipart/form-data">) to your action URL in a top-level navigation. If your service worker controls that URL, it processes the forged request exactly like a genuine share, with the user's cookies and IndexedDB. Never perform an irreversible side effect (publishing a post, sending a message, uploading to a shared space) directly from the share handler. Store the share locally and let the user confirm it in your UI.
- Treat every field as untrusted input. Render
titleandtextwithtextContentor formvalue, neverinnerHTML. Validate URLs and allow onlyhttp:andhttps:before using them inhref,fetch()or a link preview request, which otherwise becomes a server-side request forgery vector if your backend fetches previews. - Validate files by content, not by name or MIME type. Both come from the sending app, or from an attacker's form. Check magic bytes for the formats you accept, enforce size limits in the service worker before storing, and decode images through
createImageBitmap()or<img>, which never execute script. Never render a shared SVG or HTML file as a same-origin document; SVG can carry script. The receiver above excludesimage/svg+xmlfrom inline previews for that reason. - Bound the damage. The service worker caps the number and total size of files and the length of text, and prunes records nobody opened. Without limits a forged request can fill your origin's storage quota, see Storage Quotas & Persistence.
- Keep shared data out of URLs and logs. Prefer POST; with GET, strip the query immediately. Do not send shared text or file names to analytics or error-reporting services; they often contain personal data.
- Clean up. Delete records once processed and prune abandoned ones. Shared photos are some of the most sensitive data a user can hand to an app.
- Understand the platform's identity checks. The share sheet shows your manifest
nameand icon, so the specification's concern about spoofing (a target presenting itself as another party) is addressed by the OS showing the installed app, and on Android by the WebAPK being tied to your origin. Users still rely on your name and icon to recognize you; see Icons & Maskable Icons.
More background on the service worker's privileges and how to limit them is on Service Worker Security and Privacy & Storage Partitioning.
Common pitfalls¶
- Testing in a browser tab. Nothing registers until the app is installed. On Android, also confirm that a WebAPK (not a shortcut) was created.
- One bad
acceptstring."image/","images/*"or"jpg"without a dot silently removes the wholeshare_targetin Chromium. - Declaring
fileswith GET or urlencoded POST. The member is discarded. Files need"method": "POST"and"enctype": "multipart/form-data". - An
actionoutsidescope."action": "/share"with"scope": "/app/"invalidates the member. Relativeactionvalues resolve against the manifest URL, not the page. - Reading only the
urlparameter. On Android it is always empty. Extract links fromtextandtitle. - Answering the POST with a page or a 307. A 200 page makes reloads re-submit; a 307 or 308 re-POSTs the files. Use 303.
- No offline receiver. Cache the receiver page and match with
ignoreSearch: true; otherwise a share on the subway fails. - No server fallback. When the service worker is missing, the POST reaches a static host that answers 405, and the share is lost.
- Side effects without confirmation. The action URL can be targeted by any site; confirm before publishing or sending.
- Forgetting the WebAPK update cycle. A new
accepttype does not appear in the share sheet until Chrome has updated the WebAPK. Usechrome://webapkswhile developing and plan releases accordingly. - Expecting extensions to match. ChromeOS ignores extension criteria and Android often cannot see an extension in content URIs. Always list the MIME types.
- Assuming desktop Chrome support. Chrome on Windows, macOS and Linux does not register share targets; ChromeOS is the only Chrome desktop platform that does.
Further reading¶
On this site
- Web Share API: the sending side,
navigator.share()and its fallbacks - Advanced & Integration Members:
share_targetalongsidefile_handlers,protocol_handlersandlaunch_handler - Handling Fetch Events:
respondWith(), navigation requests and redirects - IndexedDB: transactions, Blob storage and versioning
- App Identity & Updates: when manifest changes, including
share_target, reach installed apps - Android: WebAPKs, installation and updates on Android
- File Handling: the desktop way to open files with your PWA
- Device & OS Integration: the full capability matrix and feature detection
External references
- Web Share Target API (W3C editor's draft) and Level 2 with files (WICG draft)
- MDN:
share_targetmanifest member - Chrome for Developers: Receiving shared data with the Web Share Target API
- Microsoft Edge: share content with other apps
- Chrome Platform Status: Web Share Target and Web Share Target Level 2
- WebKit standards position on Web Share Target and Mozilla standards positions
-
Microsoft Edge documentation describes registering an installed PWA as a target in the Windows share dialog. It does not state a minimum version, so verify on the Windows and Edge versions you support. ↩
-
Chromium's
share_target_utils.ccimplements the share-target launch only for ChromeOS; the Windows and macOS branches are markedNOTIMPLEMENTED()with open tracking bugs. ↩