Back to Blog
Tutorial11 min read2026-08-01

Sending Scheduled Emails with a PandaStack Cronjob

Run a Node.js script every morning at 9 AM to query a database for pending notifications and send emails via SendGrid, with cron syntax, failure retries, and execution logs.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Scheduled tasks that send emails — daily summaries, weekly reports, abandoned cart reminders — are a common backend requirement. You can build this as a background job in your main app, but that couples scheduling logic to your deployment cycle. If the app restarts during the cron window, the job might not run. If you scale to three replicas, the job runs three times.

PandaStack's cronjobs are isolated, scheduled containers that run on a fixed schedule, independent of your app deployments. Define the cron expression, point it at a script in your repo, and the platform handles execution, retries, and logging.

Write a Node.js script that queries the database and sends email

Create a file send-notifications.js:

const { Client } = require('pg');
const sgMail = require('@sendgrid/mail');

sgMail.setApiKey(process.env.SENDGRID_API_KEY);

async function main() {
  const client = new Client({ connectionString: process.env.DATABASE_URL });
  await client.connect();

  const res = await client.query(
    'SELECT id, user_email, message FROM notifications WHERE sent = false'
  );

  console.log(`Found ${res.rows.length} pending notifications`);

  for (const row of res.rows) {
    const msg = {
      to: row.user_email,
      from: 'notifications@yourapp.com',
      subject: 'Your daily summary',
      text: row.message,
    };

    try {
      await sgMail.send(msg);
      await client.query('UPDATE notifications SET sent = true WHERE id = $1', [row.id]);
      console.log(`Sent email to ${row.user_email}`);
    } catch (err) {
      console.error(`Failed to send email to ${row.user_email}:`, err.message);
    }
  }

  await client.end();
}

main().catch(err => {
  console.error('Fatal error:', err);
  process.exit(1);
});

This script connects to Postgres, fetches rows from a notifications table where sent = false, sends each one via SendGrid, and marks it as sent. If SendGrid returns an error (rate limit, invalid email), the script logs it but continues processing other rows.

Add dependencies in package.json:

{
  "name": "email-cronjob",
  "version": "1.0.0",
  "dependencies": {
    "pg": "^8.11.0",
    "@sendgrid/mail": "^7.7.0"
  },
  "scripts": {
    "start": "node send-notifications.js"
  }
}

Commit this to a Git repo.

Create the cronjob on PandaStack

Use the CLI to create a cronjob that runs the script every day at 9 AM UTC:

panda cronjobs create \
  --name daily-email-job \
  --repo github.com/yourname/email-cronjob \
  --branch main \
  --schedule "0 9 * * *" \
  --command "npm start" \
  --env DATABASE_URL=$DATABASE_URL \
  --env SENDGRID_API_KEY=$SENDGRID_API_KEY

The --schedule flag uses standard cron syntax:

┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6, Sunday = 0)
│ │ │ │ │
0 9 * * *

This expression means "run at minute 0 of hour 9, every day." For 9 AM Eastern Time, adjust for your timezone offset or use a tool like [crontab.guru](https://crontab.guru) to verify the expression.

PandaStack spins up a container at the scheduled time, runs npm start, streams the logs, and tears down the container when the script exits. The next run happens 24 hours later.

View execution logs

Go to the dashboard Cronjobs → daily-email-job → Logs to see output from recent runs. Each execution is logged separately with a timestamp.

If the script exits with a non-zero status code (like process.exit(1) on a fatal error), the execution is marked as failed. PandaStack does not retry failed cronjobs automatically — if you need retries, implement them in the script or use an external queue.

Link a managed database

Instead of hard-coding DATABASE_URL, link the cronjob to the same managed PostgreSQL database your API uses. PandaStack injects the connection string automatically.

panda cronjobs link-database <cronjob-id> <database-id>

Redeploy the cronjob (or wait for the next scheduled run). The script reads DATABASE_URL from the environment and connects to the shared database.

This keeps the API and cronjob in sync. When the API writes a row to notifications, the cronjob picks it up on the next run and sends the email.

Handle timezone-aware scheduling

Cron expressions in PandaStack run in UTC. If you want the job to run at 9 AM in New York (America/New_York), you need to calculate the UTC offset.

  • 9 AM EST (UTC-5)14 * * * * (2 PM UTC)
  • 9 AM EDT (UTC-4)13 * * * * (1 PM UTC)

Daylight saving time changes this offset twice a year. For precise scheduling, handle the conversion in the script instead of relying on cron syntax. Query the current time in the target timezone and skip execution if it is not the right hour.

Alternatively, run the job every hour and check the local time inside the script:

const now = new Date().toLocaleString('en-US', { timeZone: 'America/New_York' });
const hour = new Date(now).getHours();

if (hour !== 9) {
  console.log('Not 9 AM in New York, skipping');
  process.exit(0);
}

// Proceed with email sending

This guarantees the job only executes during the target window, regardless of UTC offset changes.

Avoid duplicate sends with idempotency

If the cronjob runs twice (manual trigger, schedule overlap), the script might send duplicate emails. Prevent this by marking rows as sent in the same transaction that queues the email:

await client.query('BEGIN');
const res = await client.query(
  'SELECT id, user_email, message FROM notifications WHERE sent = false FOR UPDATE SKIP LOCKED'
);

// Send emails...

await client.query('UPDATE notifications SET sent = true WHERE id = ANY($1)', [
  res.rows.map(r => r.id)
]);
await client.query('COMMIT');

The FOR UPDATE SKIP LOCKED clause locks rows while processing them and skips rows already locked by another transaction. If two instances of the script run simultaneously (which should not happen in PandaStack, but might in a self-hosted setup), they process disjoint sets of rows.

Use a KV store for rate limiting

SendGrid enforces rate limits. If you send 1,000 emails in one cronjob run, you might hit the limit and get throttled. Spread the sends across multiple runs or implement a rate limiter using PandaStack's KV store (managed Redis).

Link a KV instance to the cronjob:

panda cronjobs link-kv <cronjob-id> <kv-id>

PandaStack injects KV_REST_API_URL and KV_REST_API_TOKEN. Use these to track how many emails have been sent in the current day:

const fetch = require('node-fetch');

const KV_URL = process.env.KV_REST_API_URL;
const KV_TOKEN = process.env.KV_REST_API_TOKEN;

async function incrementEmailCount() {
  const res = await fetch(`${KV_URL}/incr/email_count_today`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KV_TOKEN}` }
  });
  const data = await res.json();
  return data.result;
}

async function main() {
  const count = await incrementEmailCount();
  if (count > 500) {
    console.log('Daily email limit reached, skipping');
    process.exit(0);
  }

  // Send emails...
}

Set the counter to expire at midnight UTC:

const secondsUntilMidnight = (86400 - (Date.now() / 1000) % 86400) | 0;
await fetch(`${KV_URL}/expire/email_count_today/${secondsUntilMidnight}`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${KV_TOKEN}` }
});

This caps the cronjob at 500 emails per day. If you need more, increase the limit or split the job across multiple runs.

Test the cronjob manually before scheduling

Before setting a daily schedule, run the cronjob manually to verify it works:

panda cronjobs trigger <cronjob-id>

This starts the container immediately, runs the script, and streams the logs to the terminal. If it succeeds, schedule it. If it fails, fix the script and test again.

Manual triggers do not affect the regular schedule. If the job is scheduled for 9 AM daily, triggering it manually at 3 PM does not skip the 9 AM run.

Monitor execution history

The dashboard shows the last 10 runs for each cronjob, with start time, end time, exit code, and log output. If a run fails, the exit code is non-zero and the logs show the error.

For long-term monitoring, export logs to an external service (like Elasticsearch or a log aggregator) or query the PandaStack API for execution history:

curl -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  https://api.pandastack.io/v1/cronjobs/<cronjob-id>/executions

This returns JSON with timestamps and status for recent runs.

Why cronjobs beat running cron inside your app

Running cron or node-cron inside your container app works until it does not. The job runs multiple times if you scale horizontally. It misses runs if the app restarts during the cron window. It couples your app's deploy cycle to background task reliability.

Cronjobs as a separate service solve all of these. They run independently, are not affected by app deployments or scaling, and are easy to test and monitor in isolation.

PandaStack's cronjobs are built on Kubernetes CronJobs, which handle retries, concurrency policies, and failure tracking. You get the benefits of a mature scheduling system without managing the infrastructure.

References

  • [SendGrid Node.js library](https://github.com/sendgrid/sendgrid-nodejs)
  • [Cron syntax reference](https://crontab.guru)
  • [PandaStack cronjobs documentation](https://docs.pandastack.io)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also