Run Code in the Background
Some work should happen even when nobody has your app open, such as recording each file a user adds to a folder, or posting to a chat when a report is saved. On other platforms you'd use a webhook, a background job, or a cloud function that runs on upload.
In Puter, you give each user's persistent subscription a function, and it
runs it when their files change. A persistent subscription, created with
puter.events.onPersistent(), is saved to the user's
account and keeps running after your app closes. Puter runs the function in
an events worker, a serverless function, so you don't run
a server. To update a page while it's
open, use the subscriptions in
Watch for Changes instead.
The examples build one app. Users add files to the app's Inbox folder, and a
handler saves a record of each new file in the app's key-value store,
so the app can list them later.
Publish the Handler
A handler is the function a subscription runs. There are two ways to give one
to a subscription. The simplest is to pass it inline,
which any signed-in user's session can do from your app. The other is to publish
it once under a name with puter.events.handlers.publish(),
and have every user's subscription bind to that name. Publishing is a developer
step, like a deploy, and only the account that owns the app can do it, so run it
from a deploy script or your browser console. Use it when you want one copy of
the code that you can update for every user at once. This section and the ones
after it use a published handler:
const app = await puter.apps.get('drop-box'); // your app's name
async function recordUpload ({ event, user }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
await user.kv.set(`uploads:${event.uid}`, {
name: event.path.split('/').pop(),
addedAt: event.ts,
});
}
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
The handler gets one object. Its event is what changed, in the
same shape as
onLocal() events. Its user is a puter object signed
in as the user the subscription belongs to, with the access your app has for
that user. It expires after 15 minutes, so use it only during the run.
Puter saves the handler's code and runs it later in the events worker, so the
handler can't use anything defined outside it, including puter. It can use its
parameters, its own variables, and standard globals like fetch and JSON.
The publish() method rejects any other name with
events_handler_free_variable.
Uploads and copies arrive as add. A file moved in from another folder, for
example by dragging it on the Puter desktop, arrives as a move with no
from, because it came from outside the folder. The handler skips everything
else, including edits, deletes and
gaps. A skipped event still runs the
handler, and each run is billed to the user as worker usage under the
User-Pays Model.
A new folder in Inbox also arrives as an add, and a folder dragged in sends
one move and nothing for the files inside it. To handle those, check the item
with user.fs.stat({ uid: event.uid }) and list a folder with
user.fs.readdir().
Write the handler's output outside the folder it watches, as this one does with
the key-value store. A file it writes into Inbox is a new change that runs the
handler again, and Puter stops that loop after a few runs without an error.
Handler chains has the numbers.
Pass the Handler Inline
To skip the deploy step, pass the function as handler and leave out
handlerName. Puter stores the code in the signed-in user's own
user scope of your app, and runs it for that
user's events only:
const inbox = `~/AppData/${puter.appID}/Inbox`;
const sub = await puter.events.onPersistent({
subject: `fs:${inbox}`,
queue: true,
handler: async ({ event, user }) => {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
await user.kv.set(`uploads:${event.uid}`, {
name: event.path.split('/').pop(),
addedAt: event.ts,
});
},
});
The handler follows the same rules as a published one, and gets its settings
through context in the same way. The handler is private to that user, so one
user's copy can't be changed by anyone else, including you. It also counts
toward that user's limits and bills to the
user like their own workers, and publishing needs credit on a hosted Puter
server. Changing the code later makes a new handler, so the old subscription
keeps running the old code. To roll out new code, unsubscribe and subscribe
again, or publish a named handler into the user scope.
Clean Up Background Work
covers removing what a user's scope holds.
Ask for Permission
Running code while your app is closed needs the user's permission. Ask from
your app with puter.perms.request():
const allowed = await puter.perms.request('backgroundEvents');
It resolves to true or false, and doesn't prompt when the user already
allowed it. To check without prompting, for example to decide whether to show
an "Enable background processing" button, use
puter.perms.check('backgroundEvents').
The user can take the permission back later. That pauses the app's
subscriptions for that user with the reason permission_revoked. They stay
paused even if the user allows it again, so your app has to subscribe again.
Persistent subscriptions need a Puter account. For a guest account,
onPersistent() rejects with
events_durable_requires_account.
Subscribe Once per User
Every onPersistent() call creates a new
subscription, and each one runs the handler for every event. Calling it each
time the app opens would run the handler several times per file, until the user
reaches the limit on persistent subscriptions.
So look for one with puter.events.list() first:
const inbox = `~/AppData/${puter.appID}/Inbox`;
const options = { subject: `fs:${inbox}`, handlerName: 'recordUpload', queue: true };
async function findSubscription () {
const subs = await puter.events.list();
return subs.find((s) =>
s.subject === options.subject && s.handlerName === options.handlerName);
}
async function startBackgroundWork () {
const folder = await puter.fs.mkdir(inbox, { createMissingParents: true });
const existing = await findSubscription();
if (existing?.anchor.uid === folder.uid && existing.suspendedReason !== 'permission_revoked') return;
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
if (await puter.perms.request('backgroundEvents')) await startBackgroundWork();
An fs: subject on a folder matches changes anywhere inside it. The
mkdir() call creates Inbox if it's missing, and returns the
existing folder otherwise. The list() method returns only
your app's subscriptions, with subject exactly as you passed it.
A subscription is tied to the folder itself, not its path. If the user deletes
Inbox on the desktop, the folder moves to the Trash and the subscription
stays on it there. Its anchor.uid is the ID of the folder it's tied to, so
when that differs from the folder mkdir() returned, the code
subscribes to the new folder. It also replaces a subscription paused with
permission_revoked, which never resumes.
If two tabs open at the same moment, both might create a subscription. When
list() returns more than one match, keep one and unsubscribe
the rest.
With queue: true, Puter treats the subscription like a work queue, so every
new file gets recorded. A run that fails with a network error, a timeout or
another temporary error is retried after 2 seconds, then 4, then 8, up to 5
minutes apart. Runs over the per-minute limit for your app wait and run later.
Without queue, a failed run isn't retried and runs over the limit are
skipped, so uploading 100 files at once would record only about 60.
Queued events cost more per event, as
What events cost lists. For work
where a missed event doesn't matter, such as refreshing a summary that the next
change refreshes anyway, leave queue out.
Pass Settings to the Handler
The handler can't see your app's variables, so per-user settings go in
context when you subscribe, and the handler gets them as ctx. For example,
to post each new file to a chat webhook the user saved in your app's settings,
add it to options before calling startBackgroundWork():
options.context = { webhookUrl: settings.webhookUrl };
Then use it in the handler, and publish the handler again:
async function recordUpload ({ event, user, ctx }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
const name = event.path.split('/').pop();
await user.kv.set(`uploads:${event.uid}`, { name, addedAt: event.ts });
const postedKey = `posted:${event.id}`;
if (!ctx.webhookUrl || await user.kv.get(postedKey)) return;
await fetch(ctx.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `New file: ${name}` }),
});
await user.kv.set(postedKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}
A retried event has the same event.id. If a run posts the message but times
out before it finishes, the retry would post it again, so the handler saves the
ID after posting and skips IDs it already posted. The marker key expires after
a week. Saving the record needs no check, since writing it twice changes
nothing.
When the user changes the setting, change the subscription's context with
puter.events.update(). The subscription keeps its ID and
any events still waiting to run:
async function updateWebhook (webhookUrl) {
options.context = { webhookUrl };
const existing = await findSubscription();
if (existing) await puter.events.update(existing.subId, { context: options.context });
}
The context can be up to 4 KB of JSON. For more data, store it with
puter.kv and read it in the handler with user.kv.get().
Update the Handler
To change the handler, publish it again under the same name. Every subscription runs the new code from its next event, and users don't need to do anything. Keep the name the same, since subscriptions are tied to it.
A deploy script runs in a new process each time, so its SDK hasn't seen what's
published. Call handlers.list() before publishing changed
code, or publish() rejects with
events_handler_conflict. This check stops two deploys from overwriting each
other's code without an error:
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
After handlers.list(), publishing only fails if someone
else published in between. Pass replace: true to overwrite anyway. To roll
back, publish the old code.
Check on Subscriptions
A subscription can pause without being removed, and
list() shows why in suspendedReason:
suspendedReason |
What happened | What to do |
|---|---|---|
permission_revoked |
The user took back the permission, or lost access to the folder. | Nothing. It never resumes, and startBackgroundWork() replaces it once the user allows background work again. |
failures |
Five runs in a row failed. | Fix the handler and publish it again. The subscription resumes with up to 100 events it saved while paused. |
handler_not_found |
The handler was removed. | Publish it again. The subscription resumes. |
no_credit |
The user ran out of credit. | Nothing. It resumes when they add credit. |
A handler that throws counts as a failure, and with queue the event is
dropped unless the error has retry: true. When failures pause subscriptions,
Puter sends you, the developer, an app.events.suspended notification.
A subscription can also end, for example when its folder is permanently
deleted. It then disappears from list(), Puter sends the
user's app an app.events.ended notification, and startBackgroundWork()
creates a new one the next time the app opens.
Catch Up on Notifications shows how to
read both notifications.
To turn off background work, for example from a setting in your app, pass the
subId from findSubscription() to
puter.events.unsubscribe().
Full Code
The recipe has two programs. The publish script runs as the app's developer, from a deploy script or your browser console:
const app = await puter.apps.get('drop-box'); // your app's name
async function recordUpload ({ event, user, ctx }) {
const arrived = event.op === 'add' || (event.op === 'move' && !event.from);
if (!arrived) return;
const name = event.path.split('/').pop();
await user.kv.set(`uploads:${event.uid}`, { name, addedAt: event.ts });
const postedKey = `posted:${event.id}`;
if (!ctx.webhookUrl || await user.kv.get(postedKey)) return;
await fetch(ctx.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `New file: ${name}` }),
});
await user.kv.set(postedKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('recordUpload', recordUpload, { appUid: app.uid });
The app code runs in your app, signed in as the user. Here settings is your
app's saved settings for that user:
const inbox = `~/AppData/${puter.appID}/Inbox`;
const options = {
subject: `fs:${inbox}`,
handlerName: 'recordUpload',
queue: true,
context: { webhookUrl: settings.webhookUrl },
};
async function findSubscription () {
const subs = await puter.events.list();
return subs.find((s) =>
s.subject === options.subject && s.handlerName === options.handlerName);
}
async function startBackgroundWork () {
const folder = await puter.fs.mkdir(inbox, { createMissingParents: true });
const existing = await findSubscription();
if (existing?.anchor.uid === folder.uid && existing.suspendedReason !== 'permission_revoked') return;
if (existing) await puter.events.unsubscribe(existing.subId);
await puter.events.onPersistent(options);
}
async function updateWebhook (webhookUrl) {
options.context = { webhookUrl };
const existing = await findSubscription();
if (existing) await puter.events.update(existing.subId, { context: options.context });
}
if (await puter.perms.request('backgroundEvents')) await startBackgroundWork();