How to Build Smart, Fast & Resilient Web Scrapers for Dynamic Websites?
- Shahana farvin
- Sep 10
- 16 min read
Updated: Nov 10

Scraping dynamic websites can be tricky, content often loads via JavaScript after the initial page render, making traditional HTML parsing useless. In this guide, you’ll learn exactly how to build smart, fast, and bot-resistant web scrapers for dynamic websites, with real examples from Datahut’s scraping projects.
When I first started web scraping, I thought it would be simple — send a request, get the HTML, and extract what I need. But then I came across dynamic websites. These sites didn’t give me all the data in one go. Some content, like product reviews or ratings, would only load after scrolling or clicking. That’s when I realized scraping dynamic websites is a whole different game.
What is a Dynamic Website?
Dynamic websites are pages that load some or most of their content only after the initial page is loaded — usually through JavaScript. This means if you just check "View Page Source," the content won’t be there. Instead, JavaScript runs in the browser, fetches data in the background, and updates the page.
To show this clearly, I took an example from Blinkit’s. When JavaScript is enabled, product listings appear and load as you scroll down the page. But when JavaScript is disabled, the page looks almost empty — no product data is visible, and scrolling doesn't load any new items.
Here, I’ve added screenshots showing the difference:One with JavaScript enabled (you can see the products) and other with JavaScript disabled (only the header and layout are visible, products are missing)

This kind of behaviour confirms that we’re dealing with a dynamic, scroll-triggered loading website — and we’ll need to use browser automation (like Playwright or Selenium) with smart scrolling strategies to handle it properly.
Over time, after scraping a variety of dynamic websites, I’ve picked up a bunch of techniques and tricks that make the scraping process smoother, faster, and less error-prone. This isn’t a theoretical guide. These are all from real scraping experiences — what worked for me, and why I keep using them. Here's how I write smart and efficient scrapers for modern dynamic sites.
Use the Right Technology for Dynamic Websites
If you’re working with a dynamic website, one of the first and most important decisions is choosing the right tool.
Why not use requests or BeautifulSoup directly? Because those tools only work with static HTML. Dynamic websites use JavaScript to load most of their content after the page initially opens — which requests and BeautifulSoup won’t see.
What can you use instead?
Playwright (Recommended): Can control a headless or visible browser, wait for JavaScript to load, click buttons, scroll, and more.
Selenium: Another browser automation library, great for beginners and widely supported.
Puppeteer: Node.js-based automation library like Playwright.
Splash (for Scrapy users): Lightweight headless browser with a Lua scripting interface.
But personally, I stick with Playwright for most of my dynamic scraping needs — it’s fast, reliable, and supports async code well
Example Code: Playwright Setup
from playwright.async_api import async_playwright
import asyncio
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True) # use False during debugging
page = await browser.new_page()
await page.goto("https://example-dynamic-site.com", wait_until="networkidle")
await page.wait_for_selector(".target-element") # wait for specific content
html = await page.content() # for parsing via BeautifulSoup
await browser.close()
asyncio.run(main())Splitting the Task into Two Phases
This is one of the first things I learned after scraping a couple of dynamic websites. Trying to do everything — from collecting links to scraping details — in one go is messy and hard to fix if something goes wrong.
That’s why I always split my work into two clear scripts:
Phase 1: A script just to collect all product or listing URLs.
Phase 2: A separate script to open each of those URLs and extract full product details.
This structure helped me debug things faster and avoid unnecessary repetition. Also, if scraping gets interrupted, I don’t lose everything.
Using Async/Await for Concurrency
When I first started scraping dynamic websites, I opened one page, waited for it to load fully, scraped the content, and then moved to the next. But I noticed this took too long — especially when I had 100s of product pages to scrape.
That’s when I learned about async and await. These two helped me open and scrape multiple pages at the same time, without waiting one-by-one.
But let me be honest — this doesn't mean you can open 50 pages at once! That will crash your browser or slow down everything. From my experience, it’s best to limit the scraper to open only 3–4 pages at a time to keep things stable and safe.
How I Run 3–4 Pages at a Time Safely
I use asyncio.gather() to scrape multiple pages at once, but in small batches. That keeps memory usage low and prevents crashes.
from playwright.async_api import async_playwright
import asyncio
product_urls = [
"https://example.com/product1",
"https://example.com/product2",
"https://example.com/product3",
"https://example.com/product4",
"https://example.com/product5"
]
async def scrape_page(playwright, url):
browser = await playwright.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto(url)
await page.wait_for_selector("h1.product-title")
title = await page.text_content("h1.product-title")
print(f"Scraped: {title}")
await browser.close()
async def main():
async with async_playwright() as playwright:
for i in range(0, len(product_urls), 3): # Run 3 at a time
batch = product_urls[i:i+3]
tasks = [scrape_page(playwright, url) for url in batch]
await asyncio.gather(*tasks)
asyncio.run(main())What await Really Does
This is the part that clicked for me: await pauses that line until the browser finishes that task — like loading a page, finding an element, or clicking a button. It doesn't block the whole program, just that one function. So other parts can continue running in the background. That’s what makes it non-blocking and perfect for automation or scraping multiple pages smoothly.Everywhere I need to wait for something to finish — like:
await page.goto(url)
await page.wait_for_selector("div.item")
await page.text_content("h1.title")
await page.screenshot(path="item.png")
await browser.close()Without await, the code will try to scrape content before it even appears — and then it fails or scrapes nothing.await is your best friend when scraping dynamic websites.
Smart Scrolling (Full, Section, or Incremental)
Many dynamic websites don’t load all products or content at once. Instead, they show more items only when we scroll down. So just visiting the page won’t be enough — we need to scroll like a real user would.
At first, I didn’t know this. I used to go to the page and see only 10 items, but I knew there were more. When I manually scrolled the page in the browser, the rest loaded. That’s when I realized I had to add scrolling logic in my script.
Based on how the site is built, I use different scrolling methods. Here’s how I decide what to use:
1. Full Page Scroll (scroll to the bottom all at once)
This is the simplest one. Some websites load everything once we scroll to the bottom. I just scroll to the bottom and wait for the content to load.
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(2) # Wait for items to loadThis worked for sites that dump all content when you reach the bottom — no step-by-step needed.
2. Section Scroll (scroll inside a particular box or div)
Some websites use a scrollable box inside the page — not the full page scroll. This confused me at first. I scrolled the whole page and nothing happened. Then I noticed that the products were inside a container with its own scrollbar.
In that case, I scroll that specific element.
await page.evaluate('''
document.querySelector("div.scrollable-section").scrollTop =
document.querySelector("div.scrollable-section").scrollHeight;
''')
await asyncio.sleep(2)Always inspect the page and find the class or ID of the scrollable container.
3. Incremental Scroll (scroll step-by-step)
This one is super useful when the website loads more items only after small scrolls — like lazy loading.
Instead of jumping to the bottom, I scroll down little by little in a loop, giving time for new content to load at each step.
for _ in range(10):
await page.evaluate("window.scrollBy(0, 1000)")
await asyncio.sleep(1) # Wait after each scrollI use this when the content loads slowly or in parts. This one gives the most control.
Waiting for Selectors Instead of Just Sleeping
When I first started automating dynamic websites, I used to add time.sleep(5) thinking that the page would load in that time. But I soon realized — just sleeping doesn’t mean the page is ready.
Sometimes 5 seconds is too much, sometimes too little. And the scraper would either crash or miss data.
So instead of using a fixed time, I started using smart waits — where the script waits for a specific element to appear before moving forward. That’s how I know the content I need is actually loaded.
await page.wait_for_selector("div.review-block")This tells the browser: "Wait here until the review section shows up."
This is much better than guessing how long to wait. It only moves forward when that part of the page is really visible.
Also: Wait Until Network Becomes Idle
Sometimes I want to be extra sure the page has loaded — for that i use Playwright’s wait_until="networkidle" option. It waits until there’s no more network activity for a while.
await page.goto(url, wait_until="networkidle")This is like saying: "Go to this URL, but don’t continue until everything in the background is calm and finished."
Anti-Bot Detection and How I Avoid It
When I started scraping dynamic websites, I thought just loading the page and extracting data would be enough. But very soon, I began facing strange issues — sometimes data was missing, sometimes the site would load differently, and sometimes I’d get blocked completely. That’s when I understood that these websites actively try to detect bots.
They look for small signs — like repeating patterns, strange browser behavior, or too many visits from the same IP — and if something feels off, they stop you right there.
So, through trial and error, I figured out a bunch of methods that helped me avoid detection and make my scraper behave more like a real human user. Here’s what I do:
User Agent Rotation
At first, I didn’t think much about headers or how my scraper “looked” to the site. But after a few runs, I started seeing blocks, empty data, or strange redirects. That’s when I came to know about User-Agent strings.
A User-Agent is just a line in the browser request that tells the site what kind of device or browser you're using. For example, it might say "I’m a Chrome browser on Windows" or "I’m Safari on an iPhone." Websites use this to understand who is visiting.
If we keep using the same User-Agent for every request, it becomes obvious that it’s a bot doing the job. That’s risky because many sites block such patterns.
So I created a simple text file called useragents.txt, where I pasted over 100 different User-Agent strings — taken from actual browsers, mobiles, and operating systems. During scraping, my code picks one randomly for each page visit.
This randomness helps my scraper behave like different users — as if people are visiting from different devices. It reduces the chances of getting blocked or flagged.
Here’s the short code I use:
import random
with open("useragents.txt") as f:
USER_AGENTS = f.read().splitlines()
headers = {"User-Agent": random.choice(USER_AGENTS)}This tiny change actually helped me a lot while scraping dynamic websites. It’s like a small disguise for my scraper — not foolproof, but definitely useful when combined with other smart methods.
Adding Realistic Headers from DevTools
Even after setting the User-Agent, some websites didn’t behave normally. Then I realized — the browser usually sends extra information in the background, called headers. These headers tell the website what kind of browser is being used, where the request is coming from, and more.
That’s when I started checking the Network tab in Chrome DevTools or Postman, just to copy the same headers that a real browser sends. After I added them to my Playwright browser context or requests inside the script, it worked much better — the page loaded properly, fewer errors, and less blocking.
This made a big difference. The website started responding like it would for a regular user.
headers = {
"accept": "text/html,application/xhtml+xml",
"user-agent": random.choice(USER_AGENTS),
"referer": "https://example.com"
}
context = await browser.new_context(extra_http_headers=headers)With this, Playwright acts more natural, just like a normal browser.
Random Delay Between Requests
In the beginning, I used to send requests one after the other — super fast. At first, it felt cool that my scraper could go through 50 pages in seconds. But soon, I started facing issues: some requests would return empty, or worse, the site would block me completely.
Then I realized that humans don’t browse like that. No one clicks every link within milliseconds. So, it became clear — I had to slow my scraper down a bit and make it behave more like a real person.
The solution? Add random delays between each page visit or scraping task. Not a fixed sleep like await asyncio.sleep(2) everywhere — that’s still a pattern. Instead, I use a random time gap, like 5 to 10 seconds, for every request.
import random, asyncio
await asyncio.sleep(random.uniform(5,10))This makes each request take a slightly different amount of time. It’s not only safer but also more natural. From my experience, this simple trick reduced my chances of getting blocked and gave my scrapers a longer life.
So now, I always include random delays as a part of my scraping routine — especially for dynamic websites that load content using JavaScript.
Headless=False During Development or Detection
Some websites behave differently when they know the browser is in headless mode — meaning it runs in the background with no window. At first, I didn’t understand why data was missing or why the page looked broken, even though it worked fine when I visited it manually.
Later, I figured out that some websites detect if the browser is headless and either block the content or behave differently on purpose. So, when I face such issues, I just change headless=False in my code to open the browser in visible mode. This way, the browser behaves more like a real user and most of the problems go away.
Using headless=False is also super helpful during development. I can actually see what the browser is doing, whether the page is loading properly, and where the scraper is clicking or scrolling.
So now, especially while testing or when a site is blocking me in headless mode, I always switch to headless=False. It has helped me fix a lot of unexpected bugs during scraping.
Geolocation Settings for Area-Specific Content
Sometimes when I scrape websites like Blinkit or food delivery sites, I notice that they show different products or shops based on my location. This is because they use geolocation to decide what to display. If the browser doesn’t share a proper location, they may show errors or not load anything useful.
So what I do is set a fake location (like Mumbai or Delhi) using Playwright’s built-in geolocation feature. This makes the browser think I'm visiting from that place, and the site loads data just like it would for a real user in that city.
context = await browser.new_context(
geolocation={"longitude": 77.2090, "latitude": 28.6139},
permissions=["geolocation"]
)In this example, I set the geolocation to Delhi’s coordinates. If I wanted to set it to Mumbai, I’d just change the latitude and longitude.
This trick has helped me many times when a website refused to load or showed a "service not available in your area" message. Setting the location properly gives better control and makes the scraper behave more like a real local user.
Proxies to Avoid IP Bans or Region Restrictions
Even after doing everything else right, some sites block my IP address if I scrape too many pages or sometimes the first page itself.
That’s where proxies help. A proxy acts like a middleman. Instead of your real IP reaching the website, the proxy’s IP is used — so the website thinks it’s a different user.
I mostly use rotating proxies, which means my scraper switches IP addresses automatically after each request or after a few. This gives each visit a new identity, reducing the chance of getting blocked.
Some websites also show different content based on your region. For example, a product or service might only be available in Bangalore but not in Delhi. In that case, I use a region-based proxy, which lets me pretend I’m browsing from that specific location.
Using proxies has saved me many times — especially when scraping large websites where too many visits from the same IP would result in blocks.
browser = await playwright.chromium.launch(
proxy={"server": "http://your-proxy-ip:port"}, headless=False
)With proxies, I can scrape more safely without worrying about IP bans.
Shipping Pincode Set for Local Stores
For websites like Blinkit, BigBasket, or any online grocery or delivery service, I noticed that the products, prices, and even whether something is available — all depend on the shipping pincode. If the pincode is not set properly, the website either shows limited content or nothing at all.
So before scraping any data, I first check if there’s a popup asking for the pincode, or if there’s a small input box on the page where I can enter it. Using Playwright, I automate this step just like a normal user: type the pincode and press Enter or click the submit button.
Once the location is set, the entire page refreshes and shows data that’s specific to that area — exactly what a real user from that region would see. Only after this step do I continue scraping the product or price details.
Here’s a small example of how I do it in Playwright:
await page.goto("https://www.blinkit.com")
await page.click("input[placeholder='Enter your PIN code']") # or use actual selector
await page.fill("input[placeholder='Enter your PIN code']", "600001") # Example pincode
await page.keyboard.press("Enter")
await asyncio.sleep(3) # wait for page to reloadI always make sure the location is fully set before scraping — otherwise, I might collect wrong or incomplete data. This trick is especially useful when a client or project needs data from a specific city or area.
Stealth Plugins to Hide Automation
Even after all these steps, some sites still figured out that I was using automation. That’s because Playwright (and tools like it) leave small signs — like the navigator.webdriver flag — which websites can detect.
To fix this, I started using stealth plugins like playwright-stealth. These remove those signs and tweak the browser to behave like a real human-controlled one. After using stealth settings, I saw a huge improvement. No more blank pages or errors due to automation detection.
Some Additional Practices for Structuring and Writing Smart Scrapers
Use BeautifulSoup for Parsing: After the page loads using Playwright, I grab the HTML with page.content() and parse it using BeautifulSoup because it's simple and flexible.
Break the Code into Functions:When the script gets long, I split it into small functions like get_price(), get_title(), etc., which makes things cleaner and reusable.
Save One Product at a Time: Instead of saving all data at the end, I insert product info one by one as soon as it's scraped to avoid memory loss or crashes.
Use INSERT OR IGNORE for Duplicates:To avoid inserting the same product twice, I use INSERT OR IGNORE in my SQL queries — it keeps the database clean.
Add Full Error Handling:I wrap the whole scraping logic in try-except blocks and log or save the failed URLs separately so I can retry them later.
Enable Logging for Everything:I log each step — scraping success, skips, failures — so I can easily trace bugs without having to rerun everything.
Use a Scraped Status Column:In the database, I keep a scraped column and mark URLs as done after scraping, so I can restart from where I left off.
Reuse Browser Sessions and Tabs:Instead of opening a new browser each time, I open the browser once and reuse tabs to save both time and system resources.
Close Pages and Free Resources:After scraping each product, I close the page and clear resources — this avoids memory leaks and keeps things running smoothly.
Avoid Repeating Code — Reuse Everything:Whether it's selectors, headers, or parsing logic, I reuse everything by keeping them in helper functions or separate modules.
Common Mistakes to Avoid When Scraping Dynamic Websites
Even with all the techniques and best practices above, there are a few common pitfalls that many beginners (and sometimes even experienced scrapers) fall into. These mistakes can slow down your scraper, cause errors, or even get you blocked. Here’s what to watch out for:
1. Overloading the Browser with Too Many Pages at Once
One of the first mistakes I made when I started scraping dynamic sites was opening too many pages at the same time. At first, it seemed efficient — more pages, faster scraping, right? Not really. The browser slows down, memory usage spikes, and sometimes it crashes completely. Always stick to small batches (3–4 pages at a time) and use async/await to manage concurrency safely.
2. Using Fixed sleep() Instead of Smart Waits
Relying on time.sleep() or await asyncio.sleep() for fixed durations is tempting, but it’s unreliable. Pages load at different speeds depending on network conditions or server response times. This can cause missed elements or partial scraping. Instead, always wait for specific elements with await page.wait_for_selector() or use wait_until="networkidle" to make sure the page is fully ready before extracting data.
3. Ignoring Geolocation or Pincode
Requirements Dynamic websites, especially e-commerce or delivery platforms, often show content based on your location. Skipping the geolocation or pincode step can result in missing data or incorrect product listings. Always check if the site requires a location, and automate that step to make sure you scrape what a real user in that area would see.
4. Forgetting to Rotate User-Agents and Headers
Using the same User-Agent for every request or not mimicking real browser headers makes your scraper easy to detect. Many sites block repeated requests or serve different content. Randomizing User-Agents and copying realistic headers from DevTools can save your scraper from unnecessary blocks.
5. Not Handling Scrollable Sections or Lazy Loading Properly
A lot of dynamic websites use infinite scroll or scrollable containers to load content incrementally. Simply opening the page won’t give you all the data. Missing this step is a common mistake. Inspect the page carefully, choose the right scroll method (full, section, or incremental), and give the page time to load new content between scrolls.
6. Not Saving Data Incrementally
Another mistake I often see is waiting to save all data at the end. If your scraper crashes midway, you lose everything. Save product data one by one, use INSERT OR IGNORE for duplicates, and keep track of scraped URLs with a status column. This makes your scraper more resilient and easy to resume.
7. Overlooking Anti-Bot Measures
Many scrapers ignore proxies, random delays, or stealth plugins. The result? Blocks, empty responses, or CAPTCHA challenges. Incorporate these preventive measures from the start — random delays, headless=False during testing, proxies, and stealth plugins help your scraper act like a real human user and keep it running longer.
By keeping these common mistakes in mind, you can save yourself hours of frustration and make your dynamic scrapers more efficient, reliable, and resilient.
Conclusion
Scraping dynamic websites isn’t just about writing code — it’s about building scrapers that think and act like real users. From handling JavaScript rendering and infinite scrolling to rotating user-agents, setting geolocation, and adding smart delays, every detail makes the difference between a scraper that breaks quickly and one that runs smoothly for months.
The key is to stay smart, fast, and resilient:
Smart by using the right tools (like Playwright) and strategies (async/await, modular design).
Fast by optimizing concurrency and avoiding unnecessary waits.
Resilient by preparing for anti-bot measures, website changes, and unexpected failures.
If you follow these practices, you’ll be able to build scrapers that not only extract the data you need but also adapt to the constantly evolving web.
At Datahut, we’ve applied these exact methods across hundreds of projects — proving that with the right approach, even the most complex dynamic websites can be scraped reliably.
FAQs
Q1. What makes scraping dynamic websites more challenging than static ones?
A: Unlike static websites where content is directly available in the HTML, dynamic websites load content using JavaScript, AJAX, or APIs. This requires techniques like headless browsing, JavaScript rendering, or API calls to reliably extract data.
Q2. How can I make my web scraper faster?
A: You can optimize speed by using asynchronous requests, efficient libraries (like Scrapy or Playwright), rotating proxies, caching repeated requests, and reducing unnecessary page loads.
Q3. What strategies help make a scraper resilient against website changes?
A: To handle frequent website updates, use robust CSS/XPath selectors, leverage APIs when available, build modular scraper code, monitor for changes, and implement fallback mechanisms.
Q4. How can I avoid IP blocking while scraping dynamic sites?
A: Use techniques like IP rotation, proxy pools, user-agent rotation, request throttling, and respecting robots.txt to minimize the risk of being blocked.
Q5. Which tools or frameworks are best for scraping dynamic websites?
A: Popular choices include Playwright, Selenium, Puppeteer for handling JavaScript-heavy pages, and Scrapy, Beautiful Soup, Requests for efficiency. The best tool depends on the complexity of the website and the scraping requirements.