Handling GET vs POST Web Share Targets

This guide is part of Registering a Web Share Target, which itself is part of Web Share Target API.

The method field of a share_target looks like a trivial choice, but it determines two completely different delivery mechanisms. A GET target arrives as an ordinary browser navigation with a query string you read in the page; a POST target arrives as a fetch request your service worker must intercept. Picking the wrong one does not always throw — the classic symptom is an app that appears in the share sheet and receives text fine, yet silently loses every shared file. This guide gives the decision rule, the exact manifest for each, and the reading code that matches.

The reason the two methods diverge so sharply is that they map onto two different points in the browser’s lifecycle. A GET share is just a URL the operating system asks the browser to open, so it behaves like any deep link: the page loads, location.search is populated, and you have the payload the moment your JavaScript runs. A POST share, by contrast, is a form submission whose body may contain megabytes of binary data. That body cannot ride in a URL, so the browser dispatches it as a real fetch — and the only place a Progressive Web App can read a fetch body before it hits the network is inside a service worker. Everything that follows is a consequence of that split.

Feature Detection Gate

Both paths only matter once the app is installed and running in a secure context. On the GET landing page, confirm the context and that the navigation actually carries a shared payload before acting on it:

// share-gate.js — run before reading any shared GET payload
export function readSharedParams() {
  if (!window.isSecureContext) {
    return null;
  }
  const params = new URLSearchParams(window.location.search);
  const payload = {
    title: params.get('title') ?? '',
    text: params.get('text') ?? '',
    url: params.get('url') ?? '',
  };
  const hasContent = payload.title || payload.text || payload.url;
  return hasContent ? payload : null;
}

Solution Walkthrough

Step 1 — Decide GET or POST by the payload

The rule is short. If the app only ever needs a shared title, text, and url, use GET. If it must receive one or more files — images, PDFs, videos, audio — use POST with multipart/form-data. Files are the deciding factor because binary content cannot be encoded into a URL query string; the specification only permits params.files on a POST target.

Consideration GET POST
Carries title/text/url Yes Yes
Carries files No Yes (multipart/form-data only)
Delivery mechanism Normal navigation to action fetch request intercepted by SW
Where you read it Page JS, URLSearchParams Service worker, request.formData()
Needs a service worker No (recommended for offline) Yes (mandatory)
Payload size Limited by URL length Suited to large binary payloads

Prefer GET when it suffices: it is simpler, needs no service worker to function, and survives a hard reload of the landing page because all state is in the URL. Reach for POST only when files are in scope.

There is one more reason to default to GET where you can. Because a GET target’s entire payload lives in the address bar, it is trivially shareable, bookmarkable, and debuggable — you can reproduce a share by pasting the URL, and you can inspect exactly what arrived in DevTools without instrumenting a service worker. A POST target trades all of that away for the ability to carry binary data: its payload is invisible in the URL, it cannot be reconstructed by re-entering an address, and it only works while a service worker is active and in control of the page. Reserve that complexity for the cases that actually need it. If your app receives only links and text today but might add file sharing later, it is still cleaner to ship GET now and migrate to POST when files arrive than to run an empty multipart/form-data target speculatively.

Step 2 — Declare and read a GET target

A GET target maps each param to a query-string name. When a user shares, the OS navigates the installed app to action with those names filled in.

{
  "share_target": {
    "action": "/share",
    "method": "GET",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  }
}

The browser opens /share?title=...&text=...&url=... as a normal navigation. You read it in the page — no service worker involvement required:

// get-landing.js — render the shared content on the /share page
import { readSharedParams } from './share-gate.js';

export function renderSharedGet() {
  const shared = readSharedParams();
  if (!shared) {
    return; // direct visit, not a share
  }
  // Some source apps put the URL inside text; recover it if url is empty
  const url = shared.url || extractFirstUrl(shared.text);
  populateComposer({ title: shared.title, text: shared.text, url });
}

function extractFirstUrl(text) {
  const match = String(text).match(/https?:\/\/\S+/);
  return match ? match[0] : '';
}

Step 3 — Declare and read a POST target

A POST target adds enctype and a files array. The enctype must be multipart/form-data to carry files; application/x-www-form-urlencoded (the default) only works for text.

{
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [
        { "name": "media", "accept": ["image/*", "application/pdf"] }
      ]
    }
  }
}

The POST is delivered as a fetch your service worker must catch. Read the body with request.formData(), stash the files, and redirect to a GET route the page can render — because the POST response itself cannot leave state in the address bar:

// sw.js — intercept the POST share and hand off to a normal page
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (event.request.method === 'POST' && url.pathname === '/share') {
    event.respondWith(handleSharedPost(event.request));
  }
});

async function handleSharedPost(request) {
  const formData = await request.formData();
  const payload = {
    title: formData.get('title') ?? '',
    text: formData.get('text') ?? '',
    url: formData.get('url') ?? '',
    files: formData.getAll('media'), // matches params.files[].name
  };
  await saveIncomingShare(payload);
  return Response.redirect('/share?received=1', 303);
}

The formData.getAll('media') key must match the name you declared in params.files, not the specification name files. Getting the multipart parsing right across source apps is covered in parsing multipart form-data in a service worker.

Two details in that handler are easy to miss. First, the redirect uses status 303 See Other, not 302: 303 forces the browser to follow up with a GET, which is exactly what you want after processing a POST so the resulting page is a clean, reloadable navigation rather than a resubmitted form. Second, the files you pulled out of formData do not survive the redirect on their own — the new GET page is a fresh document with no access to the service worker’s local variables. You must persist the payload somewhere both contexts can reach, typically an IndexedDB record or the Cache Storage API, and hand the landing page an identifier (here, a simple received=1, or a real key when you expect concurrent shares) so it can read the stashed files back out.

Failure Modes and Recovery

Error / symptom Cause Minimal fix
Files silently missing, text arrives GET target declared with files in params Change method to POST and set enctype to multipart/form-data
action URL returns 404 on share POST target with no service worker fetch handler Register a fetch handler matching the path that calls event.respondWith
Files dropped on a POST target enctype missing or application/x-www-form-urlencoded Set enctype to multipart/form-data
formData.getAll(...) returns [] Key does not match params.files[].name Use the exact name from the manifest, not files
Landing page empty after a POST Rendered from the POST response instead of redirecting Response.redirect('/share?received=1', 303) then read in the page
GET payload truncated URL length limit exceeded by long shared text Prefer POST for large text, or store and reference by id

Browser and Platform Caveat

Both GET and POST share targets are supported only on Chromium-based Android browsers — Chrome 76+ for GET, Chrome 89+ for POST and files — plus Edge and Samsung Internet, with partial support on Chrome OS. iOS Safari and Firefox implement neither, so neither method will register your app as a share destination there. Because a POST target’s delivery depends on an active service worker, also confirm the worker is activated (not merely installed) before relying on interception; a freshly installed worker that has not yet taken control will let the POST fall through to the network and 404. See registering a Web Share Target for the full installability prerequisites.

FAQ

Can a GET share target receive files?

No. Files can only be transferred through a POST target with enctype set to multipart/form-data. If you declare files in the params of a GET target, the files are silently dropped and only title, text, and url arrive as query parameters. The share sheet may still list your app, which is exactly what makes the bug easy to miss.

Why does my POST share target return a 404?

A POST target is delivered as a fetch request that the browser expects a service worker to intercept. If no active service worker has a fetch handler matching the action path, the browser performs a real network POST and your server returns 404 because no route handles that method. Register a handler that matches the path and calls event.respondWith(...).

Do I need enctype for a text-only POST target?

If you omit enctype it defaults to application/x-www-form-urlencoded, which is fine for a text-only POST. But the moment params includes files you must set enctype to multipart/form-data, because the urlencoded form cannot carry binary data. When a POST target might ever carry files, use multipart/form-data.