Forwarding Received Shares to Your App State
This guide is part of Receiving Shared Files & Text, which itself is part of Web Share Target API.
Your service worker has parsed the inbound share and written it to storage, but the parse happened in a worker that shares no memory with your running application. Getting that captured share into live app state — whether the app was cold-started by the share or already open in a tab — is a separate problem with its own race conditions. This guide covers the bridge: persist in the worker, hydrate on the redirected page, notify warm clients, and clear the stash exactly once.
Feature Detection Gate
Forwarding relies on IndexedDB plus at least one client-notification channel. Detect them before wiring the bridge so a missing capability degrades to a plain startup read rather than a silent failure.
// forward-gate.js
export function canForwardShares() {
return (
window.isSecureContext === true &&
'indexedDB' in window &&
'serviceWorker' in navigator
);
}
export function canBroadcast() {
return typeof BroadcastChannel === 'function';
}
IndexedDB is the durable hand-off that always works; BroadcastChannel and postMessage are optimisations for updating an already-open tab. The startup read is the floor — it must succeed on its own even when no notification channel is available.
Solution Walkthrough
Step 1 — Persist in the service worker, keyed by id
The worker writes the parsed payload to IndexedDB and passes the record id forward in the redirect query string. This is the durable boundary between the two contexts and it must resolve before the redirect is returned, as established in receiving shared files and text.
// sw-forward.js
import { persistShare } from './share-store.js';
export async function stashAndRedirect(fields) {
const id = await persistShare(fields); // resolves before we redirect
// Best-effort: nudge any already-open client to pick up the new record.
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of clients) {
client.postMessage({ type: 'share:received', id });
}
return Response.redirect(`/shared?id=${encodeURIComponent(id)}`, 303);
}
The postMessage loop is fired only after the write resolves, so any client that receives it can safely read the record. If no client is open, the loop is a no-op and the redirected page handles hydration on load instead.
Step 2 — Hydrate app state on the redirected page
The redirected page boots (cold) or is navigated to (warm). Its startup routine reads the record by id and seeds app state. This single path covers the cold-start case entirely, because a fresh boot always runs it.
// hydrate-shares.js
import { readShare, deleteShare } from './share-store-read.js';
export async function hydrateFromShare(appStore) {
const id = new URLSearchParams(location.search).get('id');
if (!id) return false;
const record = await readShare(id);
if (!record) return false; // already consumed elsewhere
appStore.ingestShare(record); // seed your app's state
await deleteShare(id); // consume exactly once
return true;
}
Reading and deleting by a specific id is what makes the consumption idempotent: if a broadcast handler already ingested this record, readShare returns nothing and the function exits without double-processing. Storing the payload this way builds directly on the IndexedDB share queue persistence patterns.
Step 3 — Update an already-open client without a reload
When the app is already open, the redirect may focus the existing tab rather than boot a new one, and that tab will not re-run its startup hydration. Listen for the worker’s message (or a BroadcastChannel) and hydrate reactively.
// warm-client-listener.js
import { hydrateFromShareId } from './hydrate-shares.js';
export function listenForShares(appStore) {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.addEventListener('message', async (event) => {
if (event.data?.type !== 'share:received') return;
await hydrateFromShareId(appStore, event.data.id); // reads + deletes by id
});
if (typeof BroadcastChannel === 'function') {
const channel = new BroadcastChannel('share-inbox');
channel.addEventListener('message', async (event) => {
if (event.data?.type !== 'share:received') return;
await hydrateFromShareId(appStore, event.data.id);
});
}
}
Both channels funnel into the same id-keyed hydrate function, so whichever fires first wins and the other finds the record gone. This is the mechanism that lets a warm tab reflect a new share instantly, complementing the connectivity-aware share UX that keeps the interface honest about pending work.
Step 4 — Clear the stash after consumption
Every hydrate path ends by deleting the record. Leaving records behind causes two bugs: a refresh of /shared?id=... replays a stale share, and a queued list of “received” items grows without bound. Deleting inside the same logical step that ingests the data closes both.
// clear-consumed.js
export async function ingestOnce(appStore, record, deleteShare) {
if (!record) return false;
appStore.ingestShare(record);
await deleteShare(record.id); // no replay, no duplicate
return true;
}
Because the delete is awaited right after ingestion, the window in which a second consumer could grab the same record is as small as possible.
Failure Modes and Recovery
| Failure | Symptom | Minimal fix |
|---|---|---|
| Page loads before the worker writes | Startup read finds no record | Await persistShare before Response.redirect; pass the id in the URL |
| Warm tab never updates | Share only appears after a manual reload | Broadcast share:received from the worker and hydrate reactively |
| Duplicate delivery | The same share ingested twice (startup + broadcast) | Read and delete by a stable id so the second consumer finds it gone |
| Stash never cleared | Refreshing replays old shares; list grows | Call deleteShare immediately after ingestion, awaited |
| Cold start shows empty state first | UI flashes empty before the share appears | Run hydration before the first render, or show a loading state until it resolves |
| Message ignored | event.data shape mismatch |
Guard on event.data?.type and a versioned message contract |
Browser and Platform Caveat
This bridge runs only where the receiving side of the Web Share Target API is implemented — Chromium browsers on Android and installed PWAs on Chromium desktop. On those platforms, client.postMessage and BroadcastChannel are both available, so the warm-tab path works reliably. On iOS, iPadOS, and Firefox the share POST never reaches a service worker, so none of this code executes; provide an in-app import that seeds the same appStore.ingestShare entry point directly. Note that on Android the redirect may open a fresh window rather than focus an existing one depending on the app’s display mode and whether a client is already visible, which is precisely why the startup read and the broadcast listener must both funnel into one idempotent, id-keyed consumer.
FAQ
Why does the share sometimes not appear when the app was already open?
A warm client does not re-run its startup hydration when a new share arrives, so a record the service worker just wrote to IndexedDB stays invisible until the tab reloads. Broadcast a share:received message from the worker with client.postMessage or a BroadcastChannel, and have open tabs hydrate reactively from the record id. That turns a silent write into an immediate UI update.
How do I avoid processing the same shared item twice?
Give every share a stable id and delete it from the stash immediately after handing it to app state. When both a startup read and a broadcast handler can fire, route both through one function that reads-then-deletes by id: the first consumer ingests and removes the record, and the second finds nothing and exits. This is the same deduplication discipline used for queued outbound shares.
What happens if the app is cold-started by the share?
A cold start is the simplest case. The redirected landing page boots fresh, runs its normal startup hydration, reads the stashed record by id, and seeds app state from it — there is no open client to notify, so the startup read alone suffices. Just ensure that read runs before the UI paints its empty state, or gate the first render behind a short loading state so the share is never missed.