Limiting Shared Payload Size in a Service Worker

This guide is part of Securing Incoming Shared Payloads, which itself is part of Web Share Target API.

A POST share target accepts whatever the sender puts in the request body, and the endpoint is reachable by anything that can submit a form. Without a cap, a single crafted request can write tens of megabytes into your origin’s storage — filling the budget, evicting the user’s genuinely queued shares, and doing it without any interaction at all.

The service worker is the right place to stop it, because it sees the request before any document exists.

Feature Detection Gate

The handler runs inside the worker, so the gate confirms the request shape rather than an API.

// sw-guards.js
export const LIMITS = {
  maxFiles: 10,
  maxTotalBytes: 25_000_000,
  maxSingleFileBytes: 20_000_000,
  maxTextChars: 20_000,
  maxTitleChars: 300
};

export function isShareTargetPost(request, url) {
  return request.method === 'POST' &&
         url.origin === self.location.origin &&
         url.pathname === '/share-target';
}

Pinning the origin as well as the path is a small but real hardening step: it prevents a same-path request on a different origin, in an unusual worker scope, from reaching the handler at all.

Step 1 — Check Counts Before You Touch Bytes

Order the checks by cost. A file count is free; a size sum is nearly free; reading contents is not.

// sw-share-target.js
import { LIMITS, isShareTargetPost } from './sw-guards.js';

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (isShareTargetPost(event.request, url)) {
    event.respondWith(handleShareTarget(event.request));
  }
});

async function handleShareTarget(request) {
  let form;
  try {
    form = await request.formData();
  } catch {
    return errorRedirect('malformed-payload');
  }

  const files = form.getAll('media').filter((entry) => entry instanceof File);

  if (files.length > LIMITS.maxFiles) return errorRedirect('too-many-files');

  const oversized = files.find((file) => file.size > LIMITS.maxSingleFileBytes);
  if (oversized) return errorRedirect('file-too-large');

  const totalBytes = files.reduce((sum, file) => sum + file.size, 0);
  if (totalBytes > LIMITS.maxTotalBytes) return errorRedirect('payload-too-large');

  const staged = {
    title: String(form.get('title') ?? '').slice(0, LIMITS.maxTitleChars),
    text: String(form.get('text') ?? '').slice(0, LIMITS.maxTextChars),
    url: String(form.get('url') ?? '').slice(0, 2048),
    files,
    totalBytes
  };

  await stageShare(staged);
  return Response.redirect('/share-target/review', 303);
}

function errorRedirect(code) {
  return Response.redirect(`/share-target/error?code=${encodeURIComponent(code)}`, 303);
}

Note that text fields are truncated while file payloads are rejected. A very long note is a display problem the user can live with; an enormous file set is a resource problem that must not be partially accepted.

Checks Ordered by Cost Five checks run in increasing cost order: file count and declared sizes are metadata and cost nothing, a quota estimate is a single asynchronous call, header reads pull sixteen bytes per file, and only a payload that survives all of them is staged and written. 1 · file count ≤ 10 — free, metadata only 0 bytes read 2 · per-file and total size — file.size is metadata 0 bytes read 3 · remaining quota via StorageManager.estimate() 1 async call 4 · magic-byte verification per file 16 bytes each 5 · stage and write — only after every check passes full payload

Step 2 — Make the Cap Adaptive

A fixed limit is a guess. The real question is whether this payload fits in the room the origin actually has left, which StorageManager.estimate() answers.

// quota-aware-cap.js
import { LIMITS } from './sw-guards.js';

const HEADROOM_RATIO = 0.5;      // never let one share take more than half of what remains

export async function fitsInQuota(totalBytes) {
  if (!navigator.storage?.estimate) {
    return totalBytes <= LIMITS.maxTotalBytes;            // no estimate: use the fixed cap
  }

  const { quota = 0, usage = 0 } = await navigator.storage.estimate();
  const remaining = Math.max(0, quota - usage);
  const allowance = Math.min(LIMITS.maxTotalBytes, remaining * HEADROOM_RATIO);

  return {
    ok: totalBytes <= allowance,
    totalBytes,
    allowance: Math.floor(allowance),
    remaining
  };
}

The headroom ratio is what keeps a legitimate share from filling the last of the budget and triggering an eviction of the user’s existing queue. It also makes the limit degrade sensibly: on a device that is nearly full, the app accepts less rather than accepting everything and failing at write time. The wider mechanics are covered in storage quota and eviction for share queues.

Step 3 — Fail Before Writing, and Say Why

A rejected share must leave no trace, and the user must learn what happened.

// staged-write.js
import { fitsInQuota } from './quota-aware-cap.js';

export async function stageShare(payload) {
  const verdict = await fitsInQuota(payload.totalBytes);
  if (verdict.ok === false) {
    // Nothing has been written yet — there is nothing to roll back.
    throw Object.assign(new Error('payload-too-large'), {
      code: 'payload-too-large',
      allowance: verdict.allowance
    });
  }

  const db = await openShareDb();
  const tx = db.transaction('incoming', 'readwrite');
  // A single transaction: either the whole share lands or none of it does.
  await tx.objectStore('incoming').put({
    id: crypto.randomUUID(),
    receivedAt: Date.now(),
    ...payload
  });
  await tx.done;
}

Doing the whole write in one transaction is what makes “no partial records” true rather than aspirational. A share that fails mid-write in several transactions leaves an entry with files but no metadata, and every later reader has to defend against it.

The error view should be specific:

// error-view.js
const MESSAGES = {
  'too-many-files': 'That share had too many files. Try sending up to 10 at a time.',
  'file-too-large': 'One of those files is too large to receive here.',
  'payload-too-large': 'That share is larger than the space available on this device.',
  'malformed-payload': 'That share could not be read.'
};

export function renderShareError(container, code) {
  const p = document.createElement('p');
  p.textContent = MESSAGES[code] ?? 'That share could not be received.';
  container.append(p);
}
What a Cap Prevents The same 200 megabyte crafted POST is handled two ways. Without a cap it is written to IndexedDB, exhausting the origin budget and evicting the user's queued shares. With a cap it is rejected before any write, so existing data is untouched and the user sees an explanation. crafted POST · 40 files · 200 MB total · no user interaction No cap written straight to IndexedDB origin budget exhausted queued shares evicted — data loss user never saw a prompt Capped in the worker rejected on the count check nothing written, nothing evicted 303 → error view with a reason existing queue untouched Truncate Text, Reject Files An over-long note is a display problem, so truncating it keeps the share usable. An over-large file set is a resource problem that would evict the user's existing data, so it must fail the whole request rather than being partially accepted. text → truncate a 500 KB note becomes 20 000 chars the share still succeeds cost: some pasted text is lost files → reject 200 MB would exhaust the origin the whole request fails, nothing stored cost: the user must send fewer files

Failure Modes and Recovery

Symptom Cause Minimal fix
Storage fills after one share No cap in the worker Reject on count and total bytes first
User’s queue disappears Eviction triggered by an oversized write Reserve headroom via estimate()
Partial record left behind Multi-transaction write Write the whole share in one transaction
Silent failure on a big share Rejection with no redirect 303 to an error view with a code
Cap check runs too late Enforced in the page, not the worker Move it into the fetch handler
Memory spike before rejection Contents read before the size check Use file.size; never read to measure
Legitimate share refused on a full device Fixed cap with no context Make the allowance a fraction of what remains

Browser and Platform Caveat

Share targets are Chromium-only, so this handler runs on Android, ChromeOS and installed desktop PWAs — but the endpoint stays reachable by URL from any browser, so the limits must not be conditional on share-target support. StorageManager.estimate() is widely available and returns an approximation rather than a precise figure: browsers deliberately fuzz it to limit fingerprinting, so treat it as a bound rather than an exact number and keep the fixed cap as a floor. Note also that request.formData() in a service worker buffers the body to produce File objects, so a truly enormous request costs memory before your first check runs; where that matters, a Content-Length check on the incoming request is the cheapest possible pre-filter, since it can refuse the request before the body is parsed at all.

FAQ

Why enforce the limit in the service worker rather than the page?

The service worker intercepts the POST before any document exists, so it is the first place that can refuse the payload. A page-level check runs after the request has been handed over and, in a naive implementation, after the data has already been staged.

Does reading file.size load the file?

No. size is metadata on the File object and is available immediately, so summing sizes across a payload costs nothing. Only reading the contents through arrayBuffer or text pulls actual bytes into memory.

What should happen when the payload is too large?

Redirect to a view that explains what was rejected and why, and write nothing. A silent failure looks like a broken app, and a partial write leaves an entry that later code has to defend against.