🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Guides

Web Scraping with R and rvest

Web scraping with R done right: fetch through proxies with httr2, parse HTML using rvest html_elements and html_table, and bind clean, rectangular data frames.

S SparkProxy 0 18 min read
Share
Web Scraping with R and rvest

Web scraping with R has one advantage no other language matches: the data you pull lands one function call away from a tibble, a model, or a ggplot. The catch is that rvest's read_html() gives you nowhere to attach a proxy, custom headers, or a retry, so the moment a target rate-limits you or hides behind JavaScript, the naive tutorial code stops working. This guide wires rvest to httr2 for proxy support, shows the html_element() trick that keeps your data frames rectangular, and hands JavaScript-heavy pages to a scraping API so the R you write stays parse-and-analyze.

Why R for Web Scraping?

Python owns the scraping conversation, so why write a scraper in R at all? The answer is what happens after the fetch. In R, scraped HTML becomes a tibble with one call, and that tibble is already sitting in the same environment as dplyr, ggplot2, and every statistical model you'd want to run on it. There's no export step, no handoff to a separate analysis stack. If the whole point of the scrape is a chart, a regression, or a cleaned dataset, R keeps the loop tight.

The tooling is mature. rvest wraps xml2 and gives you jQuery-style CSS selectors; html_table() reads an HTML table directly into a data frame; the tidyverse pipe (|>) chains selection and cleaning into one readable expression. If you're new to the practice itself, start with what web scraping is and come back.

The tradeoffs are honest. R is single-threaded by default, so high-concurrency crawls need extra machinery that Python and Rust ship with. rvest reads only the initial HTML, so JavaScript-rendered content needs help (covered below). And read_html() has no proxy argument, which is the wall almost everyone hits first.

Here are the packages that do the work:

PackageRoleNotes
`rvest`HTML parsing + CSS/XPath selectorsWraps xml2; `html_table()`, `html_text2()`, sessions, forms
`xml2`Underlying parserrvest is built on it; you rarely call it directly
`httr2`HTTP clientModern, pipeable, supports `req_proxy()` and retries
`httr`HTTP client (older)`use_proxy()`; still what rvest sessions use internally
`polite`robots.txt + rate limiting`bow()` and `scrape()`; the courteous default
`ratelimitr`Rate limitingWrap any function with a calls-per-period cap
`dplyr` / `tibble`Data framesWhere your scraped rows land

Install the Toolkit

Install the core packages once. These versions are current as of August 2026:

install.packages(c("rvest", "httr2", "polite", "dplyr"))
# rvest 1.0.4, httr2 1.0.x, polite 0.1.3, dplyr 1.1.x

The smallest possible scrape confirms the install works. Read a page, select the headings, pull clean text:

library(rvest)

page <- read_html("https://www.sparkproxy.io/")

page |>
  html_elements("h2") |>
  html_text2()

read_html() fetches and parses in one call, which is convenient for a hello-world and a trap for anything real, because there is no place in that call to set a proxy, a header, or a timeout. Hold that thought. The proxy section is where it matters.


Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Read and Parse HTML with rvest

Four functions cover almost every parse. Learn the difference between the singular and plural selectors early, because it decides whether your data frames come out rectangular.

  • html_elements(css) returns every node that matches, flattened into one vector.
  • html_element(css) returns one node per input node, inserting NA where a match is missing. This is what keeps columns aligned.
  • html_text2() extracts text the way a browser renders it: collapsed whitespace,
    treated as a line break.
  • html_attr(name) reads an attribute such as href or src.
library(rvest)

page <- read_html("https://www.sparkproxy.io/blog")

# All article titles on the page
titles <- page |>
  html_elements("article h2 a") |>
  html_text2()

# The href of each of those same links
links <- page |>
  html_elements("article h2 a") |>
  html_attr("href")

Reach for html_text2() over the older html_text() by default. html_text() returns the raw text nodes exactly as they sit in the source, newlines and indentation included, so you get strings like "\n Pricing\n ". html_text2() normalizes that to "Pricing". The only time to prefer html_text() is a huge page where you'll clean the strings yourself anyway and want the speed, since html_text2() does more work per node.

For attributes that may be absent, html_attr() returns NA rather than erroring, which is exactly what you want when you later assemble a data frame.


Extract Tables Straight to a Data Frame

This is R's party trick. If the data already lives in a

, html_table() reads it into a tibble in one line, with the header row promoted to column names and columns type-converted:

library(rvest)

tables <- read_html("https://www.sparkproxy.io/pricing") |>
  html_elements("table")

# Pick the table you want, parse it
pricing <- tables[[1]] |>
  html_table()

pricing
#> # A tibble: 4 x 3
#>   plan        price_month  bandwidth
#>   <chr>       <chr>        <chr>
#> ...

html_table() takes a few arguments worth knowing. header = NA auto-detects the header row; set it TRUE or FALSE to force the behavior. convert = TRUE (the default) coerces numeric-looking columns to numbers. na.strings controls which cell values map to NA. When a page has several tables and you only want one, select first with a specific CSS path (html_element("#pricing table")) instead of grabbing them all and indexing.

If the numeric columns still come back as character (common when a currency symbol or thousands separator rides along), clean them after the fact:

library(dplyr)
library(readr)

pricing <- pricing |>
  mutate(price_month = parse_number(price_month))

Build a Rectangular Data Frame from Elements

Most pages don't hand you a clean

. You get a grid of cards, list items, or divs, and you have to assemble the data frame yourself. Here's the single most important rule in R scraping, and the one tutorials skip: select the repeating record first with html_elements(), then pull each field with html_element() (singular) so missing fields become NA and every column stays the same length.

library(rvest)
library(dplyr)

page <- read_html("https://www.sparkproxy.io/products")

# 1. One node per record
cards <- page |> html_elements(".product-card")

# 2. One field per record: html_element() (singular) fills NA on a miss
products <- tibble(
  name  = cards |> html_element(".title") |> html_text2(),
  price = cards |> html_element(".price") |> html_text2(),
  url   = cards |> html_element("a")      |> html_attr("href")
)

products

Why the singular form matters: say one card is missing its .price. If you wrote cards |> html_elements(".price"), you'd get back only the prices that exist, one short, and tibble() would throw arguments imply differing number of rows. html_element() returns exactly one result per card, NA included, so all three vectors have the same length and the rows line up with their names. That NA is a feature. It marks the gap instead of silently shifting every price up a row.

To crawl many pages, wrap the per-page logic in a function and combine the results. purrr::list_rbind() stacks the tibbles into one:

library(purrr)

scrape_page <- function(n) {
  url <- paste0("https://www.sparkproxy.io/products?page=", n)
  cards <- read_html(url) |> html_elements(".product-card")
  tibble(
    page  = n,
    name  = cards |> html_element(".title") |> html_text2(),
    price = cards |> html_element(".price") |> html_text2()
  )
}

all_products <- map(1:5, scrape_page) |> list_rbind()

That loop hits five URLs back to back with no throttle and no proxy. Both problems get fixed next.


Route rvest Through a Proxy

Here's the gotcha the intro promised. read_html("https://...") has no proxy parameter, no header parameter, and no timeout. There is nowhere to hang them. The fix is to split the job: fetch with httr2 (which supports proxies), then parse the response body with rvest. resp_body_html() returns the same xml2 document that read_html() would, so every rvest selector works on it unchanged.

library(httr2)
library(rvest)

resp <- request("https://www.sparkproxy.io/") |>
  req_proxy(
    url      = "http://gate.sparkproxy.io",
    port     = 10000,
    username = "your-username",
    password = "your-password"
  ) |>
  req_user_agent("research-bot/1.0 (you@sparkproxy.io)") |>
  req_perform()

# Hand the fetched body to rvest
page   <- resp |> resp_body_html()
titles <- page |> html_elements("h2") |> html_text2()

Verify the proxy actually took effect before you trust a whole crawl to it. Route an IP echo endpoint through the same proxy and confirm the address is the proxy's, not your server's:

ip <- request("https://api.ipify.org") |>
  req_proxy("http://gate.sparkproxy.io", port = 10000,
            username = "your-username", password = "your-password") |>
  req_perform() |>
  resp_body_string()

cat("egress IP:", ip, "\n")

If that prints your real server IP, the proxy silently failed and every request is exposing your origin. Fix it before you scale.

Prefer the older httr stack? use_proxy() does the same job, and rvest can parse an httr response directly:

library(httr)
library(rvest)

resp <- GET(
  "https://www.sparkproxy.io/",
  use_proxy("gate.sparkproxy.io", port = 10000,
            username = "your-username", password = "your-password"),
  user_agent("research-bot/1.0")
)

page <- read_html(content(resp, as = "text"))

Use the http:// scheme for the proxy URL even when the target is HTTPS. The proxy opens a CONNECT tunnel and the tunnel carries the encrypted traffic. Rotating IPs, pool sizing, and per-IP request limits are their own topic; the guide on using datacenter proxies for web scraping covers the math.


Sessions and Forms: Log In and Paginate

Some data sits behind a login or a search form. rvest's session() holds a cookie jar across requests, so you authenticate once and reuse the session. The useful detail almost no tutorial mentions: session() passes its extra arguments straight to httr::GET(), which means you can inject a proxy and a user agent into the session the same way you would a plain request.

library(rvest)

# The proxy config rides along into every request this session makes
s <- session(
  "https://www.sparkproxy.io/login",
  httr::use_proxy("gate.sparkproxy.io", 10000,
                  username = "your-username", password = "your-password"),
  httr::user_agent("research-bot/1.0")
)

# Grab the login form, fill it, submit it
form <- html_form(s)[[1]]

filled <- html_form_set(form,
  email    = "you@sparkproxy.io",
  password = "your-password"
)

s <- session_submit(s, filled)

After the submit, the session carries the auth cookie. Now navigate authenticated pages with session_jump_to() and parse them with the same selectors:

s <- session_jump_to(s, "https://www.sparkproxy.io/dashboard/orders")

orders <- s |>
  html_element("table") |>
  html_table()

A form with more than one submit button needs a hint. Pass submit = to session_submit() with the name of the control you want, otherwise rvest uses the first one and you may trigger the wrong action. For plain pagination that uses URL query parameters (?page=2), skip the form machinery entirely and loop over the URLs as shown in the data frame section. Sessions are for state you can't express in a URL.


Throttle Politely with polite and ratelimitr

Hammering a site is how you get blocked and how you get your proxy pool burned. Two packages make good behavior the default.

polite bundles the whole etiquette layer: it reads robots.txt, declares a user agent, enforces a delay between requests, and caches responses so a re-run doesn't re-fetch. bow() introduces you to the host, scrape() fetches within the rules:

library(polite)

host <- bow(
  "https://www.sparkproxy.io/",
  user_agent = "research-bot (you@sparkproxy.io)",
  delay = 5          # seconds between requests
)

result <- scrape(host)   # obeys robots.txt and the delay

bow() returns a session object; use nod() to move to another path on the same host without re-reading robots.txt every time. If robots.txt disallows the path, scrape() warns and returns NULL instead of fetching, which is the behavior you want by default.

ratelimitr is lower level and composes with anything. It wraps a function so it can't be called more than N times per period, sleeping if you go over:

library(ratelimitr)
library(rvest)

# Cap our fetcher at 20 requests per minute
limited_fetch <- limit_rate(
  function(url) read_html(url),
  rate(n = 20, period = 60)
)

pages <- lapply(urls, limited_fetch)

Reach for polite when you want the full courteous default on a single host. Reach for ratelimitr when you need a hard calls-per-second ceiling across a custom fetch function, for example one that already routes through httr2 and a proxy. If blocks persist even with sane delays and rotation, the cause is usually headers or fingerprint, not rate; how to avoid getting your proxy blocked walks through the fixes.


JavaScript Pages: the SparkProxy Scraping API from R

rvest reads the HTML the server sends. If a page builds its content with JavaScript after load, that content is not in the HTML rvest sees, and your selectors come back empty. You can drive a headless browser from R, but it's heavy. The lighter path is to hand the URL to a scraping API that renders the page server-side, solves the proxy and fingerprint problem, and returns finished HTML that drops straight into rvest.

The SparkProxy Scraping API is an ordinary httr2 request. The base endpoint is https://scrape.sparkproxy.io/api/v1, you authenticate with the X-API-Key header, and you pass the target URL and options as query parameters. resp_body_html() parses the rendered result for rvest:

library(httr2)
library(rvest)

scrape_via_api <- function(target,
                           api_key = Sys.getenv("SPARKPROXY_API_KEY")) {
  request("https://scrape.sparkproxy.io/api/v1") |>
    req_headers(`X-API-Key` = api_key) |>
    req_url_query(
      url           = target,
      render_js     = "true",   # headless Chromium renders the page
      premium_proxy = "true",   # route through residential IPs
      country_code  = "us"      # geo-target the request
    ) |>
    req_perform() |>
    resp_body_html()
}

page   <- scrape_via_api("https://www.sparkproxy.io/")
prices <- page |> html_elements(".price") |> html_text2()

The parameters map to the controls you'd otherwise build yourself:

ParameterTypeWhat it does
`url`stringThe target URL to fetch (required)
`render_js`booleanRuns headless Chromium so JavaScript-built pages return full HTML
`premium_proxy`booleanRoutes through residential IPs for hard targets
`country_code`stringISO 3166-1 alpha-2 code for geo-targeting, for example `us` or `de`
`json_response`booleanWraps the result in a JSON envelope with status, timing, and credits

Set json_response=true when you want metadata alongside the HTML. The envelope's body field holds the page HTML, which you parse with read_html():

library(httr2)
library(rvest)

env <- request("https://scrape.sparkproxy.io/api/v1") |>
  req_headers(`X-API-Key` = Sys.getenv("SPARKPROXY_API_KEY")) |>
  req_url_query(
    url           = "https://www.sparkproxy.io/",
    render_js     = "true",
    json_response = "true"
  ) |>
  req_perform() |>
  resp_body_json()

page    <- read_html(env$body)
status  <- env$status_code
credits <- env$credits_used

cat("status", status, "-", credits, "credits\n")

The API replaces the proxy config, the retry loop, and the fingerprint workarounds, so the R that's left is fetch and parse. If you're weighing building that machinery yourself against renting it, web scraping API vs self-managed proxies lays out the cost tradeoffs.


Common Errors and Fixes

Error or symptomCauseFix
Requests show your real IP, not the proxy`read_html(url)` has no proxy argumentFetch with `httr2::req_proxy()` (or `httr::use_proxy()`), then parse `resp_body_html()`
`arguments imply differing number of rows`Built a tibble with `html_elements()` (plural) per fieldSelect records first, pull each field with `html_element()` (singular) so misses become `NA`
Text full of `\n` and stray spacesUsed `html_text()` on messy HTMLUse `html_text2()`, which renders whitespace like a browser
Selectors return empty on a live pageContent is rendered by JavaScriptUse `render_js=true` via the Scraping API, or drive a headless browser
`403 Forbidden` on the first requestDefault R user agent or TLS fingerprint flaggedSet `req_user_agent()`, rotate proxies, or use the Scraping API
`html_table()` columns are all characterCurrency symbols or separators block conversionClean with `readr::parse_number()` after parsing
`Couldn't connect to server` through a proxyWrong scheme or bad host/port/credentialsUse `http://` for the proxy even on HTTPS targets; recheck host, port, and auth
`session_submit()` triggers the wrong actionForm has multiple submit buttonsPass `submit =` with the button's `name` to pick the right one

Frequently asked questions

FAQ

Yes, especially when analysis is the goal. rvest reads HTML into tibbles that dplyr, ggplot2, and your models can use immediately, with no export step. R is single-threaded by default, so very high-concurrency crawls need extra tooling that Python or Rust ship with, but for research, reporting, and data collection that ends in a chart or a model, R is a strong fit.

rvest's read_html() has no proxy argument, so you fetch through a proxy with a separate HTTP client and parse the result. Use httr2::request(url) |> req_proxy(url, port, username, password) |> req_perform() |> resp_body_html(), then run rvest selectors on that document. With the older httr stack, pass use_proxy() to GET() and hand the response to read_html().

Because read_html() does not accept proxy, header, or timeout settings at all. It fetches with a plain internal request and only exposes the URL. To route through a proxy you must fetch with httr2 or httr, which do support proxies, and then parse the returned body with rvest. Splitting the fetch from the parse is the standard pattern for any non-trivial scrape in R.

Select the table and call html_table(): read_html(url) |> html_element("table") |> html_table() returns a tibble with the header row as column names. It auto-converts numeric columns by default. If a page has several tables, target the one you want with a specific CSS selector first, and clean stubborn character columns with readr::parse_number().

html_elements() (plural) returns every matching node flattened into one vector. html_element() (singular) returns exactly one node per input node and inserts NA where a match is missing. Use the singular form when building a data frame: it keeps every column the same length so rows stay aligned, even when some records are missing a field.

rvest only sees the HTML the server sends, so JavaScript-built content comes back empty. Either drive a headless browser from R, or send the URL to a scraping API that renders the page server-side and returns finished HTML. With the SparkProxy Scraping API you call https://scrape.sparkproxy.io/api/v1 with render_js=true and parse the response with rvest, so no browser runs on your machine.


Limited-time · 50% off

Get 50% off your first purchase

Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.

Offer ends soon — claim it before it's gone

Claim Discount

About the Author

SparkProxy Technical Team is the engineering group behind SparkProxy's datacenter proxies, residential proxies, and managed Scraping API. We build and operate the infrastructure that powers large-scale data collection, and we write these guides from the same code paths our own systems run. This article was validated against rvest 1.0.4, httr2 1.0.x, polite 0.1.3, and dplyr 1.1.x on R 4.4 (August 2026).

Citations: rvest package documentation · httr2 package documentation · polite package documentation

Keep reading

Related articles