Most of us have shipped something that "worked on my machine" only to watch it fall apart in production. The frustrating part? Beginner projects tend to fail in the same areas: mobile UX, performance, accessibility, and security. These mistakes are predictable which means they're fixable.
This post walks through five critical pitfalls I see constantly, with real code examples and actionable fixes you can apply today.
Pitfall #1: Breaking Your Site on Mobile
Over 60% of web traffic comes from mobile devices, yet most beginners test exclusively on a large monitor with DevTools occasionally set to "iPhone" mode. The result: horizontal scrolling, cramped spacing, and buttons too small to tap accurately.
The Fix
Go mobile-first with fluid layouts and proper touch targets:
/* ✅ Mobile-friendly approach */
.container {
width: 100%;
max-width: 1200px;
padding: clamp(1rem, 5vw, 3rem); /* Scales between 16px and 48px */
margin: 0 auto;
}
.button {
padding: 12px 24px;
min-height: 44px; /* Apple HIG + WCAG 2.2 requirement */
min-width: 44px;
font-size: 1rem;
}
Checklist
- Test on real devices, not just DevTools
- Use
clamp()for responsive spacing - All touch targets should be minimum 44×44px
- Avoid fixed widths use
max-widthinstead - Check your layout at 320px, 768px, and 1440px
Pitfall #2: Shipping Slow Sites (Core Web Vitals Failures)
The three metrics that matter:
- LCP (Largest Contentful Paint): under 2.5s
- INP (Interaction to Next Paint): under 200ms
- CLS (Cumulative Layout Shift): under 0.1
Images without dimensions are a classic CLS killer, and blocking scripts tank LCP.
The Fix
Prevent layout shift with explicit dimensions:
<!-- ✅ Prevents CLS and optimizes loading -->
<img
src="hero.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
sizes="(max-width: 768px) 100vw, 1200px"
alt="Hero image"
width="1200"
height="630"
style="aspect-ratio: 1200 / 630;"
loading="lazy"
decoding="async"
>
Load non-critical scripts only when needed:
<!-- ✅ Load chat widget on first user interaction -->
<script>
const loadChatWidget = () => {
const script = document.createElement('script');
script.src = 'chat-widget.js';
script.defer = true;
document.body.appendChild(script);
};
document.addEventListener('mousemove', loadChatWidget, { once: true });
</script>
Stop importing entire libraries:
// ❌ Imports everything
import _ from 'lodash';
import moment from 'moment';
// ✅ Tree-shakeable
import { sum } from 'lodash-es';
// ✅ Use native APIs
const formatted = new Intl.DateTimeFormat('en-US').format(new Date());
Checklist
- Run Lighthouse before every deployment
- Always specify image dimensions
- Defer or async all non-critical scripts
- Code split large JavaScript bundles
- Monitor Core Web Vitals in Google Search Console
Last year I worked on a client project where the homepage CLS was 0.32 due to missing image dimensions. Fixing just three images dropped it to 0.05 and improved mobile engagement immediately.
Pitfall #3: Locking Out Users with Disabilities
Accessibility lawsuits are rising, but beyond legal risk you're genuinely locking out real users if your site isn't keyboard or screen reader friendly.
The Fix
Use semantic HTML with proper ARIA attributes:
<!-- ✅ Accessible form input -->
<div>
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid="true"
aria-describedby="email-error"
>
<span id="email-error" role="alert" style="color: #d32f2f;">
Please enter a valid email address in the format: name@example.com
</span>
</div>
Verify color contrast meets WCAG AA (4.5:1 ratio for body text):
/* ❌ Insufficient contrast (2.5:1) */
.text { color: #767676; background: #ffffff; }
/* ✅ WCAG AA compliant (4.6:1) */
.text { color: #595959; background: #ffffff; }
Make dropdowns keyboard-navigable:
function DropdownMenu() {
const [isOpen, setIsOpen] = useState(false);
const handleKeyDown = (e) => {
if (e.key === 'Escape') setIsOpen(false);
};
return (
<div onKeyDown={handleKeyDown}>
<button
aria-expanded={isOpen}
aria-haspopup="true"
onClick={() => setIsOpen(!isOpen)}
>
Menu
</button>
{isOpen && (
<ul role="menu">
<li role="menuitem"><a href="/profile">Profile</a></li>
<li role="menuitem"><a href="/settings">Settings</a></li>
</ul>
)}
</div>
);
}
Checklist
- Use semantic HTML (
<button>,<nav>,<main>,<article>) - Every form input needs an associated
<label> - Test with keyboard-only navigation
- Install
eslint-plugin-jsx-a11yto catch issues early - Test with NVDA (Windows) or VoiceOver (Mac)
Pitfall #4: Building Insecure APIs
The most common API vulnerability is BOLA Broken Object Level Authorization. It happens when an endpoint doesn't verify that the authenticated user actually owns the resource they're requesting.
// ❌ Anyone can access ANY order by changing the ID in the URL
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
res.json(order); // No ownership check!
});
The Fix
Always verify resource ownership:
// ✅ Ownership check
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
const order = await db.orders.findOne({
id: req.params.orderId,
userId: req.user.id // Critical
});
if (!order) return res.status(404).json({ error: 'Order not found' });
res.json(order);
});
Add rate limiting to prevent brute force:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/login', loginLimiter, async (req, res) => { ... });
Use short-lived tokens with secure storage:
// ✅ Short-lived access token + httpOnly refresh token
const accessToken = jwt.sign({ userId: user.id }, ACCESS_SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId: user.id }, REFRESH_SECRET, { expiresIn: '7d' });
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
});
res.json({ accessToken });
Checklist
- Verify resource ownership in every API endpoint
- Rate limit all public endpoints
- Use short-lived JWTs (15 minutes max)
- Store refresh tokens in httpOnly cookies
- Validate and sanitize all user inputs
Pitfall #5: Blindly Trusting AI-Generated Code
AI tools like Copilot and ChatGPT are genuinely useful but they generate code that looks correct while hiding security holes and edge-case bugs. Here's a real example:
// ❌ AI-generated file upload looks fine, has a critical vulnerability
app.post('/api/upload', (req, res) => {
const file = req.files.upload;
file.mv(`./uploads/${file.name}`); // Path traversal attack!
res.json({ success: true });
});
An attacker uploads a file named ../../../etc/passwd and you're in trouble.
The Fix
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
app.post('/api/upload', async (req, res) => {
const file = req.files?.upload;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
if (file.size > MAX_FILE_SIZE) return res.status(400).json({ error: 'File too large' });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(ext)) return res.status(400).json({ error: 'Invalid file type' });
// Generate safe filename prevents path traversal
const safeFilename = `${uuidv4()}${ext}`;
const uploadPath = path.join(__dirname, 'uploads', safeFilename);
await file.mv(uploadPath);
res.json({ filename: safeFilename });
});
Checklist
- Review AI-generated code line-by-line
- Test edge cases AI tends to miss
- Never accept code you don't fully understand
- Run security linters (
eslint-plugin-security) - Treat AI as an assistant, not a replacement for thinking
Your Action Plan for This Week
- Run a Lighthouse audit on your main pages fix anything below 90
- Install
eslint-plugin-jsx-a11yand resolve violations - Audit your API endpoints for missing authorization checks
- Review any AI-generated code from the past month
- Test your site on a real mobile device, not just DevTools
Wrapping Up
These pitfalls affect developers at every level. The difference is that experienced developers have systems to catch them before they reach production automated Lighthouse CI, security scanning in PRs, accessibility linting in the editor, real device testing in QA.
You don't need years of experience to build secure, accessible, performant websites. You just need to know what to look for and now you do.
If you want more content like this, the original and more posts are on my blog: Dharanidharan's Solopreneur Blog.
What's the worst web dev pitfall you've run into? Drop it in the comments I'd love to hear how you fixed it.
Top comments (0)