Building a Rails Notification Queue 3: Queue Processing

This is the third and final piece on creating a notification queue in Rails. If you are just finding this post, i suggest starting with the first post on the database, as it is quite short.

A recap

In the previous two posts we have looked

  • the notification database table
  • the jsonb datatype and its benefits
  • how to schedule notifications
  • how to send notifications

The obvious omission here is... what processes the queue itself.

Processing script

In our case, we do all of our scheduled scripts as rake tasks kicked off from cron. I suppose that topic warrants a post as well, so look out for it in the future. Onto the code.

namespace :notifications do
	desc "Process all of the notifications scheduled to be sent by now"
	task :process_queue => :environment do
		# Check for an existing lock. Exit if found.
		# HEROKU USERS: do NOT use this (more below)
		break if File.exist?( "#{Rails.root}/tmp/queue.lock" )

		# No lock exists. Create one."./tmp/queue.lock", "w") {}

			# Load notifications for this sending batch
			notifications = Notification.unsent.where( 'scheduled_for < ?', )

			notifications.each do |notification|
				rescue StandardError => e
					Rails.logger.error( "Notification Error: #{e.inspect}" )
					Rails.logger.error( e.backtrace )

There are some interesting bits to this script that we should touch on.

The first of them is the semaphore. A semaphore is a way to ensure only a single copy of a script is running at one time. Because our script loads all unsent messages before it begins to process them, a second instance of this processing script, launched before the first one completes, will load some notifications the first one will process. This semaphore prevents us from sending a notification twice.

To accomplish this, we use the existence of a lock file, which is nothing but an empty file, to control the script's execution. Notice how we put the File.delete in an ensure block to assure it is never skipped.

The next notable portion of code is the query, Notification.unsent.where( 'scheduled_for < ?', ). The only reason i call attention to this query is the <. This allows us to effectively send notifications in batches. Each batch is every notification scheduled to be sent between the last time the processing script started and the next time it loads unsent notifications.

Process scheduling

Which brings us into the topic of how, and when, the schedule the processing queue. This will vary between implementations based on the number of users on your site and the importance of a notification going out as close as possible to its intended send time.

In our case, we run the script every minute. At this pace, regular app traffic takes the script between 5-30 seconds to process all unsent notifications. 1 minute intervals means our server isn't spending a lot of time querying unnecessarily for unset messages. Also, because none of our notifications are critical with their send minute, having 1 minute batches is not a customer issue.

A note to heroku users

Heroku users need to modify this script. Heroku's scheduler has a lot of oddities, not the least of which is silent script termination. Using the heroku scheduler, scripts that run for long periods of time, 10-15 minutes, are silently terminated. Using a lock file can cause obvious problems in this case. Because of this issue, if you are using heroku as your scheduler, a semaphore is more dangerous than valuable.


Yes! Those are all the main pieces of the notification system. As i have mentioned in the previous posts, i urge people to customize this to this specific codebase. Many of the things in the architecture have been wins for my codebases, but that doesn't mean it is perfect for yours as is. Play around with it and find your special sauce.

tl;dr: The scheduling details of a notification script can be easily modified if the processing script itself is robust.

Get the latest posts delivered right to your inbox.
Author image
Written by Ben
Ben is the co-founder of Skyward. He has spent the last 10 years building products and working with startups.