Open DevTools right now. Go to the Network tab, find the throttling dropdown, and set it to Slow 3G. Now reload something you built.
Watch the blank white screen sit there for a few seconds. Watch the layout jump around as fonts and images land out of order. Somewhere in the world, someone is looking at that exact screen on that exact connection, right now, and they don't have a throttling dropdown to turn back off.
"Optimize later" is one of the most common plans in web development, and it's also one of the most misleading. It suggests performance is a separate phase, something you schedule after the feature is done, the way you'd schedule a coat of paint after the walls go up. In practice it rarely gets scheduled at all. The feature ships, the next feature starts, and the page just keeps getting a little heavier, a little slower, until a Lighthouse score forces the conversation nobody planned for.
The developers who never have that conversation aren't smarter or more disciplined in some abstract way. They just built a handful of habits into how they write code every day, so performance was never a separate phase to schedule in the first place. None of what follows costs extra time once it's automatic. Most of it saves time, because catching a heavy dependency while you're still deciding whether to add it is faster than ripping it out after six other things depend on it.
Here are the seven that stuck.
1. Check what the page already ships before adding to it
For a long time my instinct was to reach for whatever solved the immediate problem. Need a date picker, install a date picker. Need icons, install an icon library. The Network tab was something I opened when something was already slow, not before I made it slower.
Now it's the other way around. Before adding anything, I check what's already going out over the wire.
DevTools → Network tab → reload → look at the size column at the bottom
Total: 847 KB transferred, 2.1 MB resources
That number is the actual starting condition for anything I'm about to add. A 40 KB date picker library means something different on a page that's already shipping 200 KB than on one shipping 2 MB. Checking first doesn't mean never adding a dependency. It means the decision is made with the real number in front of you, not a vague sense that "it's probably fine."
2. Reach for the platform before reaching for a library
Modals used to be one of the more expensive things I'd casually build: a backdrop div, a focus trap, an escape key handler, scroll locking on the body, all wired together by hand or, more often, pulled in from a library that did all of it plus a hundred features I didn't need.
The <dialog> element has had solid cross-browser support since 2022, and it handles most of that for free.
<!-- What used to require a library -->
<dialog id="confirm-dialog">
<p>Delete this item?</p>
<button id="cancel">Cancel</button>
<button id="confirm">Delete</button>
</dialog>
<button id="open-dialog">Delete item</button>
<script>
const dialog = document.getElementById('confirm-dialog');
document.getElementById('open-dialog').addEventListener('click', () => dialog.showModal());
document.getElementById('cancel').addEventListener('click', () => dialog.close());
</script>
Calling dialog.showModal() gets you the backdrop, focus trapping, top-layer rendering above everything else on the page, and Escape-to-close, all handled by the browser. No z-index war, no focus trap library, no separate accessibility audit finding what the hand-built version missed. The habit isn't "never use a library." It's checking whether the platform already solved this specific problem before adding forty KB to solve it again.
3. Load what's needed, not what might be needed
A <script> tag in the <head> with no attributes blocks the browser from doing anything else until that file downloads and runs. I used to write those by default and only notice the blocking behavior once a page felt sluggish enough to investigate.
<!-- Blocks rendering until this loads and executes -->
<head>
<script src="analytics.js"></script>
</head>
<!-- Downloads in parallel, runs after the HTML is parsed -->
<head>
<script src="analytics.js" defer></script>
</head>
defer alone fixes the render-blocking problem for most scripts. For code that isn't needed until later, like a component behind a click or a tab that's not visible on load, a dynamic import defers the actual download too.
// Only fetched when someone actually opens the settings panel
button.addEventListener('click', async () => {
const { openSettingsPanel } = await import('./settings-panel.js');
openSettingsPanel();
});
Neither of these is a refactor I do after noticing a slow load. They're the default way I write a script tag or an import now, the same way const became the default over var.
4. Treat images as a budget, not an afterthought
Images are usually the single biggest thing on a page, and for a long time I treated them as a last step: drop the file in, write the src, move on. The size, format, and dimensions all got decided by whatever the design file happened to export.
<!-- What I used to ship -->
<img src="hero-photo.jpg">
<!-- What I check for now, every time an image goes in -->
<img
src="hero-photo.jpg"
srcset="hero-photo-480.jpg 480w, hero-photo-960.jpg 960w, hero-photo-1600.jpg 1600w"
sizes="(max-width: 600px) 480px, (max-width: 1200px) 960px, 1600px"
width="1600"
height="900"
loading="lazy"
alt="A description of what's actually in the photo">
srcset and sizes mean a phone downloads a 480px image instead of the same 1600px file a desktop gets. width and height reserve the space before the image loads, which is most of what keeps Cumulative Layout Shift under the 0.1 threshold Google treats as good. loading="lazy" skips the download entirely for images below the fold until someone scrolls near them. None of this is exotic. It's four attributes and a srcset, decided at the moment the image goes in rather than discovered later in a performance audit.
5. Measure before you guess
I used to have strong opinions about what was slow on a page, and they were usually wrong. The animation felt janky, so I'd assume it was the animation. The page felt sluggish on load, so I'd assume it was the biggest image. Guessing feels like progress. It's usually just motion in a random direction.
// A real measurement instead of a guess
performance.mark('data-fetch-start');
const data = await fetchDashboardData();
performance.mark('data-fetch-end');
performance.measure('data-fetch', 'data-fetch-start', 'data-fetch-end');
console.log(performance.getEntriesByName('data-fetch')[0].duration);
For the metrics that actually matter to real users, DevTools' Performance panel will show the specific element responsible for Largest Contentful Paint, the metric for how long the biggest visible piece of content takes to render. Good is under 2.5 seconds. If that element turns out to be a font-loading heading instead of the hero image I assumed was the problem, the fifteen minutes I would have spent compressing that image was going to fix nothing. Measuring first means the time spent optimizing actually goes toward the thing that was slow.
6. Let the browser do the layout work it's already good at
For years I animated position the same way I'd set it in a stylesheet: top and left, sometimes margin. All three trigger the same layout recalculation. It worked, but "worked" and "smooth" aren't the same thing, especially on a mid-range phone with a dozen other things competing for the main thread.
/* Forces the browser to recompute layout on every frame */
.card {
position: relative;
transition: top 0.3s ease;
}
.card:hover {
top: -8px;
}
/* Handled entirely by the compositor, no layout recalculation */
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-8px);
}
top and left sit in the layout stage of the rendering pipeline: the browser has to recompute where every affected element sits, then repaint, on every single frame. transform and opacity skip both steps and get handled on the compositor, which is why the second version stays smooth even when the main thread is busy with something else. Same visual result, one version asks the browser to do far less work to produce it.
7. Run one performance budget check before every commit
Performance regressions almost never arrive as one dramatic change. They arrive as a 30 KB dependency here, an unoptimized image there, each one individually reasonable, none of them ever really addressed until a bundle analyzer or a Lighthouse run several months later delivers the bad news all at once.
// A budget check that runs as part of the build, not as a separate audit
import { readFileSync } from 'fs';
const bundle = readFileSync('dist/main.js');
const sizeInKB = bundle.length / 1024;
const budget = 150;
if (sizeInKB > budget) {
console.error(`Bundle is ${sizeInKB.toFixed(1)} KB, budget is ${budget} KB`);
process.exit(1);
}
A few lines like this, wired into a build script or a CI step, turn "the bundle got big somehow" into "this specific commit added 40 KB, here's why." It doesn't catch everything. It won't tell you if an interaction feels laggy or if your Largest Contentful Paint element is the wrong one. But it catches the slow, compounding kind of regression while it's still one commit, back when the fix is removing a line instead of untangling six months of small additions.
None of these habits are big undertakings on their own. Checking a Network tab before adding a dependency takes ten seconds. Writing defer instead of leaving it off costs nothing. That's really the point: optimizing later feels necessary because performance got treated as a separate concern from the actual writing of the code. Treat it as part of the same decision you're already making, when you add the script tag, when you drop in the image, when you write the transition, and there's no separate phase left to schedule.
Go throttle your connection again sometime, on something you're building right now. Not to feel bad about what you find. Just to see it once, the way someone on a slower connection already does, every day, whether you ever open that dropdown or not.
Did you learn something good today as a developer?
Then show some love.
© Muhammad Usman
Don’t forget to subscribe to Developer’s Journey to show your support.

Top comments (0)