Here is the entire vercel.json for CogniPrep:
{
"crons": [
{ "path": "/api/cron/update-population-stats", "schedule": "0 2 * * *" },
{ "path": "/api/admin/data-retention", "schedule": "0 3 * * *" },
{ "path": "/api/cron/nurture-emails", "schedule": "0 * * * *" }
]
}
No rewrites, no headers, no regions, no function config. Everything else that
could go in here is expressed in code instead, which I like, and the three lines
that remain each carry a decision worth writing down.
They are all GET handlers, and that is not negotiable
Vercel Cron only ever sends a GET. So three endpoints that mutate state (writing
aggregate statistics, deleting expired rows, sending email) are GET handlers:
export async function GET(request: NextRequest) {
const authError = verifyCronSecret(request);
if (authError) return authError;
// ...
}
A GET that sends email is the kind of thing that gets you a code review comment,
and the comment is right in general: anything that can be prefetched, crawled or
retried by a browser should not have side effects. What makes it safe here is
that the guard is the first statement in the handler and the route is in
robots.txt, so the only caller that gets past line one is one holding a secret.
You can see the guard working without any credentials:
GET https://cogniprep.app/api/cron/update-population-stats
401 {"error":"Unauthorized"}
And you can see the job's output, because the aggregates it writes are served
publicly:
GET https://cogniprep.app/api/games/population-stats/balloon
{"gameId":"balloon", ... ,"lastUpdated":"2026-10-10T02:00:15.625Z"}
That lastUpdated is the 02:00 cron finishing fifteen seconds after it started
today. It is the cheapest monitoring we have: if that timestamp is not today, the
schedule is broken, and anyone can check it. The route also calls
revalidateTag for each game it updated, so the cached public response does not
wait out a TTL before showing the new numbers.
The helper's comment describes something it does not do
The guard is nine lines, and its doc comment says it "supports both custom
CRON_SECRET and Vercel's OIDC token". Here is what it actually does:
const cronSecret = process.env.CRON_SECRET;
if (cronSecret && authHeader === `Bearer ${cronSecret}`) return null;
const vercelCronSecret = request.headers.get('x-vercel-cron-secret');
if (vercelCronSecret && vercelCronSecret === cronSecret) return null;
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
Both branches compare a header to the same environment variable. There is no
token parsing, no issuer, no signature, no OIDC anywhere in it. It is one shared
secret accepted in two header shapes, which is a perfectly reasonable thing to
build and not at all the thing the comment claims.
The property the comment gets right is the important one: it fails closed. If
CRON_SECRET is unset, cronSecret is undefined, both branches are skipped,
and every request including the platform's own scheduled one gets a 401. An
unconfigured deploy does not quietly run its own jobs unauthenticated. It stops
running them, loudly, which is the failure you want.
One job has two doors
/api/admin/data-retention is in the cron list, and it also exports a POST for
the admin screen. Same sweep, two entry points, two completely different auth
stories: the GET takes the cron secret, the POST goes through the admin wrapper
that adds the session check, the rate limit and the CSRF check.
That POST used to take a force: true flag meant to bypass a recency check. The
recency check was never implemented, and the branch force selected called only
the session cleanup, skipping the device-session and onboarding sweeps. So
force: true did strictly less work than a normal run, which is the exact
opposite of what the word implies. The flag is gone and the body is ignored. A
boolean that does the reverse of its name is worse than no boolean.
Hourly, for one campaign's sake
The nurture job is the only hourly one, and it is hourly because of a single
campaign. The welcome offer anchors on signup and is meant to land about an hour
after it, which no twice-daily schedule can do. The other campaigns have one to
two day windows and simply get checked more often.
Running an email sender every hour is only safe because minting a per-user,
per-campaign code doubles as the send log. The second scan of the same user is a
no-op because the row already exists, so "run it as often as you like" is a
property of the data model, not of the schedule. Its maxDuration is 300 against
60 for the other two, because five campaigns of email is the one of the three
that can genuinely take minutes.
The job that had to leave
The fourth background job does not appear in that file at all. Mock interview
processing runs on Trigger.dev, and the reason is four characters of config:
export default defineConfig({
dirs: ['./lib/jobs'],
runtime: 'node-24',
maxDuration: 300,
retries: {
enabledInDev: false,
default: { maxAttempts: 3, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, factor: 2, randomize: true },
},
build: { extensions: [ffmpeg()] },
});
That ffmpeg() extension apt-installs ffmpeg and ffprobe on the worker image and
sets FFMPEG_PATH and FFPROBE_PATH at runtime. You cannot apt-install a binary
into a serverless function, and the honest version of "why do you run two
schedulers" is not an architecture diagram. It is that one job needs a system
package and a retry policy with jitter, and the other three need a cron
expression and a secret.
Everything else followed from that rather than being chosen: the retry defaults,
enabledInDev: false so a local failure stops at the first attempt and you can
read the stack trace, the 300 second ceiling matching what the platform allows.
The thing I would tell myself a year ago: the question is never "where should
background jobs live". It is "what does this specific job need that a request
cannot give it". Three of ours needed a clock. One needed a filesystem with
ffmpeg in it. Those are different answers and they were always going to be.
The interview side of this is at https://cogniprep.app/interview, and the cron
side is the timestamp at
https://cogniprep.app/api/games/population-stats/balloon, which should say 02:00
today whatever day you read this.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.