Guide 11

Running bots on Cloudflare Workers: webhooks, secrets, KV, D1, Queues and Cron

Workers run your code in data centers close to users, start in milliseconds and have a generous free tier. For webhook bots they are hard to beat.

Guide3 min readUpdated

When Workers fit

Workers are request-driven: they are ideal for webhook bots (Telegram webhooks, Discord HTTP interactions, Slack Events API, WhatsApp Cloud API) and for scheduled jobs. They are not designed to hold a Discord Gateway connection for hours; use HTTP interactions instead, or a Durable Object if you really need a long-lived socket.

Project setup

npm create cloudflare@latest my-bot -- --type hello-world --ts
cd my-bot
npx wrangler secret put TELEGRAM_BOT_TOKEN
npx wrangler secret put TG_WEBHOOK_SECRET
npx wrangler kv namespace create STATE
# wrangler.toml
name = "my-bot"
main = "src/index.ts"
compatibility_date = "2026-10-01"

[[kv_namespaces]]
binding = "STATE"
id = "<id printed by the create command>"

[triggers]
crons = ["7 * * * *"]   # minute 7 of every hour, UTC

Secrets are encrypted and exposed as env.NAME; they never appear in the code or in wrangler.toml.

A complete Telegram webhook worker

export interface Env { TELEGRAM_BOT_TOKEN: string; TG_WEBHOOK_SECRET: string; STATE: KVNamespace; }

async function tg(env: Env, method: string, body: unknown) {
  const r = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, {
    method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
  });
  if (!r.ok) console.log(JSON.stringify({ level: "warn", method, status: r.status }));
  return r;
}

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname !== "/telegram" || req.method !== "POST") return new Response("not found", { status: 404 });
    if (req.headers.get("X-Telegram-Bot-Api-Secret-Token") !== env.TG_WEBHOOK_SECRET) return new Response("forbidden", { status: 403 });

    const update: any = await req.json();
    const seen = `u:${update.update_id}`;
    if (await env.STATE.get(seen)) return new Response("dup");
    await env.STATE.put(seen, "1", { expirationTtl: 86400 });

    const msg = update.message;
    if (msg?.text) {
      ctx.waitUntil(handle(env, msg));          // respond to Telegram now, keep working
    }
    return new Response("ok");
  },

  async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    ctx.waitUntil(sendDueReminders(env));
  },
};

async function handle(env: Env, msg: any) {
  const text = String(msg.text).slice(0, 1000);
  if (text === "/start") return tg(env, "sendMessage", { chat_id: msg.chat.id, text: "Hello! Send /help to see what I can do." });
  if (text === "/stop") { await env.STATE.delete(`user:${msg.from.id}`); return tg(env, "sendMessage", { chat_id: msg.chat.id, text: "Your data was deleted." }); }
  return tg(env, "sendMessage", { chat_id: msg.chat.id, text: "Unknown command. Try /help." });
}

Deploy and register the webhook:

npx wrangler deploy
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
  -d "url=https://my-bot.<your-subdomain>.workers.dev/telegram" -d "secret_token=$TG_WEBHOOK_SECRET"

Note that KV is eventually consistent, so this duplicate check is best-effort. For strict exactly-once processing, use a Durable Object or a D1 table with a unique constraint.

Choosing storage

  • KV: global key-value store, fast reads, eventually consistent writes. User settings, caches, dedupe keys.
  • D1: serverless SQLite. Relational data, reminders, audit logs, unique constraints.
  • Durable Objects: a single-threaded object with strongly consistent storage per key. Per-chat state machines, rate limiters, WebSockets.
  • R2: object storage for files and media, with no egress fees.

Background work with Queues

ctx.waitUntil is fine for a few seconds of follow-up work. For heavier tasks (AI calls, fan-out broadcasts, retries), send a message to a Queue from the webhook handler and process it in a consumer worker. Queues give you batching, automatic retries and a dead-letter queue, and keep your webhook response fast.

Discord interactions on Workers

Workers support Ed25519 in the Web Crypto API, so you can verify Discord signatures without dependencies. Respond to PING, reply immediately to fast commands, and for slow ones return a deferred response (type 5) and finish the work in waitUntil or a Queue by calling the follow-up webhook.

Limits to plan for

  • CPU time per request is limited (much lower on the free plan); waiting on fetch does not count as CPU time.
  • Subrequests per invocation are capped; batch outgoing calls.
  • Request bodies and memory are bounded; stream large files to R2 instead of buffering.
  • Cron Triggers run in UTC; test expressions with the cron builder.

Check the current numbers in the Workers documentation, as they change with plans.

Observability

Use wrangler tail for live logs during development, enable Workers Logs for retention, and log JSON objects with an event ID, chat ID hash and outcome. Never log the bot token or full message content.

Check your understanding

1. What does ctx.waitUntil do?
2. Which storage gives strong consistency per key?