Receiving Shared Files and Text in a Web Share Target
This guide is part of Web Share Target API.
Registering a share target is only half the work. The moment the operating system hands a share to your app, a POST request lands on your action URL with the title, text, url, and any files packed into a multipart/form-data body — and if you do nothing, the browser tries to render that POST as a page and the payload evaporates. This guide covers what happens after the share arrives: intercepting the request in the service worker, reading event.request.formData(), persisting the files and text to durable storage, responding with a redirect to a normal page, and hydrating your UI from the stash.
The sequence below traces a single inbound file share from the OS share sheet all the way to a rendered landing page.
Problem Framing
The Web Share Target API turns your installed PWA into a destination in the OS share sheet. When the user picks it, the browser issues a real HTTP request to the action URL declared in your manifest’s share_target block. For a POST target — the only kind that can receive files — that request is a navigation with a multipart/form-data body.
Three facts make this awkward, and every one of them breaks a naive implementation:
- The request is a navigation, not a fetch you initiated. The browser wants an HTML document back. Return JSON and it renders as text; return nothing and the tab shows an error.
- The body can be read exactly once. Calling
event.request.formData()consumes the stream. You cannot read it, redirect, and read it again on the next page — the bytes are gone after the response is sent. - The receiving page and the parsing code are different execution contexts. The service worker parses the body; your application UI runs in the page. They do not share memory, so the payload must cross a durable boundary.
The established pattern threads all three: intercept the POST in the service worker fetch handler, read the FormData once, write the files and text into IndexedDB or the Cache API, then respond with a 302 redirect to a plain GET page. That page loads normally, reads the stash, renders the content, and clears it. GET-based text shares are simpler — the data arrives as query-string parameters and needs no service worker at all — but the file path is where the real work lives. For durable storage choices, this pairs closely with IndexedDB share queue persistence and the broader service worker caching strategies used across offline-first apps.
Prerequisites Checklist
Before an inbound share can be received, confirm:
Browser Support Snapshot
| Browser / Platform | Share Target (receive) | POST + files | GET text |
|---|---|---|---|
| Chrome for Android 76+ | ✅ | ✅ | ✅ |
| Edge for Android 79+ | ✅ | ✅ | ✅ |
| Samsung Internet 12+ | ✅ | ✅ | ✅ |
| Chrome Desktop (installed PWA) | ✅ (Windows/ChromeOS) | ✅ | ✅ |
| Firefox (all platforms) | ❌ | ❌ | ❌ |
| Safari iOS / iPadOS | ❌ | ❌ | ❌ |
| Safari macOS | ❌ | ❌ | ❌ |
The receiving side of the Web Share Target API is a Chromium-only capability today. Your app must be installed (added to the home screen or installed as a desktop PWA) before it appears as a target — an in-browser tab is never offered in the share sheet. Because iOS has no support at all, always ship an alternate import route, such as a file picker or paste box, for those users.
Step-by-Step Implementation
Step 1 — Detect the share POST inside the fetch handler
The service worker sees every navigation. Match the action path and the POST method precisely, and only then call event.respondWith(). Everything else must fall through to your normal fetch strategy untouched.
// sw.js
const SHARE_ACTION = '/share';
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
const isShareTarget =
event.request.method === 'POST' && url.pathname === SHARE_ACTION;
if (isShareTarget) {
event.respondWith(handleIncomingShare(event.request));
return;
}
// ...your normal caching strategy for GET requests...
});
Guarding on both method and path matters: the same pathname may serve a GET landing page after the redirect, and you must not intercept that with the POST handler. Detecting the share correctly is the single most common failure point, examined in depth in parsing multipart/form-data in a service worker.
Step 2 — Parse the FormData
Read the body exactly once. Text fields come out with get(); files come out with getAll() because the user may share several at once. Only read fields whose names match your manifest.
// share-parse.js
export async function parseShare(request) {
const formData = await request.formData(); // consumes the body — call once
const title = formData.get('title') ?? '';
const text = formData.get('text') ?? '';
const url = formData.get('url') ?? '';
// 'files' must match manifest share_target.params.files[].name
const files = formData.getAll('files').filter((entry) => entry instanceof File);
return { title: String(title), text: String(text), url: String(url), files };
}
formData.getAll('files') may return strings if the browser fell back to text fields, so filtering by instanceof File keeps only real File objects. Each File carries its name and type, which you need when you re-render or re-share later.
Step 3 — Persist files and text to IndexedDB
The redirect discards the request, so the payload must live somewhere durable before you respond. IndexedDB is the right home for a structured, hydratable record. Store file bytes as an ArrayBuffer so they survive independently of the original File reference.
// share-store.js
const DB_NAME = 'share-inbox';
const STORE = 'incoming';
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function persistShare({ title, text, url, files }) {
const fileRecords = await Promise.all(
files.map(async (file) => ({
name: file.name,
type: file.type,
bytes: await file.arrayBuffer(),
})),
);
const record = { id: `share-${Date.now()}`, title, text, url, files: fileRecords, receivedAt: Date.now() };
const db = await openDb();
await new Promise((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
return record.id;
}
Storing raw bytes rather than the live File avoids subtle issues where a File backed by a temporary OS handle becomes unreadable later. The mechanics of writing binary payloads are detailed in storing file payloads as ArrayBuffer in IndexedDB.
Step 4 — Respond with a redirect to a normal page
Once the write resolves, return a 302 redirect. The browser follows it with a GET request to a page you fully control, and the awkward POST navigation is over.
// sw.js (continued)
import { parseShare } from './share-parse.js';
import { persistShare } from './share-store.js';
async function handleIncomingShare(request) {
try {
const payload = await parseShare(request);
const id = await persistShare(payload); // MUST resolve before redirect
return Response.redirect(`/shared?id=${encodeURIComponent(id)}`, 303);
} catch (err) {
return Response.redirect('/shared?error=parse', 303);
}
}
Use status 303 See Other (or 302) so the browser switches the followed request to GET. Because the persistShare promise is awaited before Response.redirect is constructed, the stash is guaranteed to exist by the time /shared loads. Passing the record id in the query string lets the page fetch the exact share instead of guessing.
Step 5 — Hydrate the UI on the landing page
The landing page is an ordinary GET page. On load, read the record by id, render the text and files, then clear the stash so a refresh does not replay a stale share.
// shared-page.js
import { readShare, deleteShare } from './share-store-read.js';
export async function hydrateSharedPage() {
const id = new URLSearchParams(location.search).get('id');
if (!id) return;
const record = await readShare(id);
if (!record) return;
document.querySelector('#title').textContent = record.title;
document.querySelector('#text').textContent = record.text;
for (const file of record.files) {
const blob = new Blob([file.bytes], { type: file.type });
const objectUrl = URL.createObjectURL(blob);
renderPreview(file.name, file.type, objectUrl);
// revoke after the element has loaded to avoid leaks
}
await deleteShare(id); // consume once
}
Rebuild a Blob from the stored ArrayBuffer and mint an object URL for previews, remembering to URL.revokeObjectURL() once the element has rendered. Deleting the record after consumption is what makes the flow idempotent. Bridging this stash into a live application store — including cold-start handling — is the subject of forwarding received shares to your app state.
Handling GET text shares
Not every share carries files. A method: "GET" share target receives title, text, and url as ordinary query parameters, so no service worker interception is needed — the landing page reads them synchronously.
// get-share.js
export function readGetShare() {
if (!window.isSecureContext) return null;
const params = new URLSearchParams(location.search);
const title = params.get('title');
const text = params.get('text');
const url = params.get('url');
if (!title && !text && !url) return null;
return { title: title ?? '', text: text ?? '', url: url ?? '' };
}
GET is the simpler, more portable path and should be preferred whenever you only need text. Choosing between the two methods is covered in handling GET vs POST share targets.
Payload Validation and Error Boundary
Treat an inbound share as untrusted input. The user can share a file whose MIME type you never declared, a zero-byte file, or a payload far larger than you want to persist. Validate before writing.
// share-validate.js
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per file
const ACCEPTED = ['image/', 'application/pdf', 'text/'];
export function validateFiles(files) {
return files.filter((file) => {
if (!(file instanceof File)) return false;
if (file.size === 0 || file.size > MAX_FILE_BYTES) return false;
return ACCEPTED.some((prefix) => file.type.startsWith(prefix));
});
}
Because the parse happens inside the service worker, an unhandled rejection there leaves the user on a blank POST response with no way back. Wrap parseShare and persistShare in a try/catch (as in Step 4) and always redirect — even on failure — so the user lands on a page that can show an error state rather than a browser error screen. If formData() throws because the body was already consumed, that is a code bug, not a runtime condition: never read the body twice.
| Failure | Cause | Correct response |
|---|---|---|
Empty files array |
FormData key does not match manifest params.files[].name |
Align the manifest name with the getAll() key |
TypeError: body already used |
request.formData() called after another body read |
Read the body exactly once, cache the result |
| Blank POST response shown | event.respondWith() not called, or handler rejected |
Guard method + path, catch and redirect |
| Landing page has no data | Redirect fired before the storage write resolved | Await the persist promise before Response.redirect |
| File unreadable on landing page | Stored the live File instead of its bytes |
Persist await file.arrayBuffer() |
Platform Gotchas
iOS and iPadOS: unsupported. Safari does not implement share targets, so your app never appears as a destination. There is no polyfill — the OS share sheet is native. Provide an in-app import (file input, drag-and-drop, or paste) as the iOS path and feature-detect with 'share_target' in manifest-style capability checks at the UX layer.
Service worker must be controlling. On the very first visit the worker installs but may not yet control the page. If the user installs and immediately shares, the POST can arrive before control is established. Call self.skipWaiting() in install and self.clients.claim() in activate so the worker intercepts the first share reliably.
Installed-app requirement. The share target only registers when the PWA is installed. During development, install the app (Chrome menu → Install) before testing; an ordinary tab will never show in the share sheet even with a valid manifest.
Redirect method downgrade. Some Android WebViews mishandle a 302 on a POST navigation and re-issue the POST. Prefer 303 See Other, which is explicitly defined to switch the followed request to GET, removing any ambiguity.
Cache API vs IndexedDB for large files. For multi-hundred-megabyte files, the Cache API can be cheaper because it streams Response bodies without materialising an ArrayBuffer in memory. For structured queues and metadata, IndexedDB wins. Many apps use both — Cache for bytes, IndexedDB for the index — as described in the service worker caching strategies guide.
Testing and Verification
DevTools checklist (desktop Chrome, installed PWA):
- Open the Application panel → Manifest and confirm the
share_targetblock parsed without warnings. - Under Application → Service Workers, verify the worker is activated and running, and that “Update on reload” is enabled while iterating.
- Set a breakpoint inside
handleIncomingShareand trigger a share; step through to confirmformData()returns the expected fields and files. - After the redirect, inspect Application → IndexedDB →
share-inbox→incomingand confirm a record was written, then removed after the landing page consumed it.
Physical device checklist (Android):
Verification signal: a correct implementation leaves no lingering records in the incoming store after each share is viewed, and the landing page never shows a browser error screen — even when you deliberately share an unsupported file type.
FAQ
Why can’t I render the shared files directly from the POST response?
A share target POST is a navigation request, so the browser expects to render an HTML document, not a JSON blob. Because the request body can be read only once and the parsing context (service worker) is separate from the UI context (page), the durable pattern is to persist the payload and respond with a 303 redirect to a GET page that reads the stash and renders it. Returning the files inline would consume the body and still leave you with a page that cannot re-read it.
Does the Web Share Target API work on iOS Safari?
No. As of mid-2026, iOS and iPadOS Safari do not implement the receiving side of the Web Share Target API, so your PWA never appears as a target in the iOS share sheet. Receiving shares works on Chromium-based Android browsers and installed desktop PWAs on Windows and ChromeOS. Ship an in-app import path (file picker or paste) so iOS users are not stranded.
Do I need Cache API or IndexedDB to hold the payload?
Either works, and the right choice depends on the payload. Use the Cache API when you want to stash whole Request/Response pairs and stream large files back without holding them in memory. Use IndexedDB when you need structured records, a queue of multiple shares, or file bytes stored as an ArrayBuffer alongside metadata — which is the better default for anything you will hydrate into app state.
Why does my landing page load before the shared data is available?
The redirect is constructed only after your service worker finishes writing the stash, so a correctly awaited write is present by the time the page loads. Races appear when the storage promise is not awaited inside event.respondWith(), or when the worker is not yet controlling the page on first install. Await every write before returning Response.redirect, and call clients.claim() in the activate handler.