IndexNow Batch Mode: 286 URLs Per Publish Down to 0
Bing flags full-sitemap submissions as batch mode. The ~40-line lastmod diff that fixes it, the precondition it needs, and two ways it silently breaks.
Bing Webmaster Tools put a banner on our IndexNow page: “IndexNow is in batch mode.” The recommendation underneath was to stream instead — send URLs as they change rather than announcing the whole site at once. We were announcing 286 URLs on every publish, several times a week, because our submit script read sitemap-0.xml and POSTed every <loc> in it. Two new articles, 286 URLs.
The fix is about 40 lines, and it is not the part of IndexNow the protocol docs spend time on. The docs cover the key file, the POST body shape, and the 10,000-URL cap per request. They do not cover deciding which URLs belong in the request, which is the entire problem the moment your sitemap is larger than your publish.
What batch mode is actually measuring
IndexNow has no per-day quota to blow through. The endpoint accepts up to 10,000 URLs in one urlList and returns a 2xx either way. Nothing rejects a full-sitemap submission — you get a warning in a dashboard, and the stated cost is load on the engine plus slower handling of the changes you actually care about.
That framing decides what you do about it. This is not an error you can detect from the API response. Our script logged 200 OK on every one of those full-sitemap runs, for months. The only signal lived in a dashboard nobody opens daily.
The diff: lastmod is the state you already have
The precondition comes first, because it decides whether any of this works: your sitemap’s lastmod has to reflect content changes, not build times. A sitemap integration will happily derive lastmod from file mtime, and our article generator rewrites post files on every run whether the prose changed or not. So astro.config.mjs reads each post’s updatedAt frontmatter at config-load time and serializes that as lastmod instead. If lastmod is effectively new Date() at build, every URL differs from stored state on every build, the diff skips nothing, and you have written a slower version of the same batch submission.
Given a lastmod you can trust, the change is bookkeeping. Parse per-<url> blocks rather than bare <loc> tags, so location and timestamp stay paired:
function parseSitemapEntries(xml: string): Record<string, string> {
const out: Record<string, string> = {};
for (const block of xml.match(/<url>[\s\S]*?<\/url>/g) ?? []) {
const loc = block.match(/<loc>([^<]+)<\/loc>/)?.[1]?.trim();
if (!loc) continue;
out[loc] = block.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1]?.trim() ?? '';
}
return out;
}
const state = await loadState();
const urls = Object.entries(entries)
.filter(([loc, lastmod]) => state[loc] !== lastmod)
.map(([loc]) => loc);
State goes to a gitignored scripts/.indexnow-submitted.json. The first run after the change announced 286 URLs and wrote the file. The second run, with nothing published in between, printed Streaming mode: 0 changed, 286 unchanged (skipped) and sent no request at all. An --all flag forces the full list back for recovery.
Two ways this quietly does nothing
Both of these are live in our own script. Neither shows up on the happy path.
29 of our 286 URLs carry no lastmod at all. The built sitemap has 286 <url> blocks and 257 <lastmod> elements. The 29 without are the homepage, /about/, and the category and tag listings — routes the sitemap integration emits with changefreq and priority only, because the lastmod map is keyed on post frontmatter and these are not posts. They store as an empty string in state, so the filter compares '' !== '', gets false, and skips them permanently. The homepage changes on every single publish, since it lists the newest articles, and it now gets announced exactly once ever. The listing pages are the ones most worth streaming and they are precisely the ones the diff drops. The fix is to give those routes a real lastmod — the max of the posts they contain — not to special-case the empty string.
State is written before the POST is confirmed. Our script records entries to the state file and then calls submit(), which logs a non-2xx and moves on; the whole thing exits 0 by design so a syndication hiccup cannot break a deploy. Put those two properties together and a 403 from a rotated key marks all 286 URLs as announced while announcing none of them. The next run diffs clean and sends nothing. Recovery is one --all run, but you have to notice first, and nothing tells you.
The channels, and when to skip all of this
Three separate mechanisms get conflated. They are not interchangeable:
| Channel | Auth | Ceiling | Best shape |
|---|---|---|---|
| IndexNow (Bing, Yandex, Seznam) | Key file at /<key>.txt, no account | 10,000 URLs per request | Diff on publish |
| Bing URL Submission API | API key from Webmaster Tools | 100/day, 1,300/month on our site | Daily cron draining a backlog |
| No public request-indexing API | Manual clicks in Search Console | Sitemap and patience |
A sitemap ping tells an engine to re-read a file it already polls. IndexNow names specific URLs. Streaming logic only applies to the second — there is nothing to diff about the first.
If you would rather not own any of this, a hosted CMS maintains sitemap timestamps and search-engine pings for you, and the whole problem disappears along with the 40 lines.
Webflow
Hosted CMS that generates and maintains sitemap lastmod values on publish, so per-URL submission bookkeeping is not yours to write or debug.
Free to build; a paid site plan is required for a custom domain and hosted sitemap
Affiliate link · We earn a commission at no cost to you.
The condition that flips it: if you publish from a repo and want lastmod bound to a frontmatter field you control rather than to a save event in an editor, hand-rolling wins, and 40 lines is the entire cost.
What we did not test: whether streaming changed indexing outcomes. Two days is not a result. Every claim here is bounded to submission behaviour — 286 down to 0 on an unchanged run, verified from the script’s own output — not to crawl rate or index coverage. Search Console showed 633 pages crawled-and-not-indexed against 37 indexed on 2026-08-17, and if that number moves, IndexNow batching will be one of a dozen changes made in the same window. We will not be able to attribute it, and neither should you.
FAQ
Does the batch-mode warning mean Bing is ignoring my submissions?
Do I need a local state file, or can I diff against the deployed sitemap?
Is this worth doing on a small site?
Tools used in this review
Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.
Related reading
2026-09-02
OpenAI Deployment Layer: The Assistants API Precedent
OpenAI shipped the Assistants API in November 2023 and marked it for sunset 16 months later. That precedent is how to price the new deployment stack.
2026-08-21
701 Bad Internal Links Before 49 Good Ones
A phrase-matching script inserted 750 links across 269 MDX articles. Here is where they failed, and how claim-matching cut the review pile to 128.
2026-08-21
Cloudflare Web Analytics via GraphQL: the siteTag Filter
Why accountTag and siteTag differ in rumPageloadEventsAdaptiveGroups, how the limit argument truncates silently, and which fields we did not verify.
2026-08-21
Bing Webmaster API's 100-URL Cap: 289 URLs, Three Days
Quota is 100 a day against 1300 a month, GetQueryStats was still empty at the end, and two error shapes will kill a cron job.
2026-08-19
Astro middleware can't serve 410 under output: 'static'
We deleted 434 articles. Middleware runs at build time and 404s in production; two prerender:false routes fix it on Cloudflare Workers.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.