A prototype answers a request and stops. A real product also has to send a password reset at three in the morning, keep the photo somebody uploaded, and send an invoice reminder on the first of next month. None of those fit inside "a page loads". They are the three pieces people discover at the end of the project, when they are hardest to add.

The idea

Some work does not belong in the request. Sending email means handing a message to a service that specializes in getting mail delivered rather than filtered as spam. Storing an upload means putting the file somewhere that is not your app server, because that disk gets wiped on every deploy. Work that runs later means a scheduled job or a that lets the visitor's page finish loading while the slow part happens somewhere else.

code
   REQUEST (aim for ~1s, hard cut later) LATER (nobody is waiting)
   +-----------------------------+       +------------------------------+
   | user clicks "sign up"       |       |  worker process              |
   |   write row to Postgres     |       |    picks the job off a queue |
   |   put "send welcome" on ----------> |    calls Resend              |
   |   the queue                 |       |    retries if it fails       |
   |   return the page           |       +------------------------------+
   +-----------------------------+
                                         +------------------------------+
   uploads never touch your app disk     |  cron: 03:00 daily           |
   browser --> Supabase Storage / S3     |    email invoice reminders   |
              (bucket, private by        |    delete expired uploads    |
               default, signed URLs      |    retry yesterday's failures|
               minted by your server)    +------------------------------+

How it works

Email is a deliverability problem more than a code problem. Resend and Postmark exist because mail you send from your own server lands in spam. They give you an API call and, more importantly, a list of DNS records to add. Those records live with whoever you bought the domain from, in a screen usually called DNS, and you paste in exactly what the provider shows you. Resend will not send from your domain until it can see an MX record and two TXT records: SPF and DKIM, which together say this sender is allowed to send as you. Verification usually takes minutes and can take up to 72 hours. DMARC is a third record, not required to start, and it tells receiving servers what to do with mail that fails the first two. Reputation takes weeks to build and one bad send to ruin, so do this before your first real send.

Uploads go to : Amazon S3, Supabase Storage, Cloudflare R2. Your server never holds the file, but it does hand out the permission: your server creates a short-lived signed URL, gives that URL to the browser, the browser uploads straight to the bucket, and your database stores the path rather than the bytes. The signing has to happen on your server, because the key that can sign is the key that can write anything. Keep buckets private and generate a signed link when someone is allowed to read one.

Background work splits in two. Scheduled work runs on a clock, which is on a server or a scheduled function on Vercel or Supabase. Queued work runs as soon as a worker is free, and the job row is the durable part: written first, marked done last, so a crash means a retry rather than a lost email.

What to do

  1. Add email early, with a real domain and verified DNS records. Send yourself the welcome mail and check it did not land in spam.
  2. Send uploads directly to storage from the browser using a signed URL your server created. Store the file path in Postgres. Cap the file size in two places: check it in the browser so the user gets told before a ten-minute upload, and set the limit on the bucket itself, because the browser check is a courtesy anyone can skip.
  3. Move anything slower than about a second out of the request: image resizing, PDF generation, third-party calls that can hang. The user should not wait for it.
  4. Make every background job safe to run twice. It will run twice. In practice that means the job row itself records that the work finished, in a column such as sent_at, and the worker checks that column before acting. Same idea as the idempotency key from the previous section, kept in your own table instead of the vendor's.

Where it breaks

Each of these adds a piece that fails on its own schedule, quietly. A queue with no worker running looks exactly like a working app until you notice nobody got their email, so you need visibility into failed jobs, not just successful ones. Storage costs grow without anyone deciding, especially if users upload video and nothing ever expires. Cron on a single server is fine until you run two servers and every job fires twice. Every hosting platform also cuts a request off eventually, on a limit you can look up and often raise, and the number differs enough between them that it is worth reading yours rather than assuming. That ceiling is not a budget. A page that takes twenty seconds is inside almost every limit and has already lost the person waiting for it.