Skip to main content
Back to blog
JavaScriptWebThird-Party ScriptsFrontendPatterns

Catch a third-party value the instant it lands on window

A script you don't control writes its result to a window global at a time you can't predict. Most code races it with a timeout or wastes the main thread polling. Here's the accessor-property trick that turns the write into an event — plus the getter you must not forget, and how to substitute the value entirely.

Published July 27, 20269 min read

Sooner or later you integrate a script you don't own — an attribution SDK, a consent tool, an A/B framework, a deep-link resolver dropped in through a tag manager — and it communicates back to your page by assigning a value to a global on window. Something like window.SMART_LINK_RESULT = { url: … }. Your job is to read that value and act on it as soon as it exists.

The catch: you have no control over when the assignment happens. It could be before your code runs, a few hundred milliseconds after, or — if the network is slow or the script never loads — not at all. This article walks through the two approaches everyone reaches for first, why they're both compromises, and a third one that is strictly better: intercepting the assignment itself with an accessor property.

The setup: a value that arrives whenever it wants

Concretely, imagine a lightweight redirect page. A tag manager injects a script that computes the correct, fully-attributed destination URL and, when it's done, writes it to window.SMART_LINK_RESULT. You want to redirect to that URL the moment it's available, and fall back to a plain default URL if it never shows up.

The value is a small object, and we don't own the script that produces it — we only know the shape we expect:

global.d.ts
// A third-party script assigns this global once it has computed a result.
// We don't own the script, only the type we expect on the window.
interface SmartLinkResult {
  url?: string;
  [key: string]: unknown;
}

declare global {
  interface Window {
    SMART_LINK_RESULT?: SmartLinkResult;
  }
}

export {};

Attempt 1: a fixed timeout

The first instinct is to wait a "safe" amount of time for the script to finish, then read the global once and go:

attempt-1-timeout.ts
const FALLBACK_URL = "https://example.com/download";

// Wait a "safe" amount of time, then read the global once and redirect.
setTimeout(() => {
  const url = window.SMART_LINK_RESULT?.url;
  window.location.href = url?.trim() ? url : FALLBACK_URL;
}, 3000);

It works often enough to ship, and then quietly costs you. A single read behind a fixed delay is a race you lose in both directions:

  • Too early: if the script hasn't written the value by the 3-second mark, you read undefined and redirect to the fallback — throwing away the correct URL that would have arrived a moment later.
  • Too late: if the script finished in 200 ms, you still make the user stare at a blank redirect page for the full 3 seconds before doing anything.
  • The "right" delay is unknowable, because it depends on the network and the third party — so you end up picking a number that's simultaneously too long for fast clients and too short for slow ones.

Attempt 2: polling

The obvious fix for the "too late" half is to stop waiting for a fixed moment and instead check repeatedly until the value appears, with a deadline for giving up:

attempt-2-polling.ts
const FALLBACK_URL = "https://example.com/download";
const DEADLINE = 3000;
let elapsed = 0;

const timer = setInterval(() => {
  const url = window.SMART_LINK_RESULT?.url;

  if (url?.trim()) {
    clearInterval(timer);
    window.location.href = url;           // finally showed up
  } else if ((elapsed += 50) >= DEADLINE) {
    clearInterval(timer);
    window.location.href = FALLBACK_URL;  // gave up waiting
  }
}, 50);

This is better — it reacts within one interval of the value arriving instead of waiting the whole delay. But it's still a compromise: you're running a timer that does nothing useful on most of its ticks, you've traded latency for a tunable interval (too coarse feels laggy, too fine burns the main thread), and you're busy-reading a property on a hot loop for something that happens exactly once.

The trick: intercept the write itself

Both approaches treat the assignment as something to discover after the fact — by guessing when it happened, or by asking over and over whether it happened yet. But an assignment to a property is something JavaScript will happily tell you about, if you define the property as an accessor before the script runs. Replace the plain data slot at window.SMART_LINK_RESULT with a getter/setter pair:

interceptor.ts
// Store the current value in a closure so we control what the property holds.
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    value = next; // keep it readable for whoever assigned it
    if (next?.url?.trim()) {
      window.location.href = next.url; // fires the instant the script writes
    }
  },
});

Now the moment the third-party script executes window.SMART_LINK_RESULT = …, it isn't writing to a plain slot — it's calling your set function, synchronously, with the value as its argument. No delay, no polling, no guessing. You react at the exact instant the value exists, and not a tick sooner or later. The timeout stops being your primary mechanism and becomes what it should have been all along: a fallback for the case where the value never comes.

A fixed timeout guesses when the value arrives. Polling asks repeatedly whether it has. An accessor property is simply told — the assignment becomes a synchronous callback, with zero latency and zero wasted work.

Why the getter is not optional

It's tempting to define only a set — after all, reacting to the write is the whole point. Don't. An accessor property with a setter but no getter returns undefined on every read. The third-party script (and anything else on the page) often reads its own global back — to check it, to update a field on it, to hand it to another module. A set-only accessor silently breaks all of that:

set-only.ts
// A set-only accessor silently swallows the value on read.
Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  set(next: SmartLinkResult | undefined) {
    if (next?.url) window.location.href = next.url;
  },
});

// The third-party script does its normal thing:
window.SMART_LINK_RESULT = { url: "/go" };

// ...but now anyone reading it back — including the script itself — sees nothing:
console.log(window.SMART_LINK_RESULT); // undefined

The rule is simple: if you intercept writes, you must also serve reads. Keep the last value in a closure variable, return it from get, and update it in set. To everyone else the property behaves exactly like the plain data property it replaced — you've only added a side effect on assignment.

Close the race the other way: the early check

An interceptor only catches writes that happen after you install it. If the third-party script is fast — or was inlined ahead of your code — the value might already be sitting on the window by the time you run. So check for it first, and only install the accessor if it isn't there yet:

early-check.ts
// The value may already be on the window before our code runs.
// Check first, and only install the interceptor if it isn't there yet.
const existing = window.SMART_LINK_RESULT?.url;

if (existing?.trim()) {
  window.location.href = existing;
} else {
  installInterceptor(); // the Object.defineProperty from above
}

With the early check and the setter together, you've covered the full timeline: the value was already there (read it now), or it arrives later (the setter fires), or it never arrives (the fallback timer). There's no window in which a real value can slip past you.

Substituting the value, not just watching it

Because the property's storage now lives in your closure, you're not limited to observing the value — you decide what it actually holds. That's the difference between a listener and an interceptor. You can normalize a malformed payload, strip a field, or replace the third party's default with your own before anyone downstream ever reads it:

substitute.ts
let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

Object.defineProperty(window, "SMART_LINK_RESULT", {
  configurable: true,
  get() {
    return value;
  },
  set(next: SmartLinkResult | undefined) {
    // Don't store what they sent verbatim — normalize it, or replace it entirely.
    value = normalize(next) ?? { url: FALLBACK_URL };
  },
});

Whatever the script assigns flows through your set, and whatever your get returns is what the rest of the page sees. The default the script intended to install never has to take effect — only the value you choose to keep.

Cleaning up: put the property back

An accessor installed on a global outlives the component that added it, so tear it down when you're done — especially in single-page apps, React Strict Mode, or anywhere the same code can run twice. Delete the accessor and, if there was a value, restore it as an ordinary data property:

teardown.ts
function uninstall() {
  const current = value;          // whatever the accessor last held
  delete window.SMART_LINK_RESULT; // remove the getter/setter pair
  if (current !== undefined) {
    // Put it back as an ordinary data property so nothing downstream breaks.
    window.SMART_LINK_RESULT = current;
  }
}

Restoring a plain property (rather than leaving a dangling getter/setter, or deleting the value outright) means anything that reads the global afterwards gets normal behavior back. Setting configurable: true when you defined the property is what makes this delete possible in the first place — without it, the accessor is permanent.

Putting it together

Here's the whole pattern as a single React hook: early check, interceptor with a matching getter, a fallback timer, and cleanup that restores a plain property. A done latch guarantees the redirect fires exactly once no matter which path wins.

useSmartLinkRedirect.ts
import { useEffect } from "react";

const FALLBACK_URL = "https://example.com/download";
const FALLBACK_DELAY = 3000;

export function useSmartLinkRedirect() {
  useEffect(() => {
    let done = false;
    let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
    let value: SmartLinkResult | undefined = window.SMART_LINK_RESULT;

    const go = (url: string) => {
      if (done) return;
      done = true;
      if (fallbackTimer) clearTimeout(fallbackTimer);
      window.location.href = url;
    };

    // 1. Already resolved before we mounted? Go now.
    if (value?.url?.trim()) {
      go(value.url);
      return;
    }

    // 2. Otherwise, catch the assignment the moment it happens.
    Object.defineProperty(window, "SMART_LINK_RESULT", {
      configurable: true,
      get() {
        return value;
      },
      set(next: SmartLinkResult | undefined) {
        value = next;
        if (next?.url?.trim()) go(next.url);
      },
    });

    // 3. Never trust the third party to always deliver.
    fallbackTimer = setTimeout(() => go(FALLBACK_URL), FALLBACK_DELAY);

    // 4. Restore a plain property on unmount.
    return () => {
      if (fallbackTimer) clearTimeout(fallbackTimer);
      const current = value;
      delete window.SMART_LINK_RESULT;
      if (current !== undefined) window.SMART_LINK_RESULT = current;
    };
  }, []);
}

The same shape works outside React — the moving parts are a closure variable, an Object.defineProperty with get/set, an early check, a fallback, and a teardown. In a plain module you'd wrap it in a function and call the teardown on pagehide or whenever your feature ends.

Timeout vs polling vs interceptor

ConcernFixed timeoutPollingAccessor interceptor
Reacts the instant the value is setNo — waits the full delayAlmost — within one intervalYes — synchronous
Wasted time when the value is earlyThe whole delayUp to one intervalNone
Main-thread cost while waitingNoneRepeated reads on a timerNone
Catches a value set before you startYes (reads once at the end)YesOnly with an explicit early check
Can substitute or normalize the valueNoNoYes
Handles the value never arrivingYes (reads, falls back)Yes (deadline branch)Yes (with a fallback timer)

The interceptor wins every row that's about reacting to the write, and ties the rest once you add the early check and the fallback timer it needs anyway. The one thing to remember is that it must be installed before the third-party script runs — which, for a value delivered by a tag manager or an async SDK, is almost always the case if you install it early in your page's lifecycle.

Gotchas worth knowing

  • Install order matters. The interceptor only catches writes that come after it. Define the accessor as early as possible, and always pair it with the early check for the value that beat you to it.
  • Always set configurable: true. Without it you can't delete the property to clean up, and a second attempt to redefine it throws.
  • Guard for SSR. There's no window on the server. In a framework, run this in a client-only effect; in a plain module, guard with "undefined" !== "undefined".
  • One interceptor per property. If two parts of your app both redefine the same global, the second clobbers the first. Centralize it, or check Object.getOwnPropertyDescriptor(window, name) before installing.
  • Some globals are already accessors. A property defined by the environment as non-configurable (or with its own getter/setter) can't be replaced — check the descriptor first and fall back to polling if you truly can't redefine it.
  • Keep get and set cheap. They run on every read and write of the global. Do the heavy work once, behind your done latch — not on every access.

When to reach for this

This is a precision tool, not a default. It earns its keep when all of these are true:

  • A value you need is written to a global (or any object property) by code you don't control.
  • The timing is unpredictable and you want to react with no latency — or you need to substitute the value before anything else reads it.
  • You can run your code before the writer does, or you're willing to pair it with an early check for the case you can't.

Takeaways

  • A third party often hands you a value by assigning a global at a time you can't predict; a fixed timeout races it and polling burns the main thread.
  • Object.defineProperty(window, name, { get, set }), installed before the writer runs, turns the assignment into a synchronous callback — zero latency, zero wasted work.
  • Never ship a set-only accessor: pair every set with a get that returns the stored value, or you silently break the writer's own read-back.
  • Add an early check for a value that was set before you installed the accessor, and a fallback for a value that never arrives.
  • Because you own the storage, you can normalize or replace the value — an interceptor, not just a listener.
  • Set configurable: true and restore a plain data property on cleanup so nothing downstream is left with a surprise.

None of this is exotic — it's just Object.defineProperty pointed at a global instead of your own objects. But reframing "wait for a value" as "be told when the value is written" removes an entire class of timing bugs, and hands you the value early enough to change it. The next time you find yourself picking a magic timeout number to wait out a third-party script, reach for the setter instead.