A third-party widget runs inside someone else’s product.
That changes the engineering priority.
If the widget API is slow, unavailable, misconfigured, or returns unexpected data, the customer’s website must continue working normally. The safest failure mode is often to render nothing at all.
I encountered these constraints while building the announcement widget for SoloOps Dock, a lightweight public ops tool for solo SaaS founders.
The widget is intentionally small:
- no framework runtime
- no external dependencies
- no arbitrary HTML rendering
- no requirement for the host application to wait for it
- no assumption that the network, browser, or configuration is perfect
The goal is not to make failure impossible.
The goal is to contain failure inside the widget boundary.
The host website must come first
An embedded widget has a different trust boundary from application code you fully control.
It may run on:
- a Laravel application
- a static site
- a React or Vue application
- a WordPress site
- a page with aggressive global CSS
- a page with a restrictive Content Security Policy
- a slow mobile connection
- a browser with limited API support
The widget also depends on a separate public API. That API may temporarily fail even when the host application is healthy.
If the widget throws an uncaught exception, blocks rendering, injects unsafe markup, or leaves stale incident information visible, users may blame the host product rather than the widget provider.
For that reason, I defined a few rules before implementing the UI.
- A widget failure must not become a host-page failure.
- Missing configuration should be treated as a normal condition.
- Network requests must have hard time limits.
- Remote content must not be rendered as trusted HTML.
- Widget styles should not leak into the host page.
- Temporary network failures and stale operational content must be handled differently.
- The host application must never depend on the widget being available.
Those rules shaped almost every implementation decision.
Start with a defensive bootstrap
The widget is loaded with a script tag similar to this:
<script
async
src="https://example.com/widget.js"
data-project="public_project_key">
</script>
The bootstrap code does as little as possible before validating its environment.
A simplified version looks like this:
(function () {
'use strict';
try {
const script = document.currentScript;
if (!script) {
return;
}
const publicKey = script.getAttribute('data-project');
if (!publicKey || publicKey.length < 10) {
return;
}
fetchAndSync(publicKey);
} catch {
// Contain the failure inside the widget.
}
})();
There are a few deliberate choices here.
Use an IIFE
An immediately invoked function expression keeps internal variables out of the global scope.
Third-party scripts should avoid creating names that may conflict with the host application.
Validate configuration before doing work
A copied embed snippet may be incomplete. A template may accidentally remove a data attribute. A developer may paste the script before creating the corresponding project.
These are expected operational mistakes, not exceptional events that justify breaking the page.
Catch at the outer boundary
Catching everything at the top is not a replacement for proper error handling inside individual functions.
It is the final containment boundary.
If an unexpected browser behavior or coding mistake escapes lower-level handling, the widget should still fail without affecting the host application.
Put a hard time limit on network requests
A normal fetch() call does not give the product-level guarantee I wanted.
The widget is optional UI. It should not keep waiting indefinitely for a remote service.
I use AbortController to enforce a timeout:
async function fetchAnnouncement(url) {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
controller.abort();
}, 2500);
try {
const response = await fetch(url, {
signal: controller.signal,
cache: 'no-store',
});
if (!response.ok) {
return null;
}
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
The exact timeout is a product decision, not a universal constant.
For this widget, 2.5 seconds is already generous. The announcement bar is not required for the host page to function, so waiting much longer provides little value.
The important behavior is:
- abort slow requests
- treat non-2xx responses as unavailable data
- treat invalid JSON as unavailable data
- do not show a widget-specific error screen
- do not throw into the host page
A failed announcement request should not produce a large red error banner on the customer’s product. That would turn an optional communication feature into a visible outage.
Temporary failure and stale content are different problems
Immediately removing a widget after one failed request creates another problem: flicker.
A user may briefly lose connectivity. A CDN edge may have a short error. A browser may abort a request while the tab is changing state.
If the widget disappears after every isolated failure, the interface becomes unstable.
The implementation therefore keeps track of the last successful synchronization.
A simplified version:
let lastSuccessfulSyncAt = Date.now();
const REVALIDATE_INTERVAL_MS = 60_000;
const STALE_MAX_AGE_MS = 180_000;
function handleSuccessfulSync(data) {
lastSuccessfulSyncAt = Date.now();
syncWidget(data);
}
function handleFailedSync() {
const staleFor = Date.now() - lastSuccessfulSyncAt;
if (staleFor >= STALE_MAX_AGE_MS) {
removeWidget();
}
}
The flow is:
Successful API response
↓
Render or update the widget
↓
A temporary request fails
↓
Keep the current widget for a short grace period
↓
The API remains unreachable
↓
Remove the stale widget
This distinction matters for operational messages.
A short network failure should not immediately hide a useful announcement.
But an old message such as “Major outage in progress” must not remain visible indefinitely after the widget can no longer confirm that it is current.
The widget revalidates every 60 seconds and removes mounted content after three minutes without a successful response.
These values can change, but the broader principle is stable:
Preserve the current state during brief uncertainty, then remove it when it can no longer be trusted.
Why the announcement response uses no-store
Caching is usually desirable for a public read-only API.
Announcement visibility, however, is highly state-sensitive.
The response may change because:
- a scheduled announcement reaches its start time
- an announcement reaches its end time
- the project is made private
- the active announcement changes
- the widget is disabled
- the account loses eligibility
- the owner removes or edits the announcement
An earlier version allowed the response to remain cached. That introduced two opposite failure modes.
- A previously visible announcement could remain visible after it should have been hidden.
- A previously hidden response could delay a newly activated announcement.
For operational communication, both are trust problems.
The current public endpoint returns:
Cache-Control: no-store
The browser request also uses:
fetch(url, {
cache: 'no-store',
});
This increases API traffic compared with a cached endpoint, but it keeps visibility decisions current.
The trade-off is intentional:
For operational messages, correctness of the current state is more important than maximizing cacheability.
A more advanced implementation could safely reintroduce caching by:
- limiting TTL to the next scheduled state transition
- purging cache entries when project or announcement state changes
- separating public content from eligibility state
- using versioned response URLs
For a small MVP, no-store is the simpler and safer boundary.
Isolate styles with Shadow DOM
A third-party widget has two CSS problems.
First, its styles may affect the host page.
Second, the host page may break the widget.
Global selectors such as these are common:
button {
border: 0;
}
p {
margin: 0;
}
* {
box-sizing: border-box;
}
A widget that assumes a clean environment may render differently on every site.
When available, the widget attaches a closed Shadow DOM:
const container = document.createElement('div');
container.id = 'sod-widget';
const root = container.attachShadow
? container.attachShadow({ mode: 'closed' })
: container;
const style = document.createElement('style');
style.textContent = getWidgetStyles();
root.appendChild(style);
root.appendChild(buildWidgetContent());
document.body.appendChild(container);
This isolates the widget’s style tree from most host-page CSS.
The fallback uses the normal DOM for older environments, so the widget can still render even when Shadow DOM is unavailable.
There is an important distinction:
Shadow DOM isolates styling. It does not sandbox JavaScript execution.
The script still runs in the host page’s JavaScript context. It can access browser APIs and the DOM because it is not inside an iframe sandbox.
The implementation deliberately limits itself to:
- reading attributes from its own script element
- creating its own container
- reading its own local storage keys
- calling its own public API
That is a behavioral restriction in the code, not a browser-enforced security boundary.
Render text, not HTML
The API response is produced by my own backend, but I still treat it as untrusted at the rendering boundary.
The widget does not assign remote content to innerHTML.
Instead, it creates elements and uses textContent:
const title = document.createElement('p');
title.className = 'widget-title';
title.textContent = data.title || '';
const body = document.createElement('p');
body.className = 'widget-body';
body.textContent = data.body || '';
This prevents strings such as the following from becoming executable markup:
<img src=x onerror=alert(1)>
On the server, Markdown is converted into a plain-text excerpt before it reaches the widget payload.
The public page can render sanitized Markdown, but the embedded announcement bar has a narrower responsibility. It only needs a short text summary.
The payload also applies explicit limits:
- title: up to 80 characters
- body excerpt: up to 160 characters
- link label: up to 40 characters
- link URL: only
httporhttps
Links open in a new tab with:
link.target = '_blank';
link.rel = 'noopener noreferrer';
The general lesson is simple:
A public API response should still be treated as untrusted input when it is rendered inside a customer’s page.
Avoid duplicate mounts and unnecessary re-renders
Embed scripts may be included twice by mistake.
Single-page applications may execute lifecycle code more than once.
Periodic revalidation may return the same announcement repeatedly.
The widget therefore checks for an existing container before mounting:
function renderWidget(data) {
if (document.getElementById('sod-widget')) {
return;
}
// Create and append the widget.
}
It also tracks the current announcement identifier and update timestamp.
let currentAnnouncementId = null;
let currentUpdatedAt = null;
When a response arrives:
-
show: falseremoves the current widget - a different announcement ID triggers replacement
- a changed
updated_atvalue triggers replacement - an unchanged response does nothing
- a missing widget with
show: truemounts it
That prevents duplicate UI and unnecessary DOM churn.
Make dismissals version-aware
A dismissible announcement needs persistence.
The obvious implementation is:
dismissed:{announcement_id}
That is incomplete.
Suppose the owner edits the announcement after a user dismisses it. The updated message may contain important new information, but the user will never see it because the old dismissal still applies.
The widget includes the announcement version in the storage key:
const dismissKey = [
'widget:dismissed',
projectKey,
announcement.id,
announcement.updated_at,
].join(':');
The user’s dismissal applies only to that specific version.
If the owner edits the message, updated_at changes and the new version can appear again.
This is a small implementation detail, but it makes the difference between “dismiss this message” and “never show this announcement again.”
Keep installation verification separate from display delivery
The public announcement key is intentionally safe to expose. It allows read-only access to the announcement payload.
Installation verification is a different concern.
The widget may need to prove that it has been installed on the configured site, but a copied public key alone should not be enough to mark an installation as verified.
The implementation separates the two workflows:
Public project key
→ read-only announcement delivery
Short-lived installation challenge
→ one-time installation verification
Heartbeat credential
→ ongoing last-seen updates
The installation workflow uses:
- a short-lived, one-time challenge
- browser-generated random credentials
crypto.getRandomValues- periodic heartbeats after successful verification
- host validation on the server
It does not fall back to Math.random() for credential generation.
The full verification flow involves retry safety, cross-tab coordination, challenge consumption, token rotation, and transactional server updates. That deserves a separate article.
The important architectural decision here is that display delivery remains simple even when verification cannot run.
A browser that cannot complete installation verification should still be able to display an announcement.
Features I deliberately did not add
A third-party widget can easily grow into a small frontend platform.
For the first version, I deliberately avoided:
- React or Vue runtime dependencies
- arbitrary custom HTML
- customer-provided JavaScript callbacks
- advanced targeting rules
- per-visitor analytics
- complex animation
- multiple visual widget types
- automatic incident creation
- deep access to the host application
These features may be useful later, but each increases one or more of:
- bundle size
- security surface
- support load
- integration complexity
- risk of breaking customer sites
The product goal was not to build the most customizable announcement system.
It was to provide a small, predictable operational communication layer for solo SaaS products.
Test the failure paths, not only the happy path
The most valuable widget tests are often the cases where nothing should happen.
Bootstrap cases
- the script element cannot be resolved
-
data-projectis missing - the public key is malformed
- the script is loaded twice
Network cases
- the API returns 500
- the response body is invalid JSON
- the request times out
- the browser is offline
- CORS blocks the request
- the API remains unavailable beyond the stale threshold
Rendering cases
- the title is empty
- the body is empty
- the payload contains HTML-like text
- the link uses an unsupported URL scheme
- the host page has aggressive global CSS
- Shadow DOM is unavailable
State transition cases
-
show: truebecomesshow: false - the active announcement changes
- the message is edited
- the end time is reached
- the user dismisses a message and the owner later edits it
- the widget is disabled while a message is visible
The expected result is not always “the widget is visible.”
Often, the correct result is:
Nothing is rendered.
No exception escapes.
The host application continues normally.
Trade-offs and current limitations
This design contains failures, but it does not eliminate every risk.
Shadow DOM is not a sandbox
The widget script runs in the host page context. Style isolation should not be described as full execution isolation.
Revalidation is not real-time
The client checks for changes periodically. Updates may take up to one polling interval to appear.
no-store increases API traffic
Fresh visibility state is prioritized over browser and intermediary caching.
Local storage may be unavailable
The main announcement can still render, but dismiss persistence and installation heartbeat behavior may be limited.
The host site must allow the connections
A restrictive Content Security Policy may need to allow:
- the widget script origin in
script-src - the public API origin in
connect-src
JavaScript can always fail
The design goal is not “this code can never fail.”
The goal is:
If the widget fails, the failure should remain optional, local, and invisible to the host application’s core workflow.
Conclusion
Third-party widgets live in a trust-sensitive environment: someone else’s product.
Their most important behavior is not what happens when everything works.
It is what happens when:
- the API is slow
- the configuration is wrong
- the browser lacks a feature
- the network disappears
- the response is malformed
- cached operational state becomes stale
The design principles I would reuse are:
Validate early.
Time out quickly.
Render text, not HTML.
Isolate styles.
Do not trust stale operational state.
Keep temporary failures temporary.
Remove UI when it can no longer be trusted.
Never make the host application depend on the widget.
The widget itself is not the core application. That is precisely why it needs to behave responsibly when everything around it goes wrong.
Disclosure: I used AI assistance to review the English wording and article structure. The technical decisions and implementation details are based on my own development work and were reviewed by me before publication.

Top comments (0)