← BlogNov 5, 20265 min readSoftware Development

How I Built Postlin's Post Scheduler (and Where BullMQ Fits)

Postlin is an AI LinkedIn assistant I built. You drop in an idea, Gemini helps you turn it into a post, and you pick when it should go out. That last part, "post this at 9:00 on Tuesday", sounds simple. It is the piece I had to think about the most.

It did real work, too. About 30 people used Postlin. I published around 10 real LinkedIn posts through it myself, and other users published more than 50 posts in total.

I want to be honest about one thing up front. My plan was to run scheduling on BullMQ with Redis. BullMQ is in the project, Redis is in the Docker setup and the queue names are defined. But the scheduler that actually publishes posts today is not a BullMQ worker. It is a small worker that runs inside the Next.js server and uses the database as the source of truth. This post is about how that works, why it is built the way it is, and where BullMQ fits next.

What the scheduler has to do

The rules were clear:

  1. Publish each post at the time the person picked, not "sometime in the next few minutes".
  2. Never publish the same post twice.
  3. If LinkedIn fails, try again, but do not try forever.
  4. If someone schedules, moves or cancels a post, react straight away.

Post status lives in the database

Every post has a status in MongoDB through Prisma: DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED and a few others. A scheduled post also has a scheduledAt time, a retryCount and a lastError.

Scheduling a post is a normal API route. POST /api/posts/schedule checks the session, checks the time is in the future, checks the post belongs to you, then sets the status to SCHEDULED with the time. DELETE on the same route puts the post back to DRAFT and clears the time. Both then tell the worker to look again.

Because the state is in the database, nothing is lost if the server restarts. The worker just reads it again.

Starting the worker with Next.js

Next.js has an instrumentation.ts file with a register() function that runs once when the server starts. I use it to boot the worker, only in the Node.js runtime:

ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { initializeServer } = await import("./lib/server-init");
    await initializeServer();
  }
}

initializeServer() has a guard so it only runs once, starts the workers, and stops them cleanly on SIGINT and SIGTERM.

This choice has a catch. The worker only lives as long as the Node.js server does. It needs a long-running server, not short-lived serverless functions.

Waking up at the right moment

The simple way is to check the database every minute. I did not want posts going out up to a minute late, or a query running every few seconds for nothing.

So the worker sets a timer for the exact next post:

  1. Publish anything that is already due.
  2. If it published something, check again right away, in case more posts are due.
  3. Otherwise find the next SCHEDULED post by scheduledAt and set a setTimeout for exactly that delay. If it is due within a second, publish now.
  4. If there is nothing scheduled, check again in 60 seconds. If something errors, also try again in 60 seconds.

When someone schedules or cancels a post, the API calls reschedule(). It clears the current timer and works out the next one again. So a post you schedule for two minutes from now does not wait behind a timer set for tomorrow.

Never posting twice

This is the part I care about most. Posting twice to someone's LinkedIn is embarrassing for them.

Before publishing, the worker claims the post with one atomic update:

ts
const claimed = await prisma.post.updateMany({
  where: { id: post.id, status: "SCHEDULED" },
  data: { status: "PUBLISHING" },
});

if (claimed.count === 0) continue; // someone else got it

The update only matches if the post is still SCHEDULED. If two workers race for the same post, only one gets count === 1. The other skips it. That means even if more than one server instance runs the worker, each post is published once.

Publishing to LinkedIn

Once a post is claimed, the worker:

  • checks the person still has a LinkedIn access token,
  • gets their LinkedIn member id from the userinfo endpoint,
  • uploads the image first if there is one, using LinkedIn's register upload flow,
  • creates the post through the ugcPosts API with public visibility,
  • reads the new post id from the response header and saves it, with PUBLISHED and the publish time.

If the image upload fails, the post still goes out as text. I would rather publish without the image than not publish at all.

Network calls go through a small fetchWithRetry helper that retries on network errors and 5xx responses with a short wait between tries.

When it fails

If publishing fails, the worker adds one to retryCount, saves the error in lastError, and sets the post back to SCHEDULED so it gets picked up again. After three failed attempts the post becomes FAILED. It does not vanish and it does not retry forever, and the reason is saved so it can be shown to the person.

A missing LinkedIn token follows the same path. It counts as a failed attempt, not a silent switch back to draft.

Real people's posts going out on their real LinkedIn accounts is why I cared so much about these rules. I wrote Jest tests for exactly these cases: skip when the claim is lost, publish when it is won, go back to SCHEDULED on failure, become FAILED after the limit, and treat a missing token as a retry.

Where BullMQ fits

The project already has BullMQ, ioredis and a Redis container, plus queue names for draft generation, notifications, engagement polling and scheduled posts. Those are still stubs.

What the current design gets right is that the database decides what is due and who publishes it. That part would not change. Moving to BullMQ would mean:

  • running the worker as its own process instead of inside the web server,
  • adding a delayed job per scheduled post, with the post id as the job id so it cannot be added twice,
  • keeping the atomic claim as a final safety check,
  • using the other queues for slow jobs like AI drafts and engagement polling.

That is the next step if I pick Postlin back up.

What I learned

You do not need a queue to build a correct scheduler. You need a clear source of truth, an atomic claim and a retry limit. That setup published my own posts and more than 50 from other users, for about 30 people in total. A queue makes it easier to scale and to run work in its own process. I would still start from the database rules and add the queue on top.

Keep reading

  1. Nov 5, 2026

    How to Add AI to Your Next.js App Without Making It Slow or Expensive

    AI Development
  2. Aug 17, 2026

    Instagram's Wordmark Finally Moved

    Design Strategy