Parsing multipart/form-data in a Service Worker
This guide is part of Receiving Shared Files & Text, which itself is part of Web Share Target API.
When an installed PWA is chosen from the OS share sheet, the browser POSTs a multipart/form-data body to your declared action URL. That request is a navigation your service worker must intercept, decode with request.formData(), split into File objects and text fields, and answer with a redirect — all while reading the request body exactly once. This guide is the focused mechanics of that single handler.
Feature Detection Gate
The parse only makes sense in a secure, service-worker-controlled context. Before wiring any of it, gate on the capabilities the flow depends on. This runs in the page that registers the worker, not in the worker itself.
// register-share-sw.js
export function canReceiveShares() {
return (
window.isSecureContext === true &&
'serviceWorker' in navigator &&
typeof FormData !== 'undefined' &&
typeof Request.prototype.formData === 'function'
);
}
export async function registerShareWorker() {
if (!canReceiveShares()) return null;
return navigator.serviceWorker.register('/sw.js');
}
Request.prototype.formData is present in every browser that implements share targets, so this check doubles as a proxy for the whole feature. If it fails, fall back to an in-app import path rather than registering a handler that can never fire.
Solution Walkthrough
Step 1 — Recognise the share POST
Inside the worker, fetch fires for every navigation and subresource. You must isolate the share POST with a precise guard: the method is POST and the pathname equals your action. Only then take over the response; every other request must fall through untouched so you do not break normal navigation.
// sw.js
const SHARE_ACTION = '/share';
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === SHARE_ACTION) {
event.respondWith(handleShare(request));
return; // do not fall through
}
// other requests: let them proceed / use your caching strategy
});
Matching on method as well as path is essential. After the redirect, the browser requests the landing page with GET; if your guard checked only the pathname you would wrongly intercept that GET with the POST parser.
Step 2 — Read the body once with formData()
request.formData() returns a promise for a FormData object and, in doing so, drains the request’s body stream. This is the rule the whole handler is organised around: read once, reuse the result. Do not call request.text(), do not call formData() a second time, and do not clone the request expecting a second readable body for the same purpose.
// sw.js (continued)
async function handleShare(request) {
// Single read of the body — the returned FormData holds everything.
const formData = await request.formData();
// From here on, query `formData`, never `request`, for the payload.
return buildRedirect(formData);
}
Every field you need — title, text, url, and files — is now inside the one FormData instance. Treat the original request body as spent.
Step 3 — Separate File objects from text fields
FormData mixes scalar text fields and file entries in one collection. Use get(name) for the single-valued text fields the share carries, and getAll(name) for files, because a user can share several at once. Guard file entries with instanceof File, since a browser may deliver a text value under the same key.
// share-fields.js
export function extractShareFields(formData) {
// Text fields — names must match the manifest share_target.params
const title = String(formData.get('title') ?? '');
const text = String(formData.get('text') ?? '');
const url = String(formData.get('url') ?? '');
// File field — 'files' must match share_target.params.files[].name
const files = formData
.getAll('files')
.filter((entry) => entry instanceof File);
return { title, text, url, files };
}
get() returns only the first value for a key and getAll() returns every value, so using getAll for files is what lets you receive multi-file shares. Each surviving File exposes name, type, and size, plus arrayBuffer() for reading its bytes — which is how you persist it, as shown in storing file payloads as ArrayBuffer in IndexedDB.
Step 4 — Respond with a redirect
The handler must resolve to a Response. Persist the extracted fields to durable storage first, then return Response.redirect so the browser navigates to a normal GET page that can render the share. Awaiting the persist before building the redirect guarantees the data exists when the next page loads.
// sw.js (continued)
import { extractShareFields } from './share-fields.js';
import { persistShare } from './share-store.js';
async function buildRedirect(formData) {
const fields = extractShareFields(formData);
try {
const id = await persistShare(fields); // resolve before redirecting
return Response.redirect(`/shared?id=${encodeURIComponent(id)}`, 303);
} catch {
return Response.redirect('/shared?error=parse', 303);
}
}
Use 303 See Other so the followed request is a clean GET regardless of WebView quirks. Because buildRedirect is the promise passed to event.respondWith(), returning here is what actually answers the navigation. Reading the persisted data back into your running UI is covered in forwarding received shares to your app state.
Failure Modes and Recovery
| Error / symptom | Cause | Minimal fix |
|---|---|---|
Failed to execute 'formData': body stream already read |
The request body was consumed twice (a prior text()/json()/formData() call) |
Call request.formData() once; query the returned object thereafter |
getAll('files') returns [] |
The key does not match the manifest’s share_target.params.files[].name |
Make the getAll key identical to the manifest field name |
get('title') returns null |
Text field name mismatch with share_target.params.title |
Align the get key with the manifest param name |
| Blank page or browser error after share | event.respondWith() never called, so the POST hit the network |
Guard on method + path, then call event.respondWith |
| Landing page loads with no data | Redirect returned before the storage write resolved | await the persist promise before Response.redirect |
| POST re-fired after redirect | A 302 was mishandled by an Android WebView |
Return 303 See Other to force a GET on the followed request |
Browser and Platform Caveat
This handler runs only where the receiving side of the Web Share Target API exists: Chromium-based browsers on Android (Chrome, Edge, Samsung Internet) and installed PWAs on Chromium desktop. Firefox and all versions of Safari — including iOS and iPadOS — do not deliver share POSTs to a service worker at all, so this code never executes there. The app must also be installed; a plain browser tab is never offered as a share target, which means the fetch handler will only ever see a real share POST once the PWA is on the home screen. Handling the GET-based text alternative and choosing between methods is covered in handling GET vs POST share targets.
FAQ
Why do I get “Failed to execute formData: body stream already read”?
A Request body is a one-shot stream. If you call request.text(), request.json(), or request.formData() more than once — or clone the request incorrectly and read both copies — the second read throws because the stream is already drained. Call request.formData() exactly once inside the handler and read every field from the returned FormData object.
Why is formData.getAll(‘files’) returning an empty array?
The key you pass to getAll() must exactly match the field name declared in the manifest’s share_target.params.files entry. If the manifest names the field media but your code reads files, getAll('files') returns nothing and no error is thrown. Confirm the names are identical, character for character, and that the entries are real File objects by filtering with instanceof File.
Do I need event.respondWith for a share POST?
Yes. Without event.respondWith() the browser sends the POST to the network, where your action route usually serves HTML for GET requests only — so the shared data is lost and the user sees an error page. Call event.respondWith() with a promise that reads the body, persists the payload, and resolves to a redirect Response, as shown throughout the receiving shared files and text guide.