pickuma.
Infrastructure

IndexNow Batch Mode: The lastmod Diff That Took 286 URLs Per Publish Down to 0

Bing Webmaster Tools flags full-sitemap IndexNow submissions as batch mode. Here is the ~40-line lastmod diff that fixes it, the precondition it depends on, and two ways it silently stops working.

6 min read

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:

ChannelAuthCeilingBest shape
IndexNow (Bing, Yandex, Seznam)Key file at /<key>.txt, no account10,000 URLs per requestDiff on publish
Bing URL Submission APIAPI key from Webmaster Tools100/day, 1,300/month on our siteDaily cron draining a backlog
GoogleNo public request-indexing APIManual clicks in Search ConsoleSitemap 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

Try Webflow

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?
There is no evidence of that. The dashboard frames it as a recommendation, and our full-sitemap POSTs returned success responses throughout. Read it as a signal that your submissions carry no information — every publish said the same thing about the same 286 URLs — rather than as an outage to fix urgently.
Do I need a local state file, or can I diff against the deployed sitemap?
Either works. Fetching the live sitemap before deploy and diffing against it needs no local state and survives running from a different CI runner. We use a file because our submit step runs seconds after deploy, when the CDN edge is still serving the previous sitemap — we were burned by that in the other direction on 2026-08-13, when fetching over HTTPS submitted the stale URL list and omitted the pages that had just shipped. Pick whichever source you can prove is fresh.
Is this worth doing on a small site?
Below roughly 50 URLs, no. The engine-side load argument barely applies and you are adding a state file, a recovery flag, and two failure modes to save a few hundred bytes per publish. The break-even is where your sitemap is an order of magnitude larger than a typical publish, which for us was around 286 URLs versus 3.

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

See all Infrastructure articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.