Skip to content
Measure and Reduce LLM Token Use in Web Data Applications
Article

Measure and Reduce LLM Token Use in Web Data Applications

Web

Measure how many LLM tokens HTML, Markdown and field-only JSON use for the same pages, check answer quality for each and set a per-job token budget.

By MrScraper Team 14 min read

Every web page you send to an LLM costs tokens. Send the raw HTML, and you pay for menus, scripts and styling the model does not need. Send only a few extracted fields, and you might leave out the one sentence that answers the question.

You will see claims that one format is "10 times cheaper" than another. Those numbers depend on the pages, the model and the task, so they rarely carry over. The only reliable number is one you measure.

This guide shows how to run that measurement. You will compare three versions of the same pages, count tokens with your chosen model's own counter, check answer quality for each version and set a token budget for your jobs.

We ran it on 19 product pages with Claude Sonnet 5.5. Markdown used about 72% fewer input tokens than raw HTML and field-only JSON used about 98% fewer. But each smaller version also lost answers the bigger one could give. The full results are in the middle of this guide.

How do HTML, Markdown and field-only JSON differ?

They are three levels of the same page, from everything to only what you asked for.

Version What it keeps What it drops Main risk
HTML Everything: text, tags, scripts, styles, attributes Nothing Most tokens, mostly markup
Markdown Text, headings, lists, links and tables Tags, scripts and styling Some page clutter stays, like menus and footers
Field-only JSON Only the fields in your schema Everything else on the page Missing context if a question needs text outside the fields

You can get all three from MrScraper for each page:

  • The web fetching endpoint returns the page's raw HTML.
  • The Scrape Markdown endpoint returns markdown.
  • The Scrape JSON endpoint returns structured data from a prompt and schema. The Scrape Markdown docs say the response also includes the raw html by default. In our test, that field came back empty when we asked for Markdown, so the code below fetches the HTML with its own request.
python
import json
import os
import requests
from datetime import datetime, timezone
 
MRSCRAPER_URL = "https://api.mrscraper.com/"
TOKEN = os.environ["MRSCRAPER_API_TOKEN"]
 
 
def call_mrscraper(body: dict, **params) -> dict:
    response = requests.post(
        MRSCRAPER_URL,
        params={"token": TOKEN, **params},
        headers={"x-api-token": TOKEN, "Content-Type": "application/json"},
        json={**body, "maxRetries": 2},
        timeout=330,
    )
    response.raise_for_status()
    return response.json()
 
 
def fetch_html(url: str) -> str:
    response = requests.get(
        MRSCRAPER_URL,
        params={"token": TOKEN, "url": url, "html": "true"},
        headers={"x-api-token": TOKEN},
        timeout=330,
    )
    response.raise_for_status()
    return response.text
 
 
def three_versions(url: str, schema: dict) -> dict:
    page = call_mrscraper({"url": url}, markdown="true")
    fields = call_mrscraper({
        "url": url, "agent": "general",
        "prompt": "Extract these fields.\nJson Schema:\n" + json.dumps(schema),
    }, html="false")
    return {
        "url": url,
        "retrieved_at": datetime.now(timezone.utc).isoformat(),
        "html": fetch_html(url),
        "markdown": page.get("markdown", ""),
        "json": json.dumps(fields.get("data") or {}, sort_keys=True),
        "mrscraper_tokens": {"markdown": page.get("token_usage"), "json": fields.get("token_usage")},
    }

Save these versions to disk. Every later step reads the saved copies, so all three are measured on exactly the same page content.

Check that you got the page you asked for

A fetch can fail without raising an error. In our test, one Markdown fetch came back as a browser error page ("This site can't be reached") instead of the book page. The request succeeded, so nothing flagged it. That one page made the Markdown version look worse than it was.

So check each saved version before you use it. A simple test is whether a value you know should be on the page, like the extracted title, appears in the text:

python
from html import unescape
 
 
def looks_right(page: dict) -> bool:
    title = (json.loads(page["json"]).get("title") or "").lower()
    return bool(title) and all(title in unescape(page[v]).lower() for v in ("html", "markdown"))

Refetch any page that fails, or leave it out of the comparison and say so.

One thing to keep separate: the token_usage field in a MrScraper response is MrScraper's own usage for that request. It is not the number of tokens your LLM will see. Track both, because field-only JSON moves work from your LLM call into the extraction step.

How do you count tokens for the selected model?

Use the token counter from the provider of the model you will actually call. Different models split text into tokens differently, so a count from one model does not tell you the count for another.

Anthropic's docs give a clear example. Claude 4.7 and later models use a newer tokenizer, and the same text produces about 30% more tokens than on earlier models. A budget set on one model can be off on the next.

Use the provider's counting endpoint

Both major providers offer a way to count input tokens before you send a request.

Anthropic. The token counting endpoint is free to use. The docs say the count is an estimate that may differ slightly from the final bill.

python
import anthropic
 
claude = anthropic.Anthropic()
 
 
def count_claude(model: str, system: str, user_text: str) -> int:
    result = claude.messages.count_tokens(
        model=model,
        system=system,
        messages=[{"role": "user", "content": user_text}],
    )
    return result.input_tokens

OpenAI. The input token counting endpoint takes the same input as a normal request. The docs say it returns the exact count, including formatting tokens that local tokenizers can miss.

python
from openai import OpenAI
 
openai_client = OpenAI()
 
 
def count_openai(model: str, instructions: str, user_text: str) -> int:
    result = openai_client.responses.input_tokens.count(
        model=model, instructions=instructions, input=user_text,
    )
    return result.input_tokens

Count the full prompt, not just the page. Your instructions, the question and any formatting all add tokens.

Count each version of each page

python
SYSTEM = "Answer the question using only the page content provided."
 
 
def count_versions(page: dict, question: str, model: str, counter) -> dict:
    counts = {}
    for version in ("html", "markdown", "json"):
        prompt = f"Page ({version}):\n{page[version]}\n\nQuestion: {question}"
        counts[version] = counter(model, SYSTEM, prompt)
    return counts

Raw HTML from some pages can be larger than your model's context window. If a count is too large to send, record that as a result. It is a real finding about that version.

How do output quality and cost trade off?

Fewer tokens only help if the answers stay right. So for each version, measure both the cost and the share of questions answered correctly.

Build a small, dated test set

  1. Pick 20 to 30 pages of one kind, such as product pages or docs pages.
  2. Write one or two questions per page, with the correct answer from the page.
  3. Include questions the extraction schema covers and questions it does not. The second group shows where field-only JSON loses information.
  4. Write down the date. Pages change, so your results describe that day.
json
{"url": "https://docs.example.com/plans", "question": "Which plan includes SSO?",
 "answer": "Business", "in_schema": true}

Ask the same model, score the answers

ask is your own function. It sends the prompt to your chosen model and returns the answer text plus the usage the provider reports. Use the same model, settings and prompt template for every version.

python
def normalize(text: str) -> str:
    return " ".join((text or "").lower().split()).strip(" .")
 
 
def run_test(pages: dict, items: list[dict], model: str, ask) -> dict:
    results = {v: {"correct": 0, "input": 0, "output": 0, "n": 0} for v in ("html", "markdown", "json")}
    for item in items:
        page = pages[item["url"]]
        for version in results:
            prompt = f"Page ({version}):\n{page[version]}\n\nQuestion: {item['question']}"
            answer, usage = ask(model, SYSTEM, prompt)
            r = results[version]
            r["n"] += 1
            r["correct"] += normalize(item["answer"]) in normalize(answer)
            r["input"] += usage["input_tokens"]
            r["output"] += usage["output_tokens"]
    return results

The scoring rule here is simple: the correct answer must appear in the model's reply. For longer answers, have a person grade a sample, or use a stricter check.

Turn tokens into cost

Use the prices on your provider's pricing page on the day of the test, and link that page in your write-up.

python
def cost(result: dict, price_in_per_m: float, price_out_per_m: float) -> float:
    return (result["input"] * price_in_per_m + result["output"] * price_out_per_m) / 1_000_000

Then add the extraction side. Each version needs its own MrScraper call per page: web fetching for HTML, Scrape Markdown for Markdown and Scrape JSON for field-only JSON. Use each response's token_usage and your MrScraper plan's rate.

What we measured

We ran this test with the code in this guide. Here is what we found.

The setup

  • Date: October 21, 2026
  • Model: Claude Sonnet 5.5 (claude-sonnet-5-5), with tokens counted by Anthropic's token counting endpoint
  • Pages: 20 book product pages from books.toscrape.com, a practice site built for scraping. One Markdown fetch failed, so the main results use the 19 pages where all three versions were fetched correctly.
  • Field-only JSON schema: title, price and availability
  • Questions: six per page, asked of every version. Three are covered by the schema: title, price and stock count. Three are not: the UPC code, the genre and the star rating.
  • Correct answers: read from each page's own HTML, not judged by a person or a model
  • Prices: $2 per million input tokens and $10 per million output tokens, from Anthropic's pricing page on the test date

The results

Results for 19 pages, with 114 questions per version:

Version Avg input tokens per question Correct, in-schema questions Correct, other questions LLM cost per 100 questions Extraction usage per page
HTML 6,656 57/57 57/57 $1.3422 Not reported by web fetching
Markdown 1,856 57/57 37/57 $0.4014 2 MrScraper tokens
Field-only JSON 106 57/57 0/57 $0.0313 10 MrScraper tokens

Accuracy by question:

Question In schema HTML Markdown Field-only JSON
What is the book's title? Yes 19/19 19/19 19/19
What is the price? Yes 19/19 19/19 19/19
How many copies are in stock? Yes 19/19 19/19 19/19
What is the UPC code? No 19/19 19/19 0/19
Which genre is the book listed under? No 19/19 17/19 0/19
What is the star rating? No 19/19 1/19 0/19

The whole test, including the failed page, cost $2.23 in Claude API usage for 360 calls.

What the numbers show

  • Every version answered the in-schema questions perfectly. For title, price and stock, the field-only JSON was just as accurate as the full HTML at about 1.6% of the input tokens.
  • Markdown cut input tokens by about 72%. The raw HTML pages were about 19,000 characters long. The Markdown versions were about 4,200. The extra HTML was mostly markup the model did not need for most questions.
  • Markdown lost data that lives only in the HTML. This site shows each book's star rating as a CSS class, not as text. Markdown keeps text, so the rating disappeared. Markdown got 1 of 19 ratings right, and that one was a guess: the model answered "Five" for a page where the rating was not in its input.
  • Some "misses" were judgment calls. The two Markdown genre misses were books the store files under a placeholder category called "Add a comment." The model answered "Books" instead. A person might have done the same.
  • Field-only JSON said "not available" instead of guessing. For all 57 questions outside the schema, the model said the information was not in the page content. It did not invent a single UPC, genre or rating.
  • Response times were about the same. The average response took between 1.76 and 1.84 seconds for all three versions. On this sample, smaller prompts cut cost, not waiting time.

The failed fetch

For one book, the Markdown fetch returned a browser error page instead of the book. The model correctly said the page showed an error and gave no answers. That one page caused 6 more Markdown misses, and its error text was longer than any real page. If we had not checked, Markdown would have looked worse than it was. That is why the check earlier in this guide matters.

How to use these results

  • For fixed jobs with known fields, field-only JSON gave the same answers at a tiny fraction of the tokens.
  • For open questions about page text, Markdown answered well at under a third of the HTML cost.
  • For data stored in attributes, like ratings in CSS classes, image alt text or link targets, check whether your smaller version still contains it. Here, only HTML did.

Limits of this test

This was 19 pages of one simple, uniform site, one model and one day. Real sites carry more scripts, menus and ads, so the gap between HTML and Markdown can be larger or smaller. Use these numbers as an example of the method, not as ratios to expect on your own pages.

How do you set a per-job token budget?

Set the budget from your measured sample, check each request against it before you send it and stop the job when it runs out.

Size the budget from your sample

Use a high percentile, not the average. A few long pages can blow through a budget set on the mean.

In our test, the counted input tokens per question looked like this:

Version Median 95th percentile Max
HTML 6,742 7,896 7,904
Markdown 1,906 2,471 2,479
Field-only JSON 102 133 136

With the failed page included, the Markdown maximum jumped to 9,989 tokens, because the error page was longer than any real one. A budget built on unchecked samples can be thrown off by a single bad fetch.

python
def per_page_cap(sample_counts: list[int], pct: float = 0.95) -> int:
    ordered = sorted(sample_counts)
    return ordered[min(len(ordered) - 1, int(len(ordered) * pct))]
 
 
def job_budget(pages: int, cap: int, margin: float = 0.2) -> int:
    return int(pages * cap * (1 + margin))

Enforce it on every request

Before each call, count the prompt. If one page is over the cap, fall back to a smaller version. If the job is out of budget, stop and report.

python
class BudgetExceeded(Exception):
    pass
 
 
def choose_version(page: dict, question: str, model: str, counter,
                   cap: int, remaining: int) -> tuple[str, int]:
    for version in ("markdown", "json"):
        prompt = f"Page ({version}):\n{page[version]}\n\nQuestion: {question}"
        n = counter(model, SYSTEM, prompt)
        if n <= cap and n <= remaining:
            return prompt, n
    raise BudgetExceeded(f"{page['url']} does not fit in the remaining budget")

Two more limits help:

  • Cap the output. Set the provider's maximum output tokens for each request. Short answers need short limits.
  • Track actual usage. After each response, subtract the usage the provider reports, not your estimate. The two can differ slightly. On the extraction side, MrScraper's tokenCap request field caps the MrScraper tokens one request and its retries can use. Your job budget covers the LLM side.

Hand off to your application

Save three things with every job: the version sent for each page, the counted tokens and the provider's reported usage. When costs change, you can see whether pages got longer, the model changed or the budget rules picked a different version.

FAQs

How do HTML, Markdown and field-only JSON differ?

HTML keeps everything on the page, including tags, scripts and styling. Markdown keeps the readable text and structure, like headings, lists and tables. Field-only JSON keeps only the fields in your schema. In our test, Markdown used about 72% fewer input tokens than HTML but lost data stored only in HTML attributes, so measure quality for each version on your own pages.

How do you set a per-job token budget?

Count tokens for a sample of pages with your model's own counter. Set a per-page cap from a high percentile, multiply by the number of pages and add a margin. Check every request before sending it, fall back to a smaller version when needed and stop when the budget runs out.

Which parts does MrScraper provide, and which parts must my application implement?

MrScraper is a developer-focused web data API. It returns each page as raw HTML or Markdown, and extracts field-only JSON from a prompt and schema. Scrape Markdown and Scrape JSON responses report MrScraper's own token usage, and tokenCap limits it per request.

Your application handles the LLM side. This includes token counting with your provider's tools, the test set, scoring, cost math, budget rules and fallbacks. Your team also decides which pages it has permission to use.

Summarize this post

Open it in your assistant of choice with the prompt ready to send.

Take a Taste of Easy Scraping!

Your choices

Cookie preferences

Necessary cookies keep your selection. Optional categories are disabled until you switch them on.

Strictly necessary

Remembers your privacy selection and keeps the site working.

Always on