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.

Anatomy of the Share POST The request body is a multipart form with named parts matching the manifest declaration: title, text and url as strings, and a repeated media part carrying the files. Calling request.formData decodes all of them, with getAll returning every file under the shared name. multipart/form-data body name="title" → string name="text" → string name="url" → string name="media" → File × n await request .formData() the platform parses the boundaries for you get('title') getAll('media') strings and Files, already decoded

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.

Why the Handler Never Runs Four causes of a share that appears to do nothing: the manifest declares GET rather than POST, the enctype is missing, the fetch handler does not match the action path, and the worker was not yet controlling the page when the share arrived. symptom: the share opens the app but nothing is received manifest method is GET — files cannot be sent at all, and fields land in the URL instead enctype missing — must be multipart/form-data for any file part to exist fetch handler path does not match the manifest action exactly, so the request falls through worker not yet controlling the page — claim clients on activate so the first share is handled

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
formData Buffers the Whole Body Calling formData materialises every part in memory before the handler sees anything, so an enormous share costs memory before any of your checks run. A Content-Length pre-filter refuses the request before the body is parsed at all. parse first, check later await request.formData() materialises 200 MB before your file-count check ever executes check the header first read Content-Length and refuse an implausible body before calling formData at all the cheapest possible filter, and the only one that runs before the bytes are buffered

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.