In short
On Vercel's free plan a function can be killed at 60 seconds, a cron job runs at most daily, and serverless instances do not share memory, so code that works on a laptop fails or misbehaves in production.
Treat each limit as a design rule: stop long work before the cap and report what finished, keep cron jobs small and repeatable, store anything that must be global in a database, pre-render everything that can be static, and add a test so a bad setting can never reach a deploy.
Most "Vercel limits" advice tells you what the limits are. This is about what I changed in my own code once I hit them.
This site is a Next.js 16 project on Vercel. Besides the portfolio and this blog, it runs JobPilot, a private job-search tool with AI calls, scraping and a daily job. That second part is what pushed against the limits. Everything below is from the real repo.
Limit 1: a function can be killed at 60 seconds#
JobPilot's AI routes (tailoring a resume, analysing a job, drafting a reply) call a language model, which can be slow. Each of those routes declares its own ceiling:
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;The risk is that a setting above the plan's cap does not just fail one request. It fails the whole deploy, with an error like Builder returned invalid maxDuration value. One careless edit to a single route file would block every release.
So I wrote a test that reads every route file:
test('no route declares a maxDuration above the Vercel Hobby limit of 60 seconds', () => {
const files = globSync('src/app/**/*.{js,jsx,ts,tsx}');
const offenders: string[] = [];
for (const f of files) {
const m = /export\s+const\s+maxDuration\s*=\s*(\d+)/.exec(readFileSync(f, 'utf8'));
if (m && Number(m[1]) > 60) offenders.push(`${f}: ${m[1]}`);
}
assert.deepEqual(offenders, []);
});npm test now catches the mistake in seconds, before a deploy ever starts. A limit that can break a release belongs in a test, not in your memory.
Limit 2: long jobs need a deadline of their own#
Setting maxDuration = 60 does not make slow work fast. It only decides when Vercel stops it. If the function is killed mid-way, the user gets an error and no idea what finished.
The job importer scrapes a careers page and imports up to 12 postings. That can take a while, so it enforces its own deadline well below the cap:
/** Vercel Hobby kills functions at 60s. Stop starting new pages after this
* so the response always completes. */
const DEADLINE_MS = 40_000;
const BATCH = 4;
const MAX_LISTING_JOBS = 12;It works in batches of four. Before starting a new batch it checks the clock, and past 40 seconds it stops and returns what it has finished, with the failures listed. The user sees "imported 8, 4 failed" instead of a timeout.
The 40-second figure is also tested:
assert.ok(deadline > 0 && deadline <= 45_000,
`deadline ${deadline}ms must leave room for one more batch inside 60s`);The idea is simple: the function should end itself, on its own terms, before the platform ends it.
Limit 3: cron runs at most once a day#
My cron job in vercel.json is:
{ "crons": [{ "path": "/api/cron/jobpilot", "schedule": "0 13 * * *" }] }That is once a day at 13:00 UTC, which is 18:30 in India. On the Hobby plan that is the frequency you get, so the job has to be written for it.
That shaped the job in three ways:
- Bounded. Each run handles at most 200 items per user per collection and imports at most 50 new jobs per account, so a run always finishes inside the function cap.
- Idempotent. It marks tasks as due, flags stale jobs and discovers new ones. Running it twice gives the same result as running it once, so a retry or a manual trigger does no harm.
- Locked down. It refuses to run without a
CRON_SECRETand checks theAuthorization: Bearerheader with a timing-safe comparison, so nobody can trigger it by visiting the URL.
If you need something to happen every few minutes, a once-a-day cron is the wrong tool on this plan. Do the work in response to a user action instead, or use an external scheduler.
Limit 4: serverless instances do not share memory#
My first rate limiter is a plain in-memory map:
/**
* Best-effort per-instance sliding-window limiter. On serverless each warm
* instance has its own counters, so this blunts bursts and runaway loops but
* is NOT a hard global quota.
*/
const hits = new Map<string, number[]>();It stops a runaway loop on one instance, but another instance has its own empty map, so it is not a real quota. Anything that costs money needs a counter every instance can see.
So the hard limits live in Firestore, not in memory:
- AI calls: a per-user daily counter, 60 requests by default, updated inside a transaction so two simultaneous requests cannot both slip under the limit.
- Firecrawl credits: a daily and a monthly budget, shared across instances and across both accounts that use the one key.
await ws.transact(async (tx) => {
const snap = await tx.get(ref);
const used = (snap.data()?.aiCalls as number | undefined) ?? 0;
if (used >= limit) throw new AiError('budget-exceeded', `Daily AI limit reached (${limit} requests).`, 429);
tx.set(ref, { aiCalls: used + 1 }, { merge: true });
});The in-memory limiter handles bursts, and the database handles money. I use both on purpose.
Limit 5: keep the bundle and the work small#
The best way to stay under compute limits is to do less compute. Three things I did:
Pre-render everything that can be static. Blog posts, case studies and the sitemap are generated at build time, with dynamicParams = false on the dynamic routes. A visitor gets a file from the CDN, not a function run. The share-card and cover images are the same: they are rendered once at deploy time with export const dynamic = 'force-static', so loading a card is a cache hit, not an image render.
Don't let the bundler trace your whole project. The image generator reads font files from disk. I wrote the paths as literal strings instead of building them from a variable:
readFile(join(process.cwd(), 'src/fonts/og/ProductSans-Regular.ttf')),
readFile(join(process.cwd(), 'src/fonts/og/ProductSans-Bold.ttf')),A path built from a variable makes Turbopack trace the entire project, public folder included, into the serverless bundle. Literal paths keep it to the files you meant.
Read shared assets once. The font promise is memoised, so the roughly 30 cards a build renders share one set of reads instead of one set each.
Smaller lessons the platform taught me#
- Env vars don't deploy themselves.
.env.localis gitignored, so a key set locally never reaches production. My QA notes list the Search Console, Bing and analytics keys as missing from production until added in Vercel and redeployed. - A missing key should not take the site down. I wrote about how one missing env var took down my entire site. Now the data layer falls back to a bundled dataset.
- Never put a catch-all rewrite in
vercel.json. The SPA-style/(.*) → /index.htmlswallows every route, API handler,robots.txtandsitemap.xmlon a Next.js app. The file stays minimal on purpose. - Don't build into a folder your dev server is using. I build into a separate
NEXT_DIST_DIRso a test build cannot corrupt a runningnext dev.
What I'd do again#
- Put each platform limit in a test, so a bad setting fails in seconds instead of at deploy.
- Give long jobs a deadline of their own, comfortably under the cap, and report partial results.
- Write cron jobs to be bounded and idempotent, and protect them with a secret.
- Keep anything global, like quotas and budgets, in a database, never in memory.
- Pre-render by default and treat every function invocation as something to justify.
None of this needed a paid plan. It needed treating the limits as design rules. For a client with real traffic or longer jobs, moving up a plan is a fair call, and it is one I would discuss in the scope. If you want a Next.js site built with these constraints in mind, see what a project costs or start a project.
- nextjs
- vercel
- web-development
Common questions
What is the maximum function duration on Vercel's Hobby plan?
The code in this project was written against a 60-second cap, and a setting above it makes the build fail. Vercel changes its limits over time, so check the current limits page for your plan before you rely on a number.
How often can a cron job run on the Hobby plan?
The Hobby plan allows cron jobs that run at most once a day, so a schedule like '0 13 * * *' is fine and an hourly schedule is not. Check Vercel's current documentation, since plan terms change.
Why does an in-memory rate limiter not work on serverless?
Each warm instance has its own memory, so each keeps separate counters. The limiter still blunts bursts from one instance, but it cannot enforce a global quota. For a hard limit, store the counter in a database that every instance shares.
Do I need to pay for Vercel Pro to run a real project?
Not necessarily. A portfolio, a blog and a small internal tool can run on the free plan if the work is mostly pre-rendered and long jobs are bounded. A paying client with traffic or longer jobs is a good reason to move up.
Share this
One click opens LinkedIn with a ready-written post: a hook, the short version of this guide and the link. The caption is also copied, so you can paste it if it does not appear.
Instagram has no web share link, so this gives you both pieces: copy the caption, save the card, then post it.
Keep reading
10 Next.js SEO Tips for Developers
Ten Next.js SEO tips: metadata, sitemaps, canonical URLs, structured data, image optimisation and avoiding the 'crawled, currently not indexed' trap.
How one missing env var took down my entire site
A renamed env var made getAuth() throw at module scope. That module sat in the import graph of every page, so one absent key took down all seven routes.
Search Console said 'Couldn't fetch' on a valid sitemap. Adding ?v=2 fixed it
My sitemap returned 200 and 70 valid URLs, yet Search Console said 'Couldn't fetch'. What I checked, what URL Inspection said, and how ?v=2 fixed it.
Got this problem too?
Websites, web apps and SaaS products in React and Next.js, with the backend, payments and integrations they need to actually run a business.