Jad Elamrani's Blog

Tuesday, 12 August 2025

Building a WhatsApp Scheduler

I wanted a small superpower: write a WhatsApp message now and have it go out later—tomorrow morning, in three months, after a meeting, before I forget again. Nothing enterprise. Just honest, dependable "send this later." So I built it.

WhatsApp doesn’t offer scheduled messages mainly because its design focuses on real-time messaging. A “send later” option introduces asynchronous intent, which isn’t a natural fit with their “instant communication” brand identity.

The Goal & Guardrails

From the start I gave myself a few constraints. This would:

  • Use my personal WhatsApp account.
  • Have it run on a server and not locally, meaning it will send even with my phone or laptop not connected to the internet.
  • Cost $0 to operate.
  • Exist for the rest of my life with no operating costs. 0 fixed. 0 variable.
  • Start life as a simple CLI, then gain a tiny UI.

This functionality exists on the WhatsApp Business API and it is great for companies, but it felt like bringing a bulldozer to a herb garden. It's also only available in.. WhatsApp Business. iPhone automations were close, but they are annoying to set up all the time and I wanted something that would still fire even if my phone and laptop were dead or I was on a plane. Tools online required whatsapp web to be be connected all the time. The cheapest one cost $5 a month to send unlimited scheduled messages offline.

The Architecture That Clicked

I kept it to three pieces, each doing one job well:

  • Baileys (Node): speaks WhatsApp Web. I pair once; it sends messages using a saved multi‑device session.
  • Upstash Redis: a serverless store for both scheduled tasks and the WhatsApp session.
  • GitHub Actions (cron): wakes up every 5 minutes, finds anything due, connects with Baileys, sends, sleeps.

Data is simple:

  • A sorted set wa:tasks:pending (score = whenMs, member = task id).
  • A hash per task wa:task:<id> (number, message, human time, timezone, status…).
  • A few wa:session:* keys for Baileys' multi‑device state.

Behavior is simple too: delivery is usually within 0–5 minutes of the target time. If I need it now, I hit "Run workflow" in Actions once the time has passed.

There's a particular kind of relief in seeing "Queued task … for 03:35" and then watching GitHub Actions quietly deliver it while I'm making tea. No server. No tab open. Just… done.

Shipping the Basics (pre‑UI)

I upgraded to Node 20, paired my WhatsApp session once, and added three tiny scripts:

  • pair.js — scan the code, save session to Redis.
  • add_task.js — enqueue a message (to, message, when).
  • send_due.js — the cron worker that sends and marks tasks as sent.

I normalized everything to Africa/Casablanca so "03:35" always means the same thing. From there, the system ran itself.

Adding a Small UI

Typing CLI commands was fine, but I wanted something I could use half‑asleep. I built a Next.js dashboard (App Router, /app directory) and deployed it to Vercel. The UI is intentionally boring: number, date/time, message, Schedule; below that, a list of queued messages with a Cancel button. Same Redis underneath. Same cron sending.

WhatsApp scheduler dashboard UI
Screenshot of scheduled message pending list

Locking the Door: Authentication

Login page using Clerk authentication

Once the UI existed, I put it behind sign-in. I evaluated three managed auth options:

  • Clerk: chosen for tight App Router integration, prebuilt <SignIn/> and <UserButton/>, built-in middleware, and clean server-side guards.
  • Supabase Auth: tempting price and Postgres story, but I'd be wiring more UI/auth glue.
  • Firebase Auth: mature, but more client SDK work to get server-side gating the way I wanted.

How I wired it:

  • JavaScript (no TypeScript) with Next.js App Router.
  • ClerkProvider in app/layout.js.
  • middleware.ts enforces auth; /sign-in is public.
  • app/page.js runs auth() server-side; if no user, redirect to sign-in; otherwise render the dashboard.
  • Sign-up disabled in Clerk so only my account can log in.
  • Secrets (CLERK_SECRET_KEY, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY) live in Vercel env vars; .env.local is git-ignored.

That means the dashboard isn't "hidden" client-side—it's gated before render. Sorry hackers. If you're not signed in, you never see it.

You can check out the website here: jads-scheduler.vercel.app

Why This Approach Worked for Me

No servers to nurse. A datastore + short cron job covers what I needed.

  • Session in Redis. Baileys connects fast; the runner doesn't care where it runs.
  • Fixed timezone. Pick one (Africa/Casablanca) and stick to it. Fewer surprises.
  • Server‑side auth. Cleaner, safer, and simpler to reason about.
  • Small pieces. Pair → enqueue one task → run the sender → then add UI → then add auth.

Today's Flow

Visit / → middleware checks auth.

  • If logged out → /sign‑in (Clerk‑hosted).
  • If logged in (only my account can be) → full WhatsApp scheduler.

Schedule something for later; it appears under Queued. Cron handles delivery. I go make some Moroccan tea. (jk I don't like tea)

Closing Thoughts

The tools I keep are the quiet ones—the ones that make a small promise and keep it. This one does exactly that: type it now, send it later. I coded, deployed, and wrote this blog in under a day, which was a nice surprise.

If you build something similar, start tiny. Pair the account. Write one task to Redis. Run the sender once. Then add the UI. Then put a lock on the door. You'll feel the system click into place.

Update 2 weeks later: The Hidden Cost of Reliability

About two weeks into running this, I got an awakening: I'd burned through GitHub's entire 2,000 free Actions minutes for the month. The problem was my approach. I was running two offset cron schedules, one firing every five minutes starting at the top of the hour, and another firing every five minutes starting two minutes past the hour, to ensure sub-five-minute delivery precision and handle GitHub Actions’ occasional timing inconsistencies. This created 24 workflow runs per hour.

Each run consumed 1 to 2 minutes spinning up a fresh Ubuntu VM, installing dependencies, establishing a WhatsApp Web session via Baileys, querying Redis, and tearing down, whether or not there were any messages to send. The numbers were: 24 runs per hour x 24 hours x 30 days = 17,280 runs per month. At 2 minutes per run, that is 34,560 minutes, over 17 times GitHub's free 2,000 minutes.

I needed to rethink the precision versus cost tradeoff. Instead of uniform intervals, I built a smart schedule that matches human behavior: every 30 minutes during the daytime from 8 a.m. to 8 p.m. for a total of 24 runs, and every 90 minutes overnight from 8 p.m. to 8 a.m. for 8 runs. Total: 32 runs per day x 30 days x 2 minutes = 1,920 minutes, using 96 percent of my free budget instead of obliterating it.

This gives me responsive 30-minute delivery during active hours and relaxed nighttime checking when most messages can wait. The Redis locking mechanisms I had built to prevent race conditions became unnecessary, but the infrastructure stayed robust.